mirror of
https://github.com/wiseguru/ReWrite-Voice-Notes.git
synced 2026-07-22 07:49:19 +00:00
All release testing now happens against the published -alpha/-beta prerelease assets installed in the test vault (attested CI builds), and cutting the stable release is only the version bump + bare tag on the same source. Doc-only commits may ride between the last prerelease and the stable tag; anything affecting the built artifact means a new prerelease round. Updated: RELEASING.md (TL;DR, pre-flight, "The alpha/beta channel"), release-checklist SKILL.md (Phases 2/3/5 install the prerelease via gh release download; release:prep stays as the mid-development path), CHECKLIST.md intro, CLAUDE.md summaries. Also corrected RELEASING.md's stale description of version-bump.mjs (it records every version, not only floor changes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
477 lines
129 KiB
Markdown
477 lines
129 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## Project state
|
||
|
||
This is the **ReWrite (Voice Notes) plugin for Obsidian**: record or paste speech, transcribe via a user-configured provider, clean and structure via an LLM, insert per a chosen template. Desktop and mobile.
|
||
|
||
The v1 implementation is feature-complete against [obsidian-voice-notes-spec.md](obsidian-voice-notes-spec.md) and [docs/IMPLEMENTATION_PLAN.md](docs/IMPLEMENTATION_PLAN.md). The spec is still the source of truth for behavior; the implementation plan resolves the spec's internal discrepancies (notably manifest id, base URL handling for `openai-compatible`, and mobile `safeStorage` unavailability). [docs/ROADMAP.md](docs/ROADMAP.md) is the live lifecycle tracker (Planned / Unreleased / Released, one entry per feature and fix); consult it when picking up work or before changing anything cross-cutting. It replaced the old per-phase `docs/claude-scratch/STATUS.md` build tracker, which has been retired.
|
||
|
||
When extending the plugin, follow the file layout the spec prescribes: provider adapters under `src/transcription/` and `src/llm/`, factories in each `index.ts`, no provider-specific logic leaking outside its own file.
|
||
|
||
## Documentation maintenance
|
||
|
||
Update CLAUDE.md with every behavioral change. When modifying code that this document describes (pipeline stages, command IDs, settings keys, gotchas, conventions), update CLAUDE.md in the same change. If a behavioral change has no existing section, add one or drop a note under "Gotchas". Treat the doc update as part of the task, not a follow-up.
|
||
|
||
Some subsystems are documented in depth in linked `docs/` files (currently [docs/DIARIZATION.md](docs/DIARIZATION.md), [docs/SECRETS.md](docs/SECRETS.md), [docs/WHISPER_HOST.md](docs/WHISPER_HOST.md), [docs/DEV_TOOLING.md](docs/DEV_TOOLING.md)) with only a summary + pointer left here. The same rule applies: when you change behavior covered by one of those files, update the linked doc in the same change AND keep the CLAUDE.md summary accurate. Each linked doc restates this at its top.
|
||
|
||
The user-facing guide to the template format is the wiki page **`wiki/Creating-Templates.md`** (no longer a file seeded into the vault; the old in-vault `Template guide.md` and its `DEFAULT_TEMPLATE_GUIDE` constant were removed in favor of the wiki). Any change to the template structure (a new/changed `NoteTemplate` field or frontmatter property, the prompt-assembly order, the default-template set or count, or what the shared core carries) must update that wiki page in the same change: add the property to its frontmatter table AND give the behavior its own `##` section. See the Templates section for the full rule.
|
||
|
||
The user-facing reference docs live in the **wiki** (the [`wiki/` folder](wiki/), mirrored to the GitHub Wiki; see the Wiki section below) and in the slimmed root [README.md](README.md). Both are in the in-sync set: any behavioral change a user can observe (a settings field, a command, a provider, a self-hosting step, a limit, a default-template change) must update the relevant wiki page AND the README quick-start/links if affected, in the same change. The `wiki/` source is canonical; never hand-edit the GitHub Wiki, which is a generated mirror.
|
||
|
||
**Reorganize, do not just append.** For every doc in the in-sync set (CLAUDE.md, the linked `docs/` files, the template guide, the wiki, the README), when a change touches documented behavior, first ask whether the doc's *structure* should change to absorb the new material coherently (split an overgrown page, move a topic to a better-fitting page, retitle a section, update `wiki/_Sidebar.md` and `Home.md`'s table of contents) and do that reorganization as part of the same change. Blindly appending a paragraph to the nearest section is a documented anti-pattern here; the goal is docs that stay well-organized as features accrue, not a growing pile of addenda.
|
||
|
||
## Wiki
|
||
|
||
User-facing documentation lives in [`wiki/`](wiki/) as one Markdown file per page. It is the canonical source; a GitHub Action ([.github/workflows/wiki-sync.yml](.github/workflows/wiki-sync.yml)) mirrors the folder to the separate `ReWrite-Voice-Notes.wiki.git` repo on every push to `master` that touches `wiki/`. The wiki repo is generated output; never edit it by hand. One-time setup (already done): the Wiki feature must be enabled and have a first page created in repo settings before the Action can push.
|
||
|
||
GitHub wiki conventions: filenames become page names (spaces as hyphens, no `.md` in links), `Home.md` is the landing page, `_Sidebar.md` is the nav rail, `_Footer.md` the footer. Internal links use the page name, e.g. `[Quick start](Quick-Start)`.
|
||
|
||
Page map (keep `_Sidebar.md` and `Home.md`'s contents list in sync when adding/removing pages):
|
||
- `Home.md`, `_Sidebar.md`, `_Footer.md`: navigation.
|
||
- `Quick-Start.md`: install, Populate, first note (Daily note example). Overlaps with the README quick-start by design.
|
||
- `Settings-Reference.md`: every settings section (driven by [src/settings/tab.ts](src/settings/tab.ts)).
|
||
- `Commands-and-Menus.md`: command palette, ribbon, editor/file menus, Quick Record UI ([src/main.ts](src/main.ts)).
|
||
- `Creating-Templates.md`: template file format + authoring guide. This is the canonical user-facing template guide (it replaced the old in-vault `Template guide.md`); keep it in sync with the `NoteTemplate` schema.
|
||
- `Providers.md`: provider tables, model selection, base-URL conventions, diarization, context hint, known nouns, recording limits.
|
||
- `Voxtral-Realtime.md`: the reverse-engineered Voxtral realtime WebSocket protocol, the browser-auth caveat (why it is disabled/unwired, not shipped), how a contributor would re-enable and test it, and a contributor ask. Keep in sync with `src/realtime/voxtral.ts`.
|
||
- `Self-Hosting-Whisper.md`: local whisper.cpp host (mirrors user-facing parts of [docs/WHISPER_HOST.md](docs/WHISPER_HOST.md)).
|
||
- `Self-Hosting-LLMs.md`: Ollama/llama.cpp local and remote, plus low-spec model recommendations (refresh these as models age).
|
||
- `Secrets-and-Sync.md`: encryption modes summary + the 7 sync-exclusion recipes.
|
||
- `Mobile.md`: mobile limitations.
|
||
- `Troubleshooting.md`: triage that routes into the per-page detail.
|
||
|
||
When you move user content out of the README, it goes here, and the README's Documentation section links to it. The deep `docs/` files (DIARIZATION/SECRETS/WHISPER_HOST) stay developer-facing; the wiki summarizes their user-relevant parts and points at them where useful.
|
||
|
||
## Commands
|
||
|
||
```bash
|
||
npm install # install deps
|
||
npm run dev # esbuild watch mode → bundles src/main.ts to ./main.js with inline sourcemaps
|
||
npm run build # tsc -noEmit type-check, then esbuild production (minified, no sourcemaps)
|
||
npm run lint # eslint over the repo (uses eslint-plugin-obsidianmd recommended)
|
||
npm test # vitest run: unit tests for pure/isolable modules (see Testing below)
|
||
npm version <patch|minor|major> # bumps manifest.json + versions.json via version-bump.mjs
|
||
npm run review # advisory local code-review of the current diff via a local llama.cpp model (always exits 0; see docs/DEV_TOOLING.md)
|
||
npm run release:prep # build + copy main.js/manifest.json/styles.css into a scratch vault for the release feature pass
|
||
```
|
||
|
||
Verification is `npm run build && npm run lint && npm test` (CI parity) plus the manual feature pass driven by the **`release-checklist` skill** ([.claude/skills/release-checklist/](.claude/skills/release-checklist/); see [docs/DEV_TOOLING.md](docs/DEV_TOOLING.md)), which replaced the stale checklist in [obsidian-voice-notes-spec.md](obsidian-voice-notes-spec.md).
|
||
|
||
To publish a new version, follow [docs/RELEASING.md](docs/RELEASING.md). The flow is alpha/beta-first: tag `<next-version>-alpha` (no version bump on master), test the published prerelease artifact in a real vault via the `release-checklist` skill, iterate with further suffixed tags as needed, and only on a green pass bump the version + push the bare-version tag (CI-built, attested assets; do not hand-upload). The bump commit (plus doc-only commits) is the only permitted delta between the tested prerelease and the stable tag; artifact-affecting changes mean a new prerelease round.
|
||
|
||
CI ([.github/workflows/lint.yml](.github/workflows/lint.yml)) runs `npm ci`, `npm run build`, `npm run lint`, and `npm test` on Node 20.x and 22.x for every push/PR.
|
||
|
||
### Testing
|
||
|
||
[vitest.config.ts](vitest.config.ts) drives a small [Vitest](https://vitest.dev) suite under [test/](test/) covering pure/isolable logic: `whisper-host` (`getHostArgs`/`isLoopbackHost`/`shouldStopWhenIdle`), `pipeline.extractFromBlock`, `secrets` (passphrase round trip, corrupt-envelope recovery), `settings mergeSettings` (incl. `disabledDefaultTemplateIds` / `ingestRules` sanitization), `templates-folder` (parse/render round trip, `mergeTemplate`, `managed` tri-state), `realtime/pcm` (`downsampleBuffer`/`floatTo16BitPcm`), `ingest` (`isIngestTemplate`/`collectIngestFiles`), `transcription/limits`, and the `version-bump.mjs` guard. There is no UI/integration test layer; UI behavior is still verified manually per the spec checklist.
|
||
|
||
Two things make `src/` runnable under Vitest even though it targets the Obsidian runtime, not Node:
|
||
- **The `obsidian` npm package ships only `.d.ts` files, no runtime JS.** [test/mocks/obsidian.ts](test/mocks/obsidian.ts) is a minimal runtime stand-in (just the classes/functions the tested modules actually import: `Notice`, `Plugin`, `TFile`, `parseYaml`/`stringifyYaml` via the `yaml` package, `normalizePath`, etc.), aliased in via `resolve.alias` in [vitest.config.ts](vitest.config.ts). It is not a full API shim; extend it only as new modules come under test.
|
||
- **`tsconfig.json`'s `baseUrl: "src"` bare imports** (e.g. `from 'passphrase-strength'`) are resolved for Vitest by the `vite-tsconfig-paths` plugin, since Vite/Vitest don't read `baseUrl` on their own (esbuild's bundler does, at build time).
|
||
|
||
[test/tsconfig.json](test/tsconfig.json) is a separate project (extends the root config, adds Node `types`) so ESLint's `projectService` can type-check `test/**` without pulling Node globals into `src/`'s browser-only `lib`. [test/setup.ts](test/setup.ts) polyfills `globalThis.crypto` from `node:crypto` when absent, since `secrets.ts` relies on the Web Crypto global Obsidian's real runtime always provides but older Node does not expose automatically. `secrets.ts` keeps module-level singleton state (parsed envelope, unlocked key), so [test/secrets.test.ts](test/secrets.test.ts) calls `vi.resetModules()` and re-imports it fresh in a `beforeEach` to isolate tests from each other; follow the same pattern for future tests against modules with module-level caches.
|
||
|
||
## Build architecture
|
||
|
||
- Entry: [src/main.ts](src/main.ts) → bundled to `./main.js` at repo root (the file Obsidian loads). Kept minimal: settings load, ribbon icon, two commands, settings tab, plus a single `activeQuickRecord` ref so the Quick Record command can toggle.
|
||
- Bundler: [esbuild.config.mjs](esbuild.config.mjs). `obsidian`, `electron`, all `@codemirror/*`, all `@lezer/*`, and Node built-ins are marked `external`. Never import other runtime deps without bundling them in.
|
||
- Release artifacts are `main.js`, `manifest.json`, and `styles.css` at the repo root. Do not commit the generated `main.js`.
|
||
- TypeScript config ([tsconfig.json](tsconfig.json)) is strict: `noImplicitAny`, `strictNullChecks`, `noImplicitReturns`, `noUncheckedIndexedAccess`, `useUnknownInCatchVariables`, `baseUrl: "src"`. Target ES6, module ESNext, lib DOM + ES2019, `types: []`, `skipLibCheck`. `lib` must stay matched to the runtime APIs the code actually uses (ES2019 covers `Object.entries`/`values`/`fromEntries`, `padStart`, `Promise.finally`; raise it deliberately if you adopt a newer API — Obsidian's WebViews support far newer). `types: []` is load-bearing: without it tsc auto-includes the test suite's `@types/node`, which silently supplied the ES2017+ types the old ES7 lib lacked, and the mismatch surfaced only as `no-unsafe-*` warnings from the Obsidian review bot (see the "Review-bot type environment" gotcha). No Node lib or types, so don't reach for Node APIs in plugin code.
|
||
- ESLint ([eslint.config.mts](eslint.config.mts)) layers `eslint-plugin-obsidianmd`'s recommended rules on top of `typescript-eslint`. These rules encode Obsidian-specific correctness checks; respect them rather than disabling. The config is deliberately tuned to **mirror the Obsidian community-review bot** for the classes that once slipped through to submission (see [docs/RELEASING.md](docs/RELEASING.md)): it enables the type-checked `@typescript-eslint` rules (`no-deprecated`, `no-unsafe-*`, `no-unnecessary-type-assertion`) scoped to `src/`; cherry-picks `no-unsupported-api` from a `0.4.1` alias of the plugin (`obsidianmd-latest`, since the pinned `0.1.9` base predates that rule) so an Obsidian API newer than `manifest.json`'s `minAppVersion` fails lint locally; and sets `noInlineConfig` so an `eslint-disable` comment can never silence a rule (the bot forbids disables). The base plugin stays pinned at `0.1.9` because `0.4.x`'s recommended `ui/sentence-case` is over-aggressive and diverges from what the bot enforces; only the single `no-unsupported-api` rule is taken from the alias. To reach an API outside the typed/deprecated surface, use a local structural type-alias (e.g. `ScriptProcessorNodeLike` in [src/realtime/pcm.ts](src/realtime/pcm.ts)), never a disable comment.
|
||
|
||
## Source layout
|
||
|
||
```
|
||
src/
|
||
├── main.ts # Lifecycle, commands, ribbon, settings tab registration
|
||
├── types.ts # Shared interfaces (provider IDs, configs, templates, settings)
|
||
├── http.ts # requestUrl wrappers: jsonPost/jsonGet/multipartPost + ProviderError
|
||
├── platform.ts # Active-profile resolver + MediaRecorder availability probe
|
||
├── secrets.ts # Obsidian secretStorage (preferred) + passphrase (Argon2id/PBKDF2) for API keys
|
||
├── passphrase-strength.ts # zxcvbn-ts wrapper (lazy dynamic import, async API): evaluatePassphrase + MIN_PASSPHRASE_SCORE gate + warmPassphraseStrength
|
||
├── diceware.ts # generateDicewarePassphrase (EFF wordlist, crypto rejection sampling)
|
||
├── eff-large-wordlist.ts # EFF large diceware wordlist (7776 words)
|
||
├── recorder.ts # MediaRecorder state machine + getBestMimeType + live input-level monitor (no size cap; per-provider limits live in transcription/limits.ts)
|
||
├── audio-transcode.ts # WebAudio decode + resample to 16 kHz mono PCM WAV (shared by whisper-local and mistral-voxtral)
|
||
├── pipeline.ts # transcribe → cleanup → insert orchestrator
|
||
├── insert.ts # cursor/newFile/append + {{date}}/{{time}} expansion
|
||
├── time.ts # formatMoment: Obsidian's bundled moment behind a narrow structural alias (moment's types don't resolve in the review bot's environment; never call moment directly)
|
||
├── ingest.ts # Auto-ingest batch runner: scan rule folders, pipeline each audio file, move on success
|
||
├── whisper-host.ts # Spawns/stops a user-supplied whisper-server child process (desktop only; Phase B auto-start + idle-stop)
|
||
├── templates-folder.ts # Load templates from a vault folder + populate it with the 10 defaults
|
||
├── template-guide.ts # Writes the "Template update report.md" (the Update button's worklist) next to the templates folder; never loaded by the plugin. (The in-vault user guide was removed; the template guide now lives in the wiki.)
|
||
├── shared-core.ts # Load the shared cleanup preface from a vault Markdown file + populate default (prepended to every template prompt)
|
||
├── assistant-prompt.ts # Load the ad-hoc-instructions assistant prompt from a vault Markdown file + populate default
|
||
├── known-nouns.ts # Load a vault Markdown file of known nouns + populate default + build the system-prompt section
|
||
├── audio-persist.ts # Write the recorded Blob to an attachments folder, return vault-relative path
|
||
├── wake-name.ts # Extract "<assistantName>, <directive>" instructions from a transcript
|
||
├── settings/
|
||
│ ├── index.ts # DEFAULT_SETTINGS, load/save, per-profile secret hydration
|
||
│ ├── tab.ts # PluginSettingTab: active profile, two profile sections, templates folder, recording
|
||
│ └── default-templates.ts # The 10 default templates used by the populate button (General cleanup, Todo list, Daily note, Meeting notes, Meeting transcript, Idea capture, Lecture, Podcast, Guides, Book log); each prompt = per-template rules only (the shared preface is prepended at runtime from shared-core.ts)
|
||
├── ui/
|
||
│ ├── modal.ts # Main modal: template select + Record/Paste/From note tabs + setup-card injection + record-in-background handoff
|
||
│ ├── setup-card.ts # Inline blocker when active profile is unconfigured (voice vs text purpose)
|
||
│ ├── quick-record.ts # QuickRecordController + floating mini-UI for the Quick Record command (also hosts modal handoffs)
|
||
│ ├── realtime.ts # RealtimeController + floating bar for live dictation at the cursor
|
||
│ ├── template-picker.ts # Lightweight modal for picking a template (used by Process text command and editor menu)
|
||
│ ├── ingest-rule-modal.ts # Popup editor for one auto-ingest rule (folder + newFile template + enabled)
|
||
│ ├── text-source.ts # resolveActiveTextSource + runTextPipeline helpers for text-source flows
|
||
│ ├── pipeline-progress.ts # runBackgroundPipeline: shared sticky-Notice-with-Cancel wrapper for detached pipeline runs; stageLabel/formatDuration
|
||
│ └── whisper-status-bar.ts # Status-bar dot for whisper-host start/stop (desktop + whisper-local profile only)
|
||
├── realtime/
|
||
│ ├── index.ts # RealtimeProvider/RealtimeSession interfaces + factory + transcriptionProviderSupportsRealtime()
|
||
│ ├── pcm.ts # PcmCapture (ScriptProcessor mic tap) + pure downsample/quantize helpers
|
||
│ ├── deepgram.ts # Deepgram live WS adapter (auth via ['token', key] subprotocol)
|
||
│ └── assemblyai.ts # AssemblyAI Universal-Streaming WS adapter (ephemeral temp token)
|
||
├── transcription/
|
||
│ ├── index.ts # TranscriptionProvider interface + createTranscriptionProvider()
|
||
│ ├── limits.ts # Per-provider maxBytes/maxDurationMs + validateRecording (called from pipeline)
|
||
│ ├── openai.ts # Whisper-shape POST (also used by openai-compatible + groq)
|
||
│ ├── assemblyai.ts # upload → submit → poll
|
||
│ ├── deepgram.ts # single POST
|
||
│ ├── revai.ts # submit → poll → fetch text (JSON transcript when diarizing)
|
||
│ ├── mistral-voxtral.ts # Mistral Voxtral STT (JSON response; always transcodes to WAV)
|
||
│ └── whisper-local.ts # Thin shim that POSTs to the WhisperHost-managed local server
|
||
└── llm/
|
||
├── index.ts # LLMProvider interface + createLLMProvider()
|
||
├── openai.ts # /v1/chat/completions (also used by openai-compatible + mistral)
|
||
├── anthropic.ts # /v1/messages
|
||
└── gemini.ts # :generateContent
|
||
```
|
||
|
||
## Pipeline
|
||
|
||
[src/pipeline.ts](src/pipeline.ts) runs four stages with `onStage` callbacks for UI:
|
||
|
||
1. **Persist audio** (`audio` source only): writes the raw `Blob` to the vault via [src/audio-persist.ts](src/audio-persist.ts) before transcription, so the user keeps the recording even if later stages fail. Path resolution: when `settings.attachmentsFolderPath` is set, the file goes under that folder with manual de-collision (`-1`, `-2`, ...); when empty, the path comes from `app.fileManager.getAvailablePathForAttachment(filename)`, which respects Obsidian's own attachments setting. Filename is `ReWrite-YYYY-MM-DD-HHmmss.<ext>` with the extension derived from the blob's mime type (`webm` / `m4a` / `ogg` / `wav` / `mp3`, default `webm`). Failure is non-fatal: a Notice fires and transcription proceeds. The resolved path is later prepended to the cleaned output as `![[<path>]]\n\n` before insertion.
|
||
2. **Transcribe**: `audio` → `createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Just before dispatching, `validateRecording(blobSize, durationMs, providerId)` from [src/transcription/limits.ts](src/transcription/limits.ts) throws a friendly per-provider error if the recording exceeds the provider's documented byte or duration cap. Because validation runs after `persist-audio`, the user keeps the saved file and can switch providers + reprocess from the vault.
|
||
3. **Cleanup**: `createLLMProvider(profile.llmProvider).complete(systemPrompt, transcript, config)`. The system prompt is the template prompt, optionally augmented with an `## Ad-hoc instructions` block when the wake-name scan ([src/wake-name.ts](src/wake-name.ts)) extracts directives from the transcript, a `## Context` block when `params.contextHint` is non-empty (see Context hint below), a `## Known nouns` block when `plugin.knownNouns` is non-empty (see Assistant prompt and Known nouns sections below), and a `## Note properties` block when the template declares `noteProperties` (see Note properties below). `cleanupTranscript` returns `{ body, properties, title? }`: when the template declares properties OR sets `titleFromContent`, the LLM is asked to emit a leading ```` ```yaml ```` block which `extractFromBlock` parses off the front (the rest is the body); otherwise `properties` is `{}`, `title` is undefined, and `body` is the full output. On error, the provider error propagates unchanged; nothing is written to the clipboard (the earlier clipboard fallback was removed as a sensitive-content exposure, and for `audio` sources the persisted recording is the recovery path).
|
||
4. **Insert**: `src/insert.ts` routes to `cursor` / `newFile` / `append` per the template. `cursor` falls back to `append` when no editor is active; `append` falls back to `newFile` when no markdown file exists. `{{date}}` / `{{time}}` in filename templates expand via Obsidian's `moment` (reached through `formatMoment` in [src/time.ts](src/time.ts)), and `{{title}}` expands to the LLM-generated title (empty when no title). The modal's per-invocation Destination control overrides `insertMode` / `newFileFolder` / `newFileNameTemplate` via `PipelineParams.destinationOverride`; the override is shallow-merged onto a copy of the template before the insert call, so the template file on disk is never mutated. Extracted `properties` are threaded via `InsertParams.properties` and written into the note's frontmatter via `app.fileManager.processFrontMatter` in `newFile` mode only (see Note properties below). An extracted `title` (from `titleFromContent`) is threaded via `InsertParams.title` and shapes the `newFile` filename via the `{{title}}` token / whole-name replacement (see Note title below).
|
||
|
||
The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), and [src/ui/text-source.ts](src/ui/text-source.ts) (the `runTextPipeline` helper for command + editor-menu entry points). Every caller passes the plugin itself as `host: PipelineHost`; `PipelineHost` is a narrow interface ({ `assistantPrompt`, `knownNouns` }) so `cleanupTranscript` can read the loaded vault content without importing `ReWritePlugin` (which would form a circular dep through the UI layer).
|
||
|
||
The modal has two pipeline-running paths. The **Record** tab closes the modal the moment recording stops and runs the pipeline detached via `startRecordingPipeline` ([src/ui/modal.ts](src/ui/modal.ts)), which delegates to the shared `runBackgroundPipeline` helper in [src/ui/pipeline-progress.ts](src/ui/pipeline-progress.ts), mirroring `runAudioFilePipeline` in [src/ui/audio-source.ts](src/ui/audio-source.ts) and `runTextPipeline` in [src/ui/text-source.ts](src/ui/text-source.ts), all three of which now go through the same helper. `runBackgroundPipeline` opens a sticky `Notice` with a **Cancel** button (`setMessage` per stage), wires an `AbortController` into `PipelineParams.signal`, and on completion saves `lastUsedTemplateId` + shows a completion `Notice`; on error (including a user cancel, which surfaces as an `AbortError`) it shows the failure message and calls an optional `onError` hook. This is safe because the `persist-audio` stage saved the recording before transcription, so the vault file is the recovery path even if a cancel/error happens after that point. The **Paste** and **From note** tabs instead use `execute`, which keeps the modal open with an inline `.rewrite-progress` line and renders an inline **Retry** button on error (they have no persisted recovery, so closing would lose the input); `execute` is guarded against re-entrancy (`if (this.running) return;`) and the Paste/From-note buttons disable themselves for the duration, since nothing previously stopped a double-click from launching two concurrent pipeline runs. Destination override and context hint are captured into locals before `startRecordingPipeline` closes the modal, so a recorded run still honors them.
|
||
|
||
**Record in background** (desktop only): the Record tab shows a checkbox backed by `GlobalSettings.recordInBackground` (persisted; default off, hidden on mobile where the WebView suspends backgrounded `MediaRecorder`). When on, pressing Record does not capture in the modal at all: `startBackgroundHandoff` ([src/ui/modal.ts](src/ui/modal.ts)) captures the template / destination override / context hint into locals, closes the modal, and calls `plugin.startBackgroundRecording(opts)` ([src/main.ts](src/main.ts)), which routes into the SAME `activeQuickRecord` slot + `quickRecordStarting` guard as the two Quick Record commands (single-recording guarantee, one `onunload` cleanup path). `QuickRecordController` carries the handed-off `destinationOverride` / `contextHint` into `runPipeline`; picking a *different* template from the floater's popover clears both (they were set up against the original template). When the checkbox is off, capture stays in-modal, and Stop still closes the modal + runs detached as described above, so processing never blocks Obsidian either way.
|
||
|
||
Note on cancellation: Obsidian's `requestUrl` (used by every provider adapter, see [src/http.ts](src/http.ts)) accepts no `AbortSignal`, so Cancel only takes effect between network calls (e.g. before the next poll in `assemblyai.ts`/`revai.ts`), not mid-transfer. It still recovers a pipeline stuck at a stage that would otherwise never resolve.
|
||
|
||
The `PipelineSource` union has three variants: `audio` (recorded blob, optional `sourcePath` for reprocess flows), `paste` (textarea input), `text` (input from an existing note via selection or whole body). Text-source flows skip transcription entirely and only require the LLM half of the profile. The `audio` variant's `sourcePath` is set by the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) to point at an existing vault file; when present, the persist stage is skipped and that path is reused for the `![[<path>]]\n\n` prepend.
|
||
|
||
## Provider system
|
||
|
||
[src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) each define a small interface plus a `create...Provider(id)` factory. Provider families share one adapter file where the API shapes match: OpenAI Whisper, `openai-compatible`, and Groq all dispatch into [src/transcription/openai.ts](src/transcription/openai.ts) with different base URLs; OpenAI GPT, `openai-compatible`, and Mistral all dispatch into [src/llm/openai.ts](src/llm/openai.ts). Mistral Voxtral does NOT share with `openai.ts` (see [src/transcription/mistral-voxtral.ts](src/transcription/mistral-voxtral.ts)) because Voxtral's response is JSON-only (no `response_format=text`) and it rejects WebM input (so the blob is always transcoded to 16 kHz mono WAV via [src/audio-transcode.ts](src/audio-transcode.ts)).
|
||
|
||
API keys are stored per profile on `EnvironmentProfile.transcriptionConfig.apiKey` / `llmConfig.apiKey` / `realtimeConfig.apiKey`. Three slots per profile: transcription, LLM, and real-time (the last a separate key + model because streaming models/keys differ from batch; see Real-time transcription). No global by-family map; the desktop and mobile profiles each carry their own keys even when both use the same provider (deliberate: per-profile keys make per-function usage tracking easier). Persistence is in [src/secrets.ts](src/secrets.ts) via [src/settings/index.ts](src/settings/index.ts) using the key IDs `profile-desktop-transcription`, `profile-desktop-llm`, `profile-desktop-realtime`, and the three `profile-mobile-*` equivalents. These ids (and the namespace prefix `secrets.ts` prepends for `secretStorage`) are dash-joined, NOT colon-joined: Obsidian's `app.secretStorage.setSecret` throws on any id that is not lowercase-alphanumeric + dashes, so a colon/underscore id silently fails the availability probe on every platform. Do not reintroduce colons or underscores into a secret id.
|
||
|
||
Providers may optionally implement `listModels(config, signal)` returning a string array of model IDs the configured API key can access. Implemented by: OpenAI / Groq / Mistral (via `openai.ts` shared adapter), Mistral Voxtral (own adapter, filters Mistral's `/v1/models` catalog by ID substring `voxtral`), Anthropic, Gemini, Deepgram. Not implemented for `openai-compatible` (URL-specific, list-shape varies), AssemblyAI, Rev.ai. The settings tab caches results to `GlobalSettings.modelCache` per side and provider ID; the Refresh button in the model field triggers `listModels` and updates the cache. The model field is a single adaptive control (`populateModelField` in [src/settings/tab.ts](src/settings/tab.ts), shared by both sides via a `side: 'transcription' | 'llm'` arg): a **dropdown** when the provider supports `listModels` and the cache is non-empty, otherwise a **plain text field**. There is never both at once. The dropdown carries a trailing "Custom..." option (sentinel `CUSTOM_MODEL_OPTION`, never written to `config.model`) that toggles the same control into the text field (the `forceText` arg) so a model ID not in the catalog can still be typed; a "Back to list" button returns to the dropdown. A custom value not in the cache is shown as a selected `<id> (custom)` dropdown option. The Refresh button accompanies the dropdown (and the empty-cache text field, so the first fetch can flip it to a dropdown); on success the field re-renders in dropdown mode. `openai-compatible` / AssemblyAI / Rev.ai (no `listModels`) always show a plain text field with no Refresh. For AssemblyAI and Rev.ai the field description appends a "list of models" external link to the provider's docs (`transcriptionModelDocsUrl` + `applyModelFieldDesc` in [src/settings/tab.ts](src/settings/tab.ts)); `openai-compatible` has none (the model list is the user's own server). Whichever control is active, the canonical source is `profile.config.model`.
|
||
|
||
## Speaker diarization
|
||
|
||
Opt-in `Speaker X:` labels, supported only on `assemblyai` / `deepgram` / `revai`. Chosen **per invocation**, not as a persisted setting (a profile-wide toggle would wrongly diarize e.g. daily notes, producing stray `Speaker A` labels). Two inputs: the per-template `NoteTemplate.diarize` flag (frontmatter `diarize: true`; the Meeting transcript default ships with it) and the main modal's per-run **"Identify speakers"** checkbox (`renderDiarizeToggle` in [src/ui/modal.ts](src/ui/modal.ts), shown only when the active provider supports diarization, defaulting to the active template's flag and reset on template change). The pipeline ([src/pipeline.ts](src/pipeline.ts) `collectTranscript`) computes `effectiveDiarize = (template.diarize || params.diarize) && transcriptionProviderSupportsDiarization(id)` and always sets `transcriptionConfig.diarize` explicitly (so a stale value from an older `data.json` can't leak through). `PipelineParams.diarize` carries the modal toggle; it is threaded through the Record tab, `startRecordingPipeline`, and the "Record in background" handoff (`startBackgroundRecording` -> `startQuickRecord` -> `QuickRecordController`), and cleared when the Quick Record floater switches templates. There is **no** settings-tab diarization toggle and `TranscriptionConfig.diarize` is now purely the internal pipeline->adapter transport. Capability is gated by `transcriptionProviderSupportsDiarization(id)` ([src/transcription/index.ts](src/transcription/index.ts)); on a non-capable provider the flag is a documented no-op. Labels survive cleanup via a `DEFAULT_SHARED_CORE` clause.
|
||
|
||
Full detail (per-adapter formatting, pipeline merge rules, label survival) lives in [docs/DIARIZATION.md](docs/DIARIZATION.md).
|
||
|
||
## Local whisper.cpp host (desktop)
|
||
|
||
`WhisperHost` ([src/whisper-host.ts](src/whisper-host.ts), instantiated in `onload`, stopped in `onunload`) manages a user-supplied whisper-server child process. Config lives at `GlobalSettings.localWhisper = { binaryPath, modelPath, port, extraArgs, autoStart, idleStopMinutes }`: no auto-discovery, no auto-download, no baked defaults. Desktop-only (Node modules lazy-required behind `Platform.isDesktop`; the option is filtered out of mobile dropdowns). **Loopback binding is enforced** (a non-loopback `--host` throws before spawn) because whisper-server has no auth/TLS; `getHostArgs` collects EVERY `--host` occurrence in `extraArgs` (not just the first), since whisper-server honors the last one and checking only the first let a duplicate `--host 127.0.0.1 --host 0.0.0.0` bypass the guard. The `whisper-local` transcription provider ([src/transcription/whisper-local.ts](src/transcription/whisper-local.ts)) is a thin shim POSTing transcoded 16 kHz mono WAV to `http://127.0.0.1:<port>/inference`; no API key.
|
||
|
||
Phase B lifecycle (both knobs default off): `autoStart` starts the server after `onload`'s probe settles (only when the active profile uses `whisper-local` and the probe found nothing to adopt); `idleStopMinutes > 0` stops a ReWrite-owned (spawned/adopted, NEVER external) server after that many minutes without a transcription, via a 60 s interval calling `whisperHost.stopIfIdle()`. The decision logic is the pure `shouldStopWhenIdle(...)` (unit-tested); the `whisper-local` adapter brackets each transcription with `beginUse()`/`endUse()` so an in-flight job blocks the stop and resets the idle clock.
|
||
|
||
Full detail (start/stop/probe lifecycle, the spawned/adopted/external ownership model, PID sidecar, Auto-detect, the build script, the Phase B auto-start/idle-stop wiring, and all whisper-host gotchas) lives in [docs/WHISPER_HOST.md](docs/WHISPER_HOST.md).
|
||
|
||
## Settings
|
||
|
||
`GlobalSettings` (defined in [src/types.ts](src/types.ts)) is the shape of `data.json`. Loading flow:
|
||
|
||
1. `plugin.loadData()` returns `Partial<GlobalSettings> | null`.
|
||
2. `mergeSettings(DEFAULT_SETTINGS, stored)` deep-merges, preferring stored values. Enum fields (`recordingFormat`, `activeProfileOverride`, `newFileCollisionMode`, `transcriptionProvider`, `llmProvider`) are validated against an allowlist and fall back to the base value on an unrecognized string; nested config objects (`localWhisper`, `modelCache`, `transcriptionConfig`, `llmConfig`) are only spread when they're actually plain objects. The two array fields (`disabledDefaultTemplateIds`, `ingestRules`) go through `sanitizeDisabledIds` / `sanitizeIngestRules` (exported, tested): a non-array falls back to base, and malformed elements are dropped (a rule needs a non-blank `folderPath` and `templateId`; `enabled` is coerced to a strict boolean). All of this guards against a corrupt/hand-edited `data.json` leaking garbage into runtime state (a non-object partial field would otherwise spread its characters into the nested config).
|
||
3. `hydrateSecrets()` reads keys from `secrets.json.nosync` and writes them into each profile's `transcriptionConfig.apiKey` / `llmConfig.apiKey` / `realtimeConfig.apiKey`.
|
||
|
||
Saving flow strips secrets out of `data.json` and writes them to `secrets.json.nosync` instead (see [src/secrets.ts](src/secrets.ts)). Never persist API keys to `data.json`.
|
||
|
||
## Secrets encryption
|
||
|
||
[src/secrets.ts](src/secrets.ts) encrypts API keys file-wide in one of two modes (no unencrypted at-rest option):
|
||
|
||
- **`secretStorage`** (default/preferred when available) — Obsidian's first-party `app.secretStorage`, an OS-encrypted shared store. Reached via a narrow cast + round-trip self-test, falls back to passphrase when unavailable (old Obsidian, Linux without a keyring).
|
||
- **`passphrase`** (always-available fallback) — WebCrypto AES-GCM-256 keyed by a user passphrase, KDF Argon2id (PBKDF2 fallback), stored in `secrets.json.nosync`. Entropy-gated at `MIN_PASSPHRASE_SCORE` (3/4). Locks/unlocks; the derived key never touches disk.
|
||
|
||
The two stores **coexist**: the on-disk envelope retains passphrase `kdf`/`verifier`/`keys` even while `secretStorage` is active (a preserved-at-rest snapshot), so `mode` is just the active-store flag. Switching the active method and transferring keys are **decoupled** into three separate operations (a switch never moves or drops keys): `setEncryptionMode` (switch only), `copyKeys` (copy inactive→active, source kept), `clearKeys(mode)` (wipe one method). The settings tab exposes the mode dropdown (pure switch) plus explicit **Copy** and **Clear** buttons (with `ConfirmModal` confirmations + a copied/cleared count Notice). The transfer button is labelled **Copy**, not "Migrate", because the source copy is kept; `unlockPassphraseStore` lets a secret-storage-active user unlock the passphrase snapshot to copy it.
|
||
|
||
`ReWritePlugin.encryptionStatus` (`{ mode, locked, configured, secretStorageAvailable, passphraseConfigured }`, refreshed via `refreshEncryptionStatus()`) is read synchronously by UI; pipeline entry points gate on `locked` and call `promptUnlock()`. `passphraseConfigured` is mode-independent (kdf+verifier on disk), used by the UI to distinguish switch-to vs create-passphrase and to know whether a snapshot is copyable. `minAppVersion` is `1.6.6` (raised from 1.4.4 in 1.2.1; driven by `FileManager.trashFile` `@since 1.6.6`, used to delete a disabled built-in template. `processFrontMatter` is `@since 1.4.4`; `secretStorage` is feature-detected so it does not drive the floor).
|
||
|
||
A `secrets.json.nosync` that fails to parse (bad JSON, or JSON that isn't an object) is treated as **corrupt**, not merely absent: the raw bytes are preserved as `secrets.json.nosync.corrupt` before falling back to a fresh envelope, and a sticky `Notice` warns the user before any new save can overwrite the salvageable ciphertext. Writes go through a temp-file + remove + rename sequence rather than a single direct write, so a crash mid-write can't leave a half-written file. A keyring (`secretStorage`) call that fails mid-session (as opposed to being unavailable from the start) is also surfaced once per session via `Notice`, rather than degrading silently to an empty key. See docs/SECRETS.md's Gotchas for both.
|
||
|
||
Full detail (envelope schema, KDF params, lock/unlock flow, opportunistic PBKDF2→Argon2id KDF upgrade, the `setEncryptionMode` / `copyKeys` / `clearKeys` / `changePassphrase` / `resetSecrets` model, locked-state behavior, and the secrets gotchas) lives in [docs/SECRETS.md](docs/SECRETS.md).
|
||
|
||
## Templates
|
||
|
||
Templates are Markdown files in a vault folder, not entries in `data.json`. The folder path lives on `GlobalSettings.templatesFolderPath` (default `ReWrite/Templates`). Each `.md` file in the folder is one template: YAML frontmatter holds `id`, `name`, `insertMode`, `newFileFolder`, `newFileNameTemplate`, the optional boolean flags `disableSharedCore`, `enableContextHint`, `diarize`, and `titleFromContent`, plus the tri-state `managed` ownership marker (see Managing individual defaults below); the file body is the LLM prompt. Files are sorted by basename in the modal/picker so users can prefix names (`01-...`, `02-...`) to control order.
|
||
|
||
[src/templates-folder.ts](src/templates-folder.ts) exports `loadTemplatesFromFolder(app, folderPath)`, `populateDefaultTemplates(app, folderPath, disabledIds?)`, and `updateDefaultTemplates(app, folderPath, disabledIds?)` (see Updating defaults below; `disabledIds` = `new Set(settings.disabledDefaultTemplateIds)` at both call sites). The plugin keeps a cache on `plugin.templates: NoteTemplate[]`, refreshed in [src/main.ts](src/main.ts) on:
|
||
- `workspace.onLayoutReady` after `onload` (initial load, after the vault is ready)
|
||
- vault `create` / `modify` / `delete` events scoped to the templates folder via `isPathInTemplatesFolder`
|
||
- vault `rename` (checks both old and new path)
|
||
- the Templates folder path field changing in settings
|
||
- the populate button completing
|
||
|
||
Vault-event-triggered refreshes (the first three above, and the equivalent triggers for shared core / assistant prompt / known nouns) go through `debounceRefresh(kind, fn)`: an editor autosaving a file inside the templates folder fires several `modify` events in quick succession, and without debouncing each one would trigger its own full folder reload. `debounceRefresh` waits 250 ms of quiet per refresh kind before actually reloading (timers cleared in `onunload`). Direct callers (e.g. the settings tab's Populate button) call `refreshTemplates()` etc. straight, not through the debounce, so they still get an immediate, awaitable reload. Every `refresh*()` method (`refreshTemplates`, `refreshSharedCore`, `refreshAssistantPrompt`, `refreshKnownNouns`) also bumps a per-kind generation counter before awaiting its load and only applies the result if the counter is still current when the load resolves, so two overlapping reloads (a debounced vault-event one racing a direct settings-tab one) can't have the slower one clobber the faster one's fresher result.
|
||
|
||
Consumers ([src/main.ts](src/main.ts), [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts)) read `plugin.templates` directly, never `settings.templates` (there is no such field). The populate button is non-destructive: it skips any default template whose `id` already exists on disk, and skips path collisions. Frontmatter `id` is canonical for identity, so renaming a file does not break the `defaultTemplateId` / `lastUsedTemplateId` reference. The first-launch experience is empty templates plus a setup nudge in the modal; the user clicks Populate to get the defaults.
|
||
|
||
The 10 defaults ([src/settings/default-templates.ts](src/settings/default-templates.ts)) are General cleanup, Todo list, Daily note, Meeting notes, Meeting transcript, Idea capture, Lecture, Podcast, Guides, Book log. Each default template carries ONLY its per-template rules; the shared cleanup preface (guardrail + condensed cleanup + output discipline) is NOT baked into the prompt strings. It lives in the vault SharedCore.md file and is prepended at runtime by the pipeline (see Shared core below). General cleanup carries the full detailed prose-polishing ruleset as its body; the structured templates carry only their section layout. The Daily note default is a prompt-only structured fill (extracted `## Calendar` / `## Goals` / `## Tasks`, each omitted when empty, then `## Braindump` = the full cleaned transcript last); no `NoteTemplate` schema change was needed to drive it. Meeting transcript is the Meeting notes prompt adapted for speaker-labeled input, shipped with `diarize: true` so it forces diarization on (see Speaker diarization). Guides turns a spoken walkthrough into a step-by-step how-to (`insertMode: newFile`, `enableContextHint: true`); its prompt prescribes a strict two-level list format (ordered top-level steps, `-` bulleted sub-steps indented one tab, no deeper nesting, no `a.`/`i.`/`*` markers) because LLMs reliably mangle nested ordered lists, plus a trailing `## Gaps` section for anything ambiguous or missing. Book log (`insertMode: newFile`, `enableContextHint: true`) turns spoken notes into a short book-log body. The five structured `newFile` defaults ship `noteProperties` (see Note properties below): Meeting notes / Meeting transcript carry `subject` / `participants` / `date`, Lecture carries `subject` / `lecturer` / `course`, Podcast carries `podcast` / `episode` / `host` / `guests`, Guides carries `topic` / `tool`, and Book log carries `title` / `author` / `series`.
|
||
|
||
`NoteTemplate.disableSharedCore?: boolean` (frontmatter `disableSharedCore: true`) opts a single template out of the shared-core prepend. `renderTemplateFile` ALWAYS emits the key so the knob is discoverable: empty (`disableSharedCore:`, parses to null = not disabled) by default, `disableSharedCore: true` when set. `parseTemplateFile` treats it as disabled only when the value is boolean `true` or the string `"true"` (case-insensitive) — the string form is tolerated because Obsidian's Properties UI may store an edited value as text; any other value (null/empty/false) means not disabled. The three opt-in boolean flags `enableContextHint` (see Context hint), `diarize` (see Speaker diarization), and `titleFromContent` (see Note title) follow the exact same render/parse convention (always-emitted empty key, boolean-or-`"true"` tolerance); their polarity is positive (set to turn ON), the reverse of `disableSharedCore`.
|
||
|
||
### Managing individual defaults (managed flag + disable list)
|
||
|
||
Two per-default controls, surfaced as the "Manage built-in templates" `<details>` checklist in the Templates settings section (`renderManageDefaults` in [src/settings/tab.ts](src/settings/tab.ts), expand state on `manageDefaultsExpanded`; the list is driven off `freshDefaultTemplates()` so it tracks the default set). The two controls are deliberately different affordances so their roles read without a hover: **Enabled** is a toggle switch with an inline caption that reads "Enabled" when on and "Disabled" when off (the whole tab re-renders on toggle, so the caption reflects the current state); **Tracked** is a real labelled checkbox (a manual `<input type="checkbox">` appended to the row's `controlEl`, with its caption span placed BEFORE the input so the label reads left-to-right; the caption reads "Tracked" when checked and "Untracked" when unchecked, mirroring the Enabled/Disabled switch; not a second `addToggle`, which would look identical to the Enabled switch). A legend above the list bolds both terms, and the `.rewrite-manage-*` CSS lays the row out:
|
||
|
||
- **Enabled / disabled (existence).** Disabling removes the on-disk file (matched by frontmatter `id` via `findTemplateFileById`, deleted with `app.fileManager.trashFile` behind a `ConfirmModal`) and records the id in `GlobalSettings.disabledDefaultTemplateIds`, which Populate and Update both receive as `disabledIds`: neither ever (re)creates a disabled default, including a future plugin version re-shipping it. Decision (documented): the ignore list is keyed by `id` only, NOT also by name — the `managed` flag already prevents a name-colliding user template from being touched, so name suppression would add ambiguity without protection. Re-enabling removes the id from the list and immediately recreates the file via `restoreDefaultTemplate` (same name/path-collision skip as Populate). The `ConfirmModal` gained an optional `onCancel` hook ([src/ui/confirm-modal.ts](src/ui/confirm-modal.ts), fired from `onClose` when not confirmed) so the settings tab can re-render and reset the optimistically-flipped toggle when the user backs out.
|
||
- **Tracked / untracked (updates), via `NoteTemplate.managed`.** `managed` is TRI-state, unlike the other flags: `true` (plugin-managed; Populate/Update stamp it on every file they create or reconcile), `false` (untracked: Update skips the file entirely, counted in `UpdateResult.untracked`), `undefined` (key absent/empty — treated as tracked when the id matches a built-in, preserving pre-flag behavior). `parseTemplateContent` uses `parseTriStateFlag` (boolean or string `"true"`/`"false"`); `renderTemplateFile` emits `managed: true` / `managed: false` / the empty `managed:` stub for undefined. Because Update's file-matching is now "id matches a built-in AND the file is not `managed: false`", and because only Populate/Update ever write `managed: true`, a user's own hand-authored template that coincidentally shares a name with a built-in is never clobbered. The untrack toggle edits just the frontmatter key via `app.fileManager.processFrontMatter` (preserves the body byte-for-byte) rather than re-rendering the whole file.
|
||
|
||
The Templates "Populate" button also seeds SharedCore.md when missing (non-destructive), because the shared core is load-bearing for the default templates' quality (it carries their guardrail + output discipline). Populating templates without it would yield prompts with no guardrail. (Populate no longer seeds an in-vault help file; the user-facing template guide lives in the wiki.) **Keep `wiki/Creating-Templates.md` in sync with the template structure.** Whenever you add or change a `NoteTemplate` field / frontmatter property, change the prompt-assembly order, alter what the shared core carries, or change the default-template set or count, update that wiki page in the SAME change: add the property to its frontmatter table AND give the behavior its own `##` section. The settings tab links users to it from the Templates section ("Creating templates"); it is the user-facing contract for the template format, so treat updating it as part of the task, not a follow-up.
|
||
|
||
### Updating defaults
|
||
|
||
The Templates section ships three buttons sharing one `Setting` row: **Populate** (`.setCta()` primary), **Update**, and **Load prior versions** (both secondary). Populate only ever *adds* missing files, so it can never push a changed default (new field, new `noteProperty`, reworded prompt) into a file the user already has. Update (`updateDefaultTemplates(app, folderPath)`) reconciles the user's default-derived files against the current built-ins; Load prior versions (`loadPriorTemplateVersions(app, folderPath)`) drops earlier shipped prompt versions in as their own selectable templates for comparison.
|
||
|
||
**Default history.** [src/settings/template-history.ts](src/settings/template-history.ts) holds `TEMPLATE_HISTORY: Record<string, TemplateVersionSnapshot[]>` — the plugin's in-code memory of what each default *used to be*, keyed by template id, each snapshot carrying `{ version, template }`. It is the base for the merge and the source for Load prior versions. **It starts EMPTY.** MAINTENANCE RULE (treat like the keep-the-guide-in-sync rule): whenever you change a default in [src/settings/default-templates.ts](src/settings/default-templates.ts), append the OUTGOING template to `TEMPLATE_HISTORY[id]` under the manifest version it shipped in. Without the snapshot, Update cannot tell an unedited old prompt from a user edit and falls back to a body conflict. `priorVersionsForId(id)` and `allPriorVersions()` return clones so callers can't mutate the registry.
|
||
|
||
**Per-field 3-way merge.** `mergeTemplate(onDisk, def, priors)` (pure, synchronous; `priors` = `priorVersionsForId(id)`) treats a field as *pristine* (user never touched it) when its on-disk value equals the current default OR any prior shipped default; a pristine value is brought forward to the current default, a genuine edit is kept. The **prompt body** is the only field whose kept edit becomes a report `body` conflict — an unedited old body is silently updated (recorded in `changes`). Scalars + flags adopt-or-keep silently (a real adoption is noted in `changes`). `noteProperties` union by name: missing defaults appended; an instruction matching a prior default is brought forward; a genuinely edited instruction is kept + `changedInstruction`; a property the default dropped is always kept + `removedProperty` (with `wasShippedDefault` set when the user's value matches a prior default, so the report can say it is safe to delete). `mergeTemplate` stamps `managed: true` on its result (it only runs on tracked default-derived files, so this also back-fills the flag onto pre-flag files, at the cost of one rewrite). Because `renderTemplateFile` always emits the flag stubs and emits `noteProperties` only when non-empty, re-rendering a pristine current default is byte-identical to Populate's output, so an already-current folder is a clean no-op.
|
||
|
||
`updateDefaultTemplates` walks the folder, matches files to defaults by frontmatter `id` (via `readTemplateId`, also now backing `collectExistingIds`; non-default-derived files skipped, disabled ids skipped, `managed: false` files skipped as `untracked`), merges, and `vault.modify`s in place only when the render differs (CRLF-normalized compare; never renames, even with an ordering prefix). It then **recreates any default missing from disk entirely** (superset top-up, same name + path-collision skip as Populate, disabled ids excluded) but does NOT seed SharedCore.md (Populate's job). Counters are mutually exclusive: `created` + `updated` + `unchanged` + `conflicts` + `parseFailed` + `untracked` = files seen. A default-derived file that fails to parse is left untouched and reported as `parseFailed`; an untracked (`managed: false`) file is counted only in the `untracked` tally and gets no per-template entry in `Template update report.md` (freezing it is the user's explicit choice, not a conflict).
|
||
|
||
**Load prior versions** writes each `allPriorVersions()` snapshot as a standalone template with a distinct id (`<id>@<version>`) and versioned name (`<name> <version>`), so it appears in the picker for comparison, never collides with the live template's identity, and is left untouched by Update (its id is not a current default) and Populate. Non-destructive (skips by id/path); reports `available` so the button can say "no prior versions yet" when the registry is empty.
|
||
|
||
Anything Update cannot auto-merge is written to **`Template update report.md`** via `writeTemplateUpdateReport` ([src/template-guide.ts](src/template-guide.ts), `TEMPLATE_UPDATE_REPORT_FILENAME`), placed in the parent of the templates folder (OUTSIDE it so `loadTemplatesFromFolder` never parses it) and **overwritten every run**. It lists, per non-`unchanged` template: changes applied automatically, a `body` conflict (user file kept, default shown beside it in `~~~text` fences), `removedProperty` / `changedInstruction` / `parseFailed`. The report types (`TemplateUpdateConflict` / `TemplateUpdateEntry` / `UpdateResult`) live in [src/templates-folder.ts](src/templates-folder.ts); `template-guide.ts` imports them type-only, so `templates-folder.ts → template-guide.ts` is the one runtime edge (acyclic). Caveat (documented in the report footer): a `vault.modify` re-serializes the frontmatter via `stringifyYaml`, which **drops YAML comments**; the prompt body is passed through untouched, and an already-current file is never rewritten.
|
||
|
||
## Shared core
|
||
|
||
A vault Markdown file whose body is prepended to every template prompt at cleanup time. Path: `GlobalSettings.sharedCorePath` (default `ReWrite/SharedCore.md`). [src/shared-core.ts](src/shared-core.ts) exports `loadSharedCoreFromFile(app, path)`, `populateDefaultSharedCore(app, path)`, `isPathSharedCore(path, configuredPath)`, and the `DEFAULT_SHARED_CORE` constant (the three-paragraph preface: anti-injection guardrail, condensed cleanup rules, output discipline) used as the populated file body. The loader strips any leading frontmatter (guidance only, never sent to the LLM) and returns the trimmed body, or `null` when the file is missing/empty. The plugin caches it on `plugin.sharedCore: string | null`, refreshed in [src/main.ts](src/main.ts) on the same triggers as the assistant prompt (`workspace.onLayoutReady`, scoped vault `create`/`modify`/`delete`/`rename`, settings-path change, populate button).
|
||
|
||
[src/pipeline.ts](src/pipeline.ts) `cleanupTranscript` prepends it: `systemPrompt = sharedCore ? `${sharedCore}\n\n${template.prompt}` : template.prompt`, where `sharedCore = template.disableSharedCore ? null : host.sharedCore`. So the order in the assembled system prompt is shared core → template prompt → ad-hoc instructions → known nouns. Deleting or emptying SharedCore.md disables it globally (no fallback to a baked-in default; `null` means inject nothing); `disableSharedCore: true` disables it for one template. `PipelineHost` gained `sharedCore: string | null` alongside `assistantPrompt` / `knownNouns`.
|
||
|
||
Injection caveat: vault and transcript text (the assistant prompt, wake-name ad-hoc directives, known nouns, and the cleaned input itself) all flow into the system prompt unescaped. The shared-core preface is the anti-injection defense, so a template with `disableSharedCore: true` runs without that guardrail. This is intentional (the user owns their vault), but `renderTemplates` in [src/settings/tab.ts](src/settings/tab.ts) surfaces a `.rewrite-warning-text` line naming any loaded template whose `disableSharedCore === true` so the loss of protection is visible.
|
||
|
||
The settings "Shared core" section (`renderSharedCore` in [src/settings/tab.ts](src/settings/tab.ts)) shows an Enabled/Disabled badge next to the heading (`.rewrite-status-badge.is-enabled` / `.is-disabled`, inserted into the heading's `nameEl`, driven by `plugin.sharedCore !== null`). Like the other settings status text, it reflects state as of the last full-container render, not live keystrokes in the path field. The Templates "Populate" button calls `populateDefaultSharedCore` after `populateDefaultTemplates` so a first-run Populate seeds the shared core too (it is load-bearing for the default prompts' guardrail/output discipline); non-destructive, skipped when the file exists.
|
||
|
||
## Assistant prompt
|
||
|
||
The system-prompt preface inserted above extracted ad-hoc directives lives as a Markdown file in the vault, not a settings textarea. Path: `GlobalSettings.assistantPromptPath` (default `ReWrite/AssistantPrompt.md`). [src/assistant-prompt.ts](src/assistant-prompt.ts) exports `loadAssistantPromptFromFile(app, path)`, `populateDefaultAssistantPrompt(app, path)`, `isPathAssistantPrompt(path, configuredPath)`, and the `DEFAULT_ASSISTANT_PROMPT` constant used as the fallback when the file is missing or empty. The plugin caches the body on `plugin.assistantPrompt: string | null`, refreshed in [src/main.ts](src/main.ts) on the same triggers as templates (`workspace.onLayoutReady`, scoped vault `create`/`modify`/`delete`/`rename`, settings-path change, populate button). The file body is the prompt; frontmatter is currently ignored (the loader tolerates it for future extensions). [src/pipeline.ts](src/pipeline.ts) reads `params.host.assistantPrompt ?? DEFAULT_ASSISTANT_PROMPT` inside `cleanupTranscript`.
|
||
|
||
Branding note: the "AI name" / "Agent control prompt" labels in older builds are replaced by "Assistant name" / "Assistant prompt file" everywhere. The wake-name feature itself keeps the term "wake name" (it's the trigger word, not the persona). The setting key is `assistantName`; the persona is "the assistant".
|
||
|
||
## Known nouns
|
||
|
||
A vault Markdown file of proper nouns the LLM should preserve verbatim. Path: `GlobalSettings.knownNounsPath` (default `ReWrite/KnownNouns.md`). [src/known-nouns.ts](src/known-nouns.ts) exports `loadKnownNounsFromFile`, `populateDefaultKnownNouns`, `isPathKnownNouns`, and `buildKnownNounsSystemPromptSection(nouns)`. File format: YAML frontmatter for human-readable guidance (token-cost warning, format hint), Markdown body with one noun per line, optional `canonical: alt1, alt2` for misheard variants. Lines starting with `#` and blank lines are skipped. Frontmatter is parsed but NOT sent to the LLM in v1; future opt-in is possible but should not become the default. The default file body includes both the guidance frontmatter and two illustrative example nouns.
|
||
|
||
Cache: `plugin.knownNouns: KnownNoun[]` (default `[]`), refreshed on the same triggers as the assistant prompt. [src/pipeline.ts](src/pipeline.ts) appends the section returned by `buildKnownNounsSystemPromptSection(host.knownNouns)` to the system prompt when non-empty. Order in the assembled prompt: shared core, template prompt, ad-hoc instructions (if any), context hint (if any), known nouns (if any), note properties (if any).
|
||
|
||
## Note properties
|
||
|
||
A template can declare frontmatter properties the LLM fills in from the content, written into the new note's YAML frontmatter. `NoteTemplate.noteProperties?: NotePropertySpec[]` ([src/types.ts](src/types.ts), `NotePropertySpec = { name; instruction }`). Authored in template frontmatter as a YAML **map** (key = property name, value = instruction string), parsed into an ordered array (object key order drives both the prompt and the write order). Unlike the three boolean flags, `renderTemplateFile` emits `noteProperties` ONLY when non-empty (a nested map has no useful always-empty stub); `parseTemplateFile` reads any object-map value, trims keys, coerces missing instructions to `""`, and skips blank keys ([src/templates-folder.ts](src/templates-folder.ts)).
|
||
|
||
Pipeline ([src/pipeline.ts](src/pipeline.ts)): `cleanupTranscript` returns `{ body, properties, title? }`. When `template.noteProperties` is non-empty OR `wantsTitle` is set (and `llmProvider !== 'none'`), a `## Note properties` section is appended LAST in the system prompt (after known nouns) listing the exact keys + instructions and telling the model to emit ONE leading ```` ```yaml ```` block containing exactly those keys, then a blank line, then the note body. That section explicitly overrides the shared-core "no code fences / output only the note" output discipline, but only for the single leading block. `extractFromBlock(raw, specs, wantsTitle)` (exported for tests) then: seeds `properties` with every declared key = `''` (the scaffold), matches a leading fence tagged ```` ```yaml ```` or ```` ```yml ```` specifically, `parseYaml`s it, and overlays values for declared keys only (extras ignored, non-strings `String()`-coerced, null/undefined skipped); the reserved `noteTitle` key is pulled into `title` (when `wantsTitle`) and is NOT added to `properties`. The fence tag is required (not optional): a bare ` ``` ` fence with no language tag is treated as ordinary content, not the properties block, so a model that wraps its entire reply in a plain code fence doesn't have the whole note swallowed as "the block" and left with an empty body. When no tagged block is present the whole output is the body. When a tagged block IS present it is ALWAYS stripped from the body even if its YAML is malformed (the model emitted a properties block, not content); a strict `parseYaml` failure falls back to a tolerant line-based read (`key: value` lines, matching up to the first colon so a property name with a space or non-ASCII character still round-trips; `stripQuotes` peels matched/stray quotes) so one bad value does not blank the whole scaffold. The prompt steers the model with an explicit unquoted example and "leave unknown values blank" wording.
|
||
|
||
Write side: `runPipeline` extracts BEFORE prepending the `![[audio]]` embed (otherwise the embed pushes the YAML off byte 0), builds `finalContent` from `body`, and threads `properties` + `title` via `InsertParams` ([src/insert.ts](src/insert.ts)). Only `insertNewFile` applies properties, via `app.fileManager.processFrontMatter` after `vault.create` (which prepends a real `---...---` block above the embed); `cursor` / `append` ignore them (they write into a user-owned existing note). Scope is newFile-only by design (matches the "new files" use case; never silently rewrites an existing note's frontmatter). The Meeting notes, Meeting transcript, Lecture, Podcast, Guides, and Book log defaults ship with `noteProperties`.
|
||
|
||
## Note title
|
||
|
||
A template can have the LLM name the new note from the content (and context). `NoteTemplate.titleFromContent?: boolean` ([src/types.ts](src/types.ts)), parsed/rendered with the same always-emitted-stub convention as the other opt-in flags. The title rides the SAME leading ```` ```yaml ```` channel as Note properties, under the reserved key `noteTitle` (module constant `RESERVED_TITLE_KEY` in [src/pipeline.ts](src/pipeline.ts)). Activation: the block is requested when `noteProps.length > 0 || wantsTitle`, where `wantsTitle = template.titleFromContent && !allowed.has('noteTitle')` (a user property literally named `noteTitle` wins and is written to frontmatter; title-from-content is then disabled so the key is never double-defined). `extractFromBlock` returns the value on `CleanupResult.title` and deliberately keeps `noteTitle` OUT of the property `allowed` set, so it is never written to frontmatter.
|
||
|
||
Filename use (newFile only; `cursor` / `append` ignore `InsertParams.title`, symmetric with properties): generation is always requested when the flag is set and consumed only in `insertNewFile`, so it does not depend on `destinationOverride` (which can change `insertMode` after cleanup). [src/insert.ts](src/insert.ts) `expandFilenameTemplate(template, title?)` adds a `{{title}}` token; `insertNewFile` substitutes it when present (then collapses doubled spaces a missing token leaves and trims), or makes the title the whole stem when the name template has no token. `titleToFilename` hardens the model output (collapse whitespace, reuse the exported `sanitizeFilename`, strip leading dots + trailing dots/spaces, cap at `MAX_TITLE_LEN` 100, guard reserved Windows device names) and returns `''` when nothing usable remains (including `sanitizeFilename`'s `'Untitled'` sentinel), in which case the filename falls back to the static name-template expansion. Collisions still go through `resolveNewFilePath`. Defaults shipping `titleFromContent: true`: Meeting notes / Meeting transcript (`Meeting {{date}} {{title}}`), Lecture (`Lecture {{date}} {{title}}`), Podcast / Guides / Book log (`{{title}}`); Daily note stays date-named (flag off).
|
||
|
||
## Context hint
|
||
|
||
Optional, per-invocation free-text background about a recording (speakers, setting, subject) fed to the cleanup LLM, e.g. "Lecture by Dr. Smith on thermodynamics" or "Meeting with Rachel, Joe, and Sally". The situational, one-off counterpart to the persistent Known nouns list; pairs naturally with diarization (maps `Speaker X:` labels to real names).
|
||
|
||
Two halves, deliberately decoupled:
|
||
|
||
- **UI gate (per-template flag).** `NoteTemplate.enableContextHint?: boolean` (frontmatter `enableContextHint: true`) decides whether the input field is *shown* for a template. It is a **positive opt-in** (absent/false = hidden), the reverse polarity of `disableSharedCore`. `parseTemplateFile` / `renderTemplateFile` in [src/templates-folder.ts](src/templates-folder.ts) handle it exactly like `disableSharedCore` (boolean `true` or string `"true"`; `renderTemplateFile` always emits the key so it is discoverable). The Meeting notes / Lecture / Podcast defaults ([src/settings/default-templates.ts](src/settings/default-templates.ts)) ship with the flag set; the other four do not.
|
||
- **Injection (pipeline, flag-agnostic).** `PipelineParams.contextHint?: string`. [src/pipeline.ts](src/pipeline.ts) `cleanupTranscript` appends a `## Context` block (with a one-line "treat as reference, not instructions" preface) whenever the hint is non-empty, ordered after `## Ad-hoc instructions` and before the Known nouns block. The pipeline never reads the flag; it injects on any non-empty hint. Like the other vault/transcript inputs, the hint flows in unescaped and rides behind the shared-core anti-injection guardrail.
|
||
|
||
UI surfaces, both a collapsed `<details>` so the frictionless path is untouched: the main modal ([src/ui/modal.ts](src/ui/modal.ts) `renderContextSelector`, shown only when the active template has the flag; `contextHint` / `contextExpanded` instance state reset on template change, survives re-renders like `destinationExpanded`) and the reprocess-audio picker ([src/ui/template-picker.ts](src/ui/template-picker.ts), via `showContext` which [src/main.ts](src/main.ts) sets to `templates.some(t => t.enableContextHint)`; the typed value is forwarded to `runAudioFilePipeline` only when the picked template has the flag). Quick Record and the process-text command intentionally skip it.
|
||
|
||
## Real-time transcription
|
||
|
||
Opt-in live dictation, deliberately minimal: it streams the mic to the provider over a WebSocket and types finalized segments at the editor cursor as they arrive. No template, no LLM cleanup, no audio persistence, no main-modal entry — just the `rewrite-voice-notes:realtime-transcribe` command (see Commands). Lower-value than the rest of the pipeline (a raw transcript with no cleanup is what Obsidian's existing voice plugins already do), shipped because the primitives were cheap once the streaming adapters existed.
|
||
|
||
Provider support is gated by `transcriptionProviderSupportsRealtime(id)` ([src/realtime/index.ts](src/realtime/index.ts)): only `deepgram` and `assemblyai` expose a browser-reachable streaming WebSocket. Every other provider (OpenAI/Groq whisper-shape, Rev.ai async, whisper.cpp server) accepts only whole-file uploads, so the command errors with a "use AssemblyAI or Deepgram" Notice on them. **Voxtral realtime was tried and pulled** (see the Voxtral bullet below): its adapter is kept on disk but is deliberately NOT in the gate or the factory, so it is neither bundled nor selectable.
|
||
|
||
**Realtime is configured ENTIRELY independently of batch transcription** — its own provider, key, and model — so a profile can run e.g. Voxtral for batch, AssemblyAI for realtime, and Anthropic for cleanup. `EnvironmentProfile.realtimeProvider` (a `TranscriptionProviderID`; `'none'` = off, else a realtime-capable id) plus `realtimeConfig` (a `TranscriptionConfig` shape; only `apiKey`/`model` used). Reason: streaming providers/models/keys differ from batch. The key is persisted under secret id `profile-{kind}-realtime` (see Provider system / docs/SECRETS.md), hydrated/stripped/merged alongside the transcription and LLM slots; `realtimeProvider` is validated by `pickEnum` in `mergeProfile`. The settings tab renders an independent "Real-time transcription" section — a **Real-time provider** dropdown (None + realtime-capable only) that on change re-renders, plus key + model when a provider is picked (`renderRealtimeSection` in [src/settings/tab.ts](src/settings/tab.ts)). The model field is the same adaptive control as the batch fields (`populateModelField(..., 'realtime')`: dropdown + Refresh where the provider supports `listModels`, e.g. Deepgram; a plain text field otherwise, e.g. AssemblyAI), reading `profile.realtimeProvider` / `realtimeConfig` and sharing the transcription model cache. `startRealtimeTranscription` and `RealtimeController.begin()` gate on `realtimeProvider` (not `transcriptionProvider`) and `realtimeConfig.apiKey`.
|
||
|
||
- **Capture** ([src/realtime/pcm.ts](src/realtime/pcm.ts)): `PcmCapture` taps the mic with a `ScriptProcessorNode` (deprecated but universally available; `AudioWorklet` would need a separate module file a single-bundle plugin can't ship under the app CSP; rather than `eslint-disable` the deprecation rule, which the review bot forbids, the deprecated members are reached through local `*Like` type-aliases that carry no `@deprecated` marker), downsamples the context rate to 16 kHz via the pure `downsampleBuffer` (linear interpolation), quantizes to signed-16 PCM via the pure `floatTo16BitPcm`, and emits `ArrayBuffer` chunks. Both helpers and `REALTIME_SAMPLE_RATE` are exported and unit-tested. The node is routed through a zero-gain node to `destination` (a ScriptProcessor only runs while connected) so nothing is audible.
|
||
- **Session interface** ([src/realtime/index.ts](src/realtime/index.ts)): `RealtimeProvider.start(config, sampleRate, callbacks) -> RealtimeSession`, where `RealtimeSession` is `{ sendAudio(chunk), stop() }` and callbacks are `onFinal` / `onInterim` / `onError` / `onUnexpectedClose`. `waitForOpen` / `waitForClose` are shared WS plumbing.
|
||
- **Deepgram** ([src/realtime/deepgram.ts](src/realtime/deepgram.ts)): auth rides the `['token', <key>]` WebSocket subprotocol (browser WebSockets can't set headers), so the key never touches the URL. `is_final` marks a finalized segment; the profile's batch model (nova family) generally works live, empty falls to Deepgram's default. `stop()` sends `{ type: 'CloseStream' }` to flush.
|
||
- **AssemblyAI** ([src/realtime/assemblyai.ts](src/realtime/assemblyai.ts)): browser WS can't send the API key as a header and AssemblyAI has no subprotocol auth, so the real key first buys a short-lived (60 s) single-use token over a normal header-authed `jsonGet`, and only that ephemeral token rides the WS URL query. **This is the one sanctioned exception to the "auth never goes in the query" rule** (documented in the HTTP gotcha too): the token expires fast, can't mint more, and no error path echoes the URL. `format_turns: true` re-sends each turn once formatted; only the formatted end-of-turn is treated as final so a turn isn't inserted twice. `stop()` sends `{ type: 'Terminate' }`.
|
||
- **Voxtral** ([src/realtime/voxtral.ts](src/realtime/voxtral.ts)): **UNWIRED as of 1.2.0.** The adapter exists (protocol reverse-engineered from the mistralai Python SDK `src/mistralai/extra/realtime/`: `wss://api.mistral.ai/v1/audio/transcriptions/realtime?model=<model>`, `session.update` then base64 `input_audio.append` split at the 262144-decoded-byte cap, `flush`/`end` on stop, `transcription.text.delta`/`transcription.done` back) but live testing confirmed the AUTH CAVEAT: the browser WebSocket can only send the key via the `['token', <key>]` subprotocol (no `Authorization: Bearer` header from a WebView), and Mistral's realtime endpoint rejects that handshake, so Voxtral realtime is not WebView-reachable. The file is kept on disk, documented, as a starting point for a contributor who finds a working browser-auth path; it is NOT referenced by `createRealtimeProvider` or `transcriptionProviderSupportsRealtime`, so it is not bundled or selectable. To re-enable, wire it back into both. See `wiki/Voxtral-Realtime.md`.
|
||
- **Controller** ([src/ui/realtime.ts](src/ui/realtime.ts)): `RealtimeController` owns the capture + session + a floating status bar (reuses the `.rewrite-quick-floater` shell + `.rewrite-realtime-interim` line). `insertFinal` uses `editor.replaceSelection(text + ' ')` so segments chain like typing. `finish()` is a graceful stop (end capture, let trailing finals flush, dispose); `cancel()` is a hard teardown for errors/unload. One session at a time, owned by the plugin via `activeRealtime` + `realtimeStarting` (mirrors `activeQuickRecord`), cancelled in `onunload`. `startRealtimeTranscription` gates on unlocked secrets, a realtime-capable provider, a key, capture availability, and an open Markdown editor (the transcript needs a cursor).
|
||
|
||
## Auto-ingest folders
|
||
|
||
Semi-automatic batch ingestion for audio captured outside Obsidian and dropped into a vault folder. A user-run command (NOT a live watcher — a `vault.on('create')` watcher would fire for every existing file on vault load, a "startup storm"), so the user controls when a batch runs. Config is `GlobalSettings.ingestRules: { folderPath, templateId, enabled }[]`.
|
||
|
||
[src/ingest.ts](src/ingest.ts) `runIngestBatch(plugin)` gates on enabled rules existing, unlocked secrets, and a fully-configured voice profile (same checks as manual reprocess), then `collectWork` (async) resolves each enabled rule's folder + template. A rule is skipped (with a Notice) when its template is missing or is not `insertMode: 'newFile'` — `isIngestTemplate` (exported, tested) enforces newFile-only because unattended ingest has no active editor, so `cursor`/`append` would silently cascade to `newFile` anyway; requiring it makes the destination explicit. A rule is ALSO skipped up front when its folder is where recordings would be stored (see the same-folder guard below). `collectIngestFiles` (exported, tested) takes only direct `TFile` children filtered to `AUDIO_EXTENSIONS`, sorted by name (no recursion, so organized subfolders aren't swept up); a path seen by two rules is queued once. The batch runs **serialized** (one pipeline at a time) behind a sticky `Notice` with a Cancel button (`AbortController`), reporting a per-batch "N processed, M failed" summary.
|
||
|
||
Each file runs the pipeline with `source: { kind: 'audio', audio, sourcePath: file.path }` (skips `persist-audio`, reuses the path for the `![[embed]]` prepend, exactly like reprocess). **Move-on-success is the dedupe** (and the requested last step): after a fully successful run, `moveIngestedFile` moves the recording to the attachments location via `app.fileManager.renameFile` (which rewrites the just-created note's embed to the new path automatically), so a re-run never reprocesses it — no processed-path bookkeeping in `data.json`. A file that fails stays in the ingest folder and is retried next run (failures don't move). **Same-folder guard, two layers:** the move would be a pointless in-place de-collision rename (and the file would be re-ingested every run) whenever the ingest folder IS the attachments/recordings location. `collectWork` catches this UP FRONT, before creating any note: it resolves where recordings would land for the rule's template via the side-effect-free `resolveAttachmentFolder` ([src/audio-persist.ts](src/audio-persist.ts), the folder-only sibling of `resolveAttachmentPath` that does NOT create folders) and skips the rule with a Notice when that folder equals the rule folder (`normalizeIngestFolder`, exported/tested, normalizes root + trailing-slash variants before comparing). `moveIngestedFile` keeps the original per-file backstop (`ingestTargetIsSameFolder`) for the case only knowable at move time (empty attachments setting + note-relative Obsidian setting), throwing a reported error rather than churning the file. Ships on all platforms including mobile-only (a user-run file-move command, no cross-device double-processing hazard, so no `Platform.isDesktop` gate). Rule creation/editing is a popup ([src/ui/ingest-rule-modal.ts](src/ui/ingest-rule-modal.ts), template dropdown filtered to newFile templates); the settings "Auto-ingest folders" section lists rules with enable toggle / Edit / Delete plus an Add button. `resolveAttachmentPath` was extracted from `persistAudio` in [src/audio-persist.ts](src/audio-persist.ts) so the mover resolves the same destination a live recording would.
|
||
|
||
## Commands
|
||
|
||
Registered in [src/main.ts](src/main.ts):
|
||
|
||
- **`rewrite-voice-notes:open-modal`** ("Open"): opens the main modal with the last-used template selected.
|
||
- **`rewrite-voice-notes:quick-record`** ("Quick record (last used)"): starts a recording immediately with a floating mini-UI (no modal). Second press toggles to Stop. Template = `pickQuickRecordTemplate` (`lastUsedTemplateId` → `defaultTemplateId` → `templates[0]`). On unconfigured profile or capture-API unavailability, opens the modal instead. On post-capture pipeline error, opens the modal so the user can retry (the persisted audio file is the recovery path; the pipeline no longer copies the transcript to the clipboard).
|
||
- **`rewrite-voice-notes:quick-record-fixed`** ("Quick record (set template)"): same flow, but records with the template chosen in `GlobalSettings.quickRecordTemplateId` (Settings dropdown beside Default template). If that id is unset or no longer resolves, it shows a "choose a Quick record template in settings" `Notice` and does not start (no `templates[0]` fallback, unlike the last-used command). Both commands share `toggleQuickRecord(opts?)` and the single `activeQuickRecord` ref, so either one stops an in-flight recording. The floating UI shows a stop-hotkey hint ("Press <combo> or click Stop") read live from the command's binding; see the Gotcha on `hotkeyManager`.
|
||
- **`rewrite-voice-notes:process-text`** ("Process text with template"): runs a template over the active editor's selection (or the whole note body if there's no selection). Opens a template quick-picker, then runs the pipeline in the background with progress shown via `Notice`. Gates on LLM-only configuration; opens the main modal's setup card when not configured. Bails with a Notice when no Markdown editor is active.
|
||
- **`rewrite-voice-notes:reprocess-audio`** ("Reprocess audio file with template"): reruns the pipeline over an audio file already in the vault. Opens an `AudioFilePickerModal` (`FuzzySuggestModal<TFile>` filtered to `AUDIO_EXTENSIONS` from [src/audio-persist.ts](src/audio-persist.ts)) then the template quick-picker, then calls `runAudioFilePipeline` in [src/ui/audio-source.ts](src/ui/audio-source.ts). The pipeline skips its `persist-audio` stage because the `audio` source variant carries a `sourcePath` (the existing vault path is reused for the `![[<path>]]\n\n` prepend). Gates on the full voice profile (`isProfileConfigured`).
|
||
- **`rewrite-voice-notes:process-ingest-folders`** ("Process auto-ingest folders"): runs the auto-ingest batch (`runIngestBatch` in [src/ingest.ts](src/ingest.ts)). See Auto-ingest folders above. Plain `callback` (available everywhere, including mobile-only vaults).
|
||
- **`rewrite-voice-notes:realtime-transcribe`** ("Real-time transcription (start/stop)"): toggles live dictation (`toggleRealtime` → `startRealtimeTranscription` in [src/ui/realtime.ts](src/ui/realtime.ts)). Second press (or the floating bar's Stop) ends the session. Routed through the single `activeRealtime` slot + `realtimeStarting` guard. See Real-time transcription above.
|
||
- **`rewrite-voice-notes:start-whisper-host`** / **`rewrite-voice-notes:stop-whisper-host`**: start or stop the local whisper.cpp server. Both use `checkCallback` so the palette only shows them on desktop, when the active profile's transcription provider is `whisper-local` (start) or when the host is currently `running` / `starting` (stop). Errors surface via `Notice`. Same code paths as the settings-tab Start/Stop button.
|
||
|
||
Plus an editor-menu item "ReWrite with template..." registered via `workspace.on('editor-menu', ...)` (and a second "Reprocess audio with template..." item that appears only when the cursor sits inside an `![[<audio>]]` embed, resolved via `app.metadataCache.getFirstLinkpathDest`), a `workspace.on('file-menu', ...)` handler that adds "Reprocess audio with template..." for audio files in the file explorer, an `addRibbonIcon('mic', 'ReWrite', ...)` that opens the modal, and a status-bar item ([src/ui/whisper-status-bar.ts](src/ui/whisper-status-bar.ts)) showing the live whisper-host status. The status bar polls `whisperHost.status()` every 1 s via `registerInterval`, click toggles start/stop, and the item is hidden via the `rewrite-hidden` CSS class when on mobile or when the active profile is not `whisper-local`.
|
||
|
||
## Code style
|
||
|
||
Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Matches the existing source.
|
||
|
||
## Obsidian plugin conventions
|
||
|
||
[AGENTS.md](AGENTS.md) has the full Obsidian-specific playbook. The non-obvious rules that actually constrain implementation:
|
||
|
||
- **Never change `manifest.json`'s `id` after release.** It's `rewrite-voice-notes`. Locked. (Renamed once pre-release from the invalid `rewrite-plugin`, which Obsidian's manifest rules reject because an id may not end in `plugin`; no users were affected. Do not change it again.)
|
||
- **Use `this.register*` helpers** (`registerEvent`, `registerDomEvent`, `registerInterval`) for anything that needs cleanup. Otherwise reload/unload leaks. The Quick Record floater is the one exception (a `document.body` div lifecycled by `QuickRecordController.cancel()`, which `onunload` calls).
|
||
- **Mobile compatibility**: avoid Node/Electron APIs unless `manifest.json` sets `isDesktopOnly: true`. It's `false`, and the spec's mobile profile depends on this.
|
||
- **Keep [src/main.ts](src/main.ts) minimal**: only plugin lifecycle, command registration, settings tab registration. Feature logic belongs in dedicated modules.
|
||
- **Defer heavy work**: no long tasks in `onload`. Providers/recorders lazy-init when first used.
|
||
- **Network policy**: provider calls go to user-configured endpoints with user-provided keys. No telemetry, no auto-update of plugin code, no `fetch`+`eval`.
|
||
- **Releases**: GitHub release tag must exactly match `manifest.json`'s `version` (no leading `v`). Attach `main.js`, `manifest.json`, `styles.css` as individual binary assets (not zipped). This is automated by [.github/workflows/release.yml](.github/workflows/release.yml): pushing a version tag builds the bundle, runs `actions/attest-build-provenance` (GitHub artifact attestations for provenance), and publishes the assets via `softprops/action-gh-release`. To cut a release, push a tag named exactly the version; do not hand-upload assets (that loses attestation). A tag carrying a pre-release suffix (any `-`, e.g. `1.3.0-alpha`/`1.3.0-beta`) is instead published as a GitHub **prerelease**: the workflow stamps the tag's version into the *published* `manifest.json` only (master's copy is untouched, so the official updater and community review never see it). This channel is where ALL release testing happens — every stable release is a tested prerelease plus only the version-bump commit (and doc-only changes). See "The alpha/beta channel" section of RELEASING.md. **Before publishing any release, follow [docs/RELEASING.md](docs/RELEASING.md)** for the full step-by-step (prerelease tag, feature pass, bump, bare tag, verify) and the guideline-conflict checklist that keeps the Obsidian community review green.
|
||
|
||
## Gotchas
|
||
|
||
### Review-bot type environment (lint parity)
|
||
|
||
The Obsidian community-review bot runs the same typed ESLint rules our config mirrors, but in a **different type environment**: it uses this repo's `tsconfig.json` while lacking parts of our `node_modules` (`@types/node`, `moment`'s typings) and possibly running an older TypeScript. Anything error-typed there trips `no-unsafe-*` at every use, even though local lint is clean — all ~30 of the 1.2.1 review warnings were this (see [docs/DEVCONFLICTS.md](docs/DEVCONFLICTS.md) finding 10 and the parity section of [docs/RELEASING.md](docs/RELEASING.md)). Three standing invariants:
|
||
|
||
- **Keep `tsconfig.json`'s `lib` matched to the APIs the code uses; `types: []` enforces it.** With `types: []`, an API newer than the declared lib fails `npm run build` locally instead of being silently typed by the test suite's auto-included `@types/node` (which is exactly how ES2017+ usage slipped past an ES2016 lib before 1.2.1).
|
||
- **Never call `moment` (re-exported by `obsidian`) directly; go through `formatMoment` in [src/time.ts](src/time.ts).** `obsidian` itself types fine in the bot's environment, but its `moment` export's type comes from the `moment` package, which does not resolve there, so direct calls are error-typed. The wrapper reaches it through a narrow structural alias (same pattern as `ScriptProcessorNodeLike`).
|
||
- **Avoid TS-version-dependent type assertions.** On TS 5.7+ a value declared `Uint8Array` is `ArrayBufferLike`-backed and needs `as BufferSource` for WebCrypto params; on older TS that same assertion trips `no-unnecessary-type-assertion`. Write code that needs no assertion on any version: let byte-helper return types be inferred (a `new Uint8Array(...)` infers `ArrayBuffer`-backed on 5.7+), type params that only feed WebCrypto as `BufferSource`, and copy a foreign library's bytes (`new Uint8Array(raw)`) rather than asserting ([src/secrets.ts](src/secrets.ts) does all three). Never write the generic `Uint8Array<ArrayBuffer>` syntax — it is a hard error before TS 5.7. Prefer a type-predicate narrow (`isRecord`) over an `as Record<string, unknown>` assertion where either would do.
|
||
|
||
### HTTP and provider requests
|
||
|
||
- **`requestUrl` multipart bodies are hand-built.** `requestUrl` does not accept `FormData`. [src/http.ts](src/http.ts) exports `buildMultipart(parts)` which produces a `Uint8Array` with a random boundary; transcription adapters (Whisper, Rev.ai) call into it. If you add a multipart-LLM provider, reuse this rather than reaching for `FormData`.
|
||
- **`requestUrl` uses `throw: false` + status check.** All adapters surface non-2xx as `ProviderError` with `status` and `body`, so users see provider-attributed errors instead of opaque network failures.
|
||
- **Provider auth always goes in a header, never the URL query.** Every adapter passes its key via a header (`Authorization: Bearer`, `x-api-key`, `Token`, and `x-goog-api-key` for Gemini). Do not add a `?key=`/`?token=` query-auth provider: query strings leak into proxy/CDN logs, history, and `requestUrl`'s network-failure message. As a backstop, the network-failure `catch` in [src/http.ts](src/http.ts) `providerRequest` runs `redactQueryStrings` over the message (replaces any `?...` with `?<redacted>`) before building the `ProviderError`, so even a future query-auth slip cannot surface a secret in a Notice or log. The `body.slice(0, 200)` truncation in the `ProviderError` constructor is unrelated and left as-is (response bodies do not carry the request key). **One sanctioned exception:** the AssemblyAI realtime adapter ([src/realtime/assemblyai.ts](src/realtime/assemblyai.ts)) puts a token in the WebSocket URL query, because browser WebSockets cannot set an Authorization header. It is NOT the API key: the real key (sent as a header) first mints a short-lived, single-use 60 s token, and only that expiring token rides the query. Do not generalize this to the REST adapters, and do not put a real long-lived key in a query under any circumstance.
|
||
|
||
### Pipeline and provider system
|
||
|
||
- **Both provider unions include `'none'`.** [src/types.ts](src/types.ts) `TranscriptionProviderID` and `LLMProviderID` carry a `'none'` member for users who only want one half of the pipeline. The factories in [src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) return sentinel providers (transcription throws on `transcribe()`; LLM `complete()` returns the user message unchanged), but the pipeline never actually calls these because: (a) `collectTranscript` throws a friendlier error when `transcriptionProvider === 'none'` and `source.kind === 'audio'`; (b) `cleanupTranscript` short-circuits and returns the raw transcript when `llmProvider === 'none'` (this also skips wake-name extraction and known-nouns injection, since both only matter when an LLM consumes the system prompt). The settings tab + setup card hide model/baseUrl/apiKey fields for the `'none'` side; `isProfileConfigured` / `isProfileConfiguredForText` treat `'none'` as configured. The modal's Record tab, Quick Record, and the reprocess-audio command all gate on `transcriptionProvider === 'none'` with a "use Paste instead" hint.
|
||
- **PipelineHost decouples the pipeline from `ReWritePlugin`.** [src/pipeline.ts](src/pipeline.ts) reads `params.host.assistantPrompt` and `params.host.knownNouns` through the narrow `PipelineHost` interface in [src/types.ts](src/types.ts). The plugin class `implements PipelineHost`, but the pipeline never imports `ReWritePlugin` directly, which would create a cycle through the UI layer. New cross-cutting cleanup-stage inputs should extend `PipelineHost` rather than reach for the plugin object.
|
||
- **Per-provider recording limits live in [src/transcription/limits.ts](src/transcription/limits.ts), not the recorder.** [src/recorder.ts](src/recorder.ts) does not cap recordings at any size; `validateRecording(blobSize, durationMs, providerId)` runs in [src/pipeline.ts](src/pipeline.ts) between the `persist-audio` and `transcribe` stages, throwing a friendly provider-attributed error if the recording exceeds the documented byte or duration ceiling. Both modal and Quick Record thread the recorder's `durationMs` onto the `audio` pipeline source so the duration check has data; the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) omits `durationMs` (no cheap way to measure an arbitrary vault file), so reprocess only triggers the byte check. Limits source: `openai`/`groq` 25 MB, `assemblyai` 5 GB/10 h, `deepgram` 2 GB, `revai` 2 GB/17 h, `mistral-voxtral` 1 GB/30 min, `openai-compatible`/`whisper-local`/`webspeech` no client-side cap.
|
||
- **Async poll timeout is duration-aware**, also in [src/transcription/limits.ts](src/transcription/limits.ts) via `pollTimeoutMs(durationMs?)`. The two polling adapters ([src/transcription/assemblyai.ts](src/transcription/assemblyai.ts), [src/transcription/revai.ts](src/transcription/revai.ts)) used to share a flat `POLL_TIMEOUT_MS = 60_000`, which made any recording the server took longer than a minute to process fail before it finished. They now derive the timeout from the recorded length: `min(60s + durationMs * 2, 2 h)`, so a short clip with a problem fails in ~1 min while a long job has room. `durationMs` is threaded as the optional 4th arg of `TranscriptionProvider.transcribe(audio, config, signal?, durationMs?)` from [src/pipeline.ts](src/pipeline.ts) (`source.durationMs`); the non-polling adapters ignore it (their impls simply omit the param). It is `undefined` for the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts), no cheap way to measure a vault file), which then falls back to the 2 h ceiling.
|
||
- **Poll loops tolerate a bounded run of transient errors before giving up.** A single failed status check (network blip, a 5xx) used to fail the whole transcription immediately, even on a job the server would have finished seconds later, exactly the wrong failure mode for the long-running providers polling exists for. Both `pollAssemblyAI` and `pollRevAI` now catch a poll request's error, and via `isTransientPollError` (checks for a network failure or `ProviderError.status >= 500`) retry up to `MAX_CONSECUTIVE_POLL_ERRORS` (3) in a row with the same backoff before rethrowing; a 4xx or an `AbortError` (user cancel) rethrows immediately since retrying can't fix either. The counter resets on any successful poll.
|
||
|
||
### LLM and token limits
|
||
|
||
- **`maxTokens` has two settings-tab views over one value.** `LLMConfig.maxTokens` (per profile, default `2560`) is the single source of truth. The normal-area "Maximum note length" dropdown (`renderNoteLength` in [src/settings/tab.ts](src/settings/tab.ts)) frames it in minutes via `NOTE_LENGTH_PRESETS` at `TOKENS_PER_MIN = 256` (≈150 wpm × ~1.3 tokens/word, padded ~20% for headings/bullets/`Speaker X:` labels; ~10 min → 2560). The Advanced "LLM max tokens" text field edits the same number raw; a value not matching a preset surfaces in the dropdown as a "Custom (N tokens, ~M min)" option. The dropdown calls `this.display()` on change so Advanced reflects the new number; the raw text field does not redraw (focus preservation), so the dropdown updates on the next full render. The Anthropic adapter's `config.maxTokens > 0 ? ... : 2560` fallback ([src/llm/anthropic.ts](src/llm/anthropic.ts)) must stay in sync with the default. The cap is on **output** tokens, so it bounds note length, not input.
|
||
- **`maxTokens` over a model's output cap is remapped to a friendly error.** When the requested output cap exceeds a model's max output tokens, Anthropic and OpenAI return a cryptic HTTP 400. `remapOutputLimitError` ([src/llm/index.ts](src/llm/index.ts)), applied as a `.catch()` on the `complete()` POST in [src/llm/anthropic.ts](src/llm/anthropic.ts) and [src/llm/openai.ts](src/llm/openai.ts), detects that specific 400 (body names `max_tokens`/`max_completion_tokens` + a "maximum/too large/at most/exceed" phrase) and rethrows a `ProviderError` pointing at the "Maximum note length" setting; all other errors pass through unchanged. Its `never` return preserves the awaited type. Gemini doesn't error the same way (it clamps `maxOutputTokens` and reports `finishReason: 'MAX_TOKENS'` on the response instead of an HTTP 400), so it gets no `remapOutputLimitError`; [src/llm/gemini.ts](src/llm/gemini.ts) checks `finishReason` directly instead and throws the same "Maximum note length" message on `MAX_TOKENS`, plus clear messages for `RECITATION`/`PROHIBITED_CONTENT`. A response can also legitimately split its text across multiple `parts`; `complete()` concatenates all of them rather than reading only `parts[0]`, which used to silently truncate a note at the part boundary.
|
||
- **OpenAI reasoning models need `max_completion_tokens`, not `max_tokens`.** [src/llm/openai.ts](src/llm/openai.ts) `usesCompletionTokens(id, model)` switches the param name to `max_completion_tokens` when `id === 'openai'` and the model matches `/^(o\d|gpt-5)/i` (o1/o3/o4 + gpt-5 families), which reject the legacy `max_tokens`. Scoped to the first-party `openai` id only; `openai-compatible` and `mistral` keep `max_tokens`, so a reasoning model proxied behind an openai-compatible endpoint is a known gap. This only fixes the token param; o1-mini/o1-preview also reject `system` messages, which is not handled.
|
||
- **No baked-in model defaults.** Both profiles ship with `model: ""`. The modal renders an inline setup card that blocks recording/paste until the active profile has a provider, model, key, and (for `openai-compatible`) base URL. If you add a provider, do not bake a default model string; surface it as placeholder hint text.
|
||
- **`openai-compatible` base URL asymmetry** (literal interpretation of the spec): transcription appends `/v1/audio/transcriptions` to a *root* URL (`http://localhost:8080`); LLM appends `/chat/completions` to a URL that *already includes* `/v1` (`http://localhost:11434/v1`). The settings UI hint text and setup card both guide users; do not "normalize" one to match the other. The `openai-compatible` LLM option is also the supported route for cloud OpenAI-compatible services (DeepSeek, Kimi/Moonshot, Qwen/DashScope, Zhipu GLM) — there are no first-class provider entries for them; the README's "Cloud OpenAI-compatible LLMs" section carries the per-provider base URLs. The LLM option label is "OpenAI-compatible (cloud or local)" to reflect this.
|
||
|
||
### Templates, properties, and insertion
|
||
|
||
- **Templates are vault files, not settings.** There is no `settings.templates` array. Consumers read `plugin.templates` (refreshed from disk). When you add a field to `NoteTemplate`, update [src/templates-folder.ts](src/templates-folder.ts) on both sides: `parseTemplateFile` reads it out of frontmatter (with a sensible default if missing), and `renderTemplateFile` writes it into the frontmatter the populate button emits. The populate button is non-destructive: it skips files whose `id` already exists, so re-running it tops up the folder without clobbering user edits.
|
||
- **The two template frontmatter flags have opposite polarity.** `disableSharedCore` is a negative opt-out (set it to turn a default OFF); `enableContextHint` is a positive opt-in (set it to turn a feature ON). Don't "harmonize" them. `enableContextHint` only gates whether the modal / reprocess-picker shows the Context field; the pipeline injects a `## Context` block on any non-empty `PipelineParams.contextHint` without consulting the flag (see Context hint section).
|
||
- **Note-property extraction must run before the audio-embed prepend, and frontmatter is written newFile-only.** [src/pipeline.ts](src/pipeline.ts) `extractFromBlock` strips the leading ```` ```yaml ```` block off the LLM output inside `cleanupTranscript`, BEFORE `runPipeline` prepends `![[<path>]]\n\n`; prepending first would push the YAML off byte 0 and it would never parse as frontmatter. The parsed values are written via `app.fileManager.processFrontMatter` in `insertNewFile` only (after `vault.create`, before `openLinkText`) so the real `---...---` lands above the embed; `cursor` / `append` ignore `InsertParams.properties`. A present block is always stripped from the body (even when malformed YAML triggers the tolerant line-based fallback); a missing block leaves the whole output as the body. See Note properties.
|
||
- **`noteTitle` is filename-only — never a frontmatter property.** The reserved `noteTitle` key (`titleFromContent`) shares the one leading ```` ```yaml ```` block with `noteProperties`, but `extractFromBlock` deliberately keeps it OUT of the property `allowed` set and returns it on `CleanupResult.title`. Do NOT "fix" the extractor to also write it into `properties`/frontmatter; it exists to name the file (`{{title}}` token or whole-name replacement in `insertNewFile`). It is the model's own generated string, independent of any `title` noteProperty (e.g. Book log's `title` property and its filename title are separate axes and need not match). Caveat: `resolveNewFilePath` collision detection is case-sensitive (`getAbstractFileByPath`), so on case-insensitive filesystems two titles differing only by case can still collide at `vault.create`; pre-existing, just likelier with content-derived names. See Note title.
|
||
- **Frontmatter parsing uses `parseYaml` from Obsidian, not the metadata cache.** The metadata cache is async and may not be populated for newly created files; reading content via `app.vault.read(file)`, splitting off the leading `---...---` block, and parsing it with `parseYaml` is synchronous-enough and works immediately after `app.vault.create`.
|
||
- **New-file collisions are resolved by `insert.ts`, not the caller.** `GlobalSettings.newFileCollisionMode` is `'auto'` (silently iterate `name-1.md`, `name-2.md`, ...) or `'prompt'` (open `RenamePromptModal` defaulted to the next free path; Cancel throws `Insert canceled: file already exists.`). Threaded through `InsertParams.collisionMode` from `pipeline.ts`. The path search uses `app.vault.getAbstractFileByPath` and caps at 1000 iterations. `nextFreePath` is local to [src/insert.ts](src/insert.ts); the equivalent `deCollide` in [src/audio-persist.ts](src/audio-persist.ts) is intentionally not shared — audio always auto-iterates regardless of the setting (the file is a side-effect users keep; the new-note path is the *target* the user named).
|
||
- **`guardReservedName` (exported from [src/templates-folder.ts](src/templates-folder.ts)) is the single reserved-filename guard; call it LAST in any sanitization pipeline.** It rejects purely-dot names (would produce a hidden Unix file or an OS-trimmed Windows one; falls back to `'Untitled'`) and the reserved Windows device names (`CON`/`PRN`/`AUX`/`NUL`/`COM1-9`/`LPT1-9`, case-insensitive; prefixes with `_`). `sanitizeFilename` calls it on its own output. `insert.ts`'s `titleToFilename` (LLM-generated titles get extra hardening: dot-stripping, length cap) calls it AGAIN on its own final result, because an intermediate transform can turn a name that wasn't reserved right after `sanitizeFilename` into one that is (e.g. `"con."` becomes `"con"` only after trailing-dot stripping): checking only once, early, would miss that.
|
||
- **`pickDefaultTemplateId(settings, templates)`, exported from [src/templates-folder.ts](src/templates-folder.ts), is the one place the `lastUsedTemplateId → defaultTemplateId → templates[0]` fallback chain lives.** [src/main.ts](src/main.ts) and [src/ui/modal.ts](src/ui/modal.ts) both call it rather than keeping their own copies; if you need the same "which template is currently default" logic elsewhere, call this instead of reimplementing it.
|
||
- **Destination override does not mutate the template object.** [src/ui/modal.ts](src/ui/modal.ts) renders a per-invocation Destination control (insertMode + conditional newFile fields) and threads the result through `PipelineParams.destinationOverride`. [src/pipeline.ts](src/pipeline.ts) shallow-merges the override onto a *copy* of the template via `applyDestinationOverride` before calling `insertOutput`; the cached template and the file on disk remain untouched. The override is ephemeral: it resets when the modal closes and when the template selector changes. Not exposed in Quick Record, `runTextPipeline`, or `runAudioFilePipeline` (no UI surface). The UI is a collapsible `<details>` whose `<summary>` reads `"Destination: Default (<description>)"` (no override) or `"Destination: Custom (<description>)"` (override set, forced open); expand state is tracked on `ReWriteModal.destinationExpanded` so it survives the full-container re-renders that fire when the inner insertMode dropdown changes. `describeDestination(mode, folder, name)` formats the description (e.g. `New file: ReWrite Notes/{{date}}-note`).
|
||
|
||
### Cleanup-prompt inputs
|
||
|
||
- **Wake-name extraction is regex-only, off by default.** [src/wake-name.ts](src/wake-name.ts) requires `<assistantName>,` (vocative comma) to fire, captures up to the next sentence terminator or next name occurrence, and drops filler matches ("never mind", "scratch that", short tokens). It runs on ALL pipeline sources, including `paste` and `text`. The extracted instructions are appended to the LLM system prompt as a numbered `## Ad-hoc instructions` block, prefaced by `plugin.assistantPrompt` (loaded from `GlobalSettings.assistantPromptPath`); when the file is missing or empty, `DEFAULT_ASSISTANT_PROMPT` from [src/assistant-prompt.ts](src/assistant-prompt.ts) is used as the fallback so behavior is identical to the previous hardcoded textarea. Both the OpenAI and Anthropic adapters route this into the API's system slot. Whisper transcription homophones ("Scribner", "Scrivner") are not fuzzy-matched in v1; document the limitation if a user reports misses.
|
||
- **Known nouns frontmatter is NOT sent to the LLM.** The vault file at `GlobalSettings.knownNounsPath` uses YAML frontmatter purely for human-readable guidance (token-cost warning, format hint). Only the body lines are parsed via `loadKnownNounsFromFile` and injected by `buildKnownNounsSystemPromptSection`. If a future change opts frontmatter in, it should be a per-vault opt-in setting, not the default. The body parser treats `#` lines and blank lines as ignored; an entry can be either bare canonical (`Anthropic`) or canonical + misheard alternates (`Hoxhunt: hawks hunt, hocks hunt`).
|
||
|
||
### Recording and capture
|
||
|
||
- **The main modal locks its controls while recording or running a pipeline.** `ReWriteModal.isRecording` lives on the instance (not a tab-local closure), alongside the pre-existing `running` flag; `isLocked()` is `isRecording || running`. A mid-recording `render()` (from switching tabs, changing the template, or editing the destination) used to rebuild the record tab with a fresh "Record" button while the old `Recorder` and its 250 ms timer kept running with no way to stop them. The tab bar buttons, template `<select>`, and destination controls now check `isLocked()` and disable themselves (and no-op their change handlers as a second guard) for the duration, so the only interactive control left during a recording or a run is the Record/Stop button itself (or, for Paste/From note, the disabled submit button).
|
||
- **Audio persistence runs before transcription**, not after, so the user keeps the recording even if transcription fails. [src/audio-persist.ts](src/audio-persist.ts) catches its own errors and emits a `Notice`; the pipeline always continues to the transcribe stage even when persistence throws. Cancel paths in [src/ui/modal.ts](src/ui/modal.ts) and [src/ui/quick-record.ts](src/ui/quick-record.ts) call `recorder.cancel()` before `runPipeline()`, so no orphan file is written on cancel. The `![[<path>]]` embed is prepended to the cleaned output unconditionally when an audio file was saved, regardless of insert mode. The reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) skips persistence by passing `sourcePath` on the `audio` source variant; the embed prepend still runs, reusing the existing vault path so reprocessed output links back to the original file.
|
||
- **A screen wake lock is held for the duration of active recording.** Android (and iOS) suspend the Obsidian Capacitor WebView when the screen sleeps, which kills `MediaRecorder` capture mid-recording. [src/recorder.ts](src/recorder.ts) requests `navigator.wakeLock.request('screen')` in `start()` (and re-acquires in `resume()`), releases it in `pause()`, and tears it down via `stopWakeLock()` from the shared `releaseStream()` (so both `stop()` and `cancel()` cover it). The OS auto-releases a screen wake lock whenever the document becomes hidden, so a `visibilitychange` listener (registered in `startWakeLock`, removed in `stopWakeLock`) re-requests on the next `visible` transition while `state === 'recording'`. The acquire is best-effort and degrades silently: where the Wake Lock API is absent (older WebView, desktop builds, insecure context) or denied (`NotAllowedError`), recording proceeds without it. The API is reached through a narrow local `WakeLockLike` / `WakeLockSentinelLike` interface + `getWakeLock()` cast (it is not in every TS DOM lib version), mirroring the `hotkeyManager` pattern. Cost: the screen stays lit while recording (a partial CPU-only wake lock would need native code Obsidian doesn't expose to plugins). A `stop`/`cancel` that races ahead of the async `request('screen')` is handled by re-checking `state === 'recording'` before retaining the sentinel. The wake lock only prevents screen-sleep; it cannot stop an app switch from backgrounding (and suspending) the WebView, so both recording UIs additionally show a **mobile-only caution** for the duration of capture (`MOBILE_RECORD_WARNING_TEXT`, "Keep Obsidian in the foreground..."): `.rewrite-mobile-record-warning` in the modal's Record tab and `.rewrite-quick-mobile-warning` in the Quick Record floater, both shown only when `Platform.isMobile`, shown on record-start, hidden on stop / when the floater goes busy. Styled yellow (`--text-warning` outline + text, not the red `--background-modifier-error` of the silence warning) so it reads as an alert, not an error.
|
||
- **Live silence detection is a Web Audio tap on the recording stream.** [src/recorder.ts](src/recorder.ts) builds an `AnalyserNode` off the mic `MediaStream` in `start()` (no connection to `destination`, so no monitoring feedback) and samples peak amplitude every 100 ms via a `window.setInterval`. It exposes `getInputLevel()` (0..1 peak), `hasDetectedSound()`, and `getSilentMs()` (ms of continuous silence; returns 0 while paused / stopped or when the analyser could not be created, so the UI never warns when there is nothing to listen to). `resume()` resets the silence baseline so the paused gap is not counted. Both recording UIs poll `getSilentMs()` in their existing 250 ms timer loop and show a "No audio detected" warning past `SILENCE_WARNING_MS` (3 s): the modal's Record tab (`.rewrite-silence-warning`, [src/ui/modal.ts](src/ui/modal.ts)) and the Quick Record floater (`.rewrite-quick-silence-warning` below the controls row, [src/ui/quick-record.ts](src/ui/quick-record.ts) `setSilenceWarning`, hidden while busy). The monitor is torn down in `releaseStream()` (called by `stop()`/`cancel()`); any setup failure (no `AudioContext`, e.g. a stripped environment) degrades to "monitoring unavailable" and the recording proceeds without a warning. The threshold `SILENCE_LEVEL_THRESHOLD = 0.015` clears even quiet speech but flags a muted or dead mic.
|
||
|
||
### Real-time transcription
|
||
|
||
- **Realtime capture uses `ScriptProcessorNode`, not `AudioWorklet`.** The deprecation is deliberate ([src/realtime/pcm.ts](src/realtime/pcm.ts)): an `AudioWorklet` processor must be loaded from a separate module file via `addModule(url)`, which a single-file bundled Obsidian plugin cannot ship and the app's CSP complicates via blob URLs. ScriptProcessor is supported in every WebView Obsidian runs in. The deprecated API is reached through local structural type-aliases (`ScriptProcessorNodeLike` / `AudioProcessingEventLike` / `ScriptProcessorFactory`) instead of an `eslint-disable` comment (the community-review bot rejects disabling its rules); the aliases carry no `@deprecated` marker so `@typescript-eslint/no-deprecated` never fires. If ScriptProcessor is ever actually removed, that is the point to revisit; do not preemptively swap in a worklet.
|
||
- **Realtime auth cannot use a header (browser WebSocket limitation).** Deepgram takes the key via the `['token', <key>]` subprotocol (the key stays off the URL). AssemblyAI has no subprotocol auth, so it mints a short-lived single-use token over a normal header-authed request and puts only that token in the WS query — see the HTTP query-auth gotcha. Do not "simplify" either adapter to put the real key in the URL.
|
||
- **Realtime is one session at a time, plugin-owned.** `activeRealtime` + `realtimeStarting` mirror `activeQuickRecord` / `quickRecordStarting`; `onunload` calls `activeRealtime?.cancel()`. A realtime session and a Quick Record session are independent slots — they can technically both run — but each is individually single-instance. If you ever need them mutually exclusive, gate at `toggleRealtime` / `launchQuickRecord`.
|
||
- **Interim vs final segments.** Only `onFinal` text is inserted into the note; `onInterim` is display-only on the floating bar (superseded by later events). Treating an interim as final would double-insert or insert unpunctuated text. Both adapters map their provider's "formatted end of turn" / `is_final` to `onFinal`.
|
||
|
||
### Auto-ingest
|
||
|
||
- **Move-on-success IS the dedupe; there is no processed-path bookkeeping.** A successfully processed file leaves the ingest folder (moved to the attachments location via `app.fileManager.renameFile`, which also fixes the new note's `![[embed]]`), so a re-run can't see it again. A file that fails stays put and is retried next run. Do not add a "processed paths" list to `data.json` to compensate — the move is the whole mechanism, and it also gives the user the requested "recording ends up with the other recordings" behavior for free.
|
||
- **`renameFile` does NOT create the destination folder** (unlike `vault.create` / `createBinary`), so `resolveAttachmentPath` in [src/audio-persist.ts](src/audio-persist.ts) now `ensureParentFolder`s the resolved path before returning, and `moveIngestedFile` passes the just-created note's path as the `sourcePath` arg to `getAvailablePathForAttachment` so a note-relative attachment setting resolves correctly. Missing either was a real move-time failure mode. `resolveAttachmentPath` gained the optional `sourcePath` param for this; the recording flow passes none (harmless — no note yet).
|
||
- **A move failure after a successful pipeline is reported distinctly.** The note WAS created, so re-running would duplicate it. `ingestOne` wraps the `moveIngestedFile` call and rethrows a message that says the note was created and the user must move/delete the source themselves. The move-time same-folder backstop (destination folder equals the file's current folder — the ingest folder IS the attachments location) throws the same class of error rather than silently returning, so it surfaces in the batch's per-file error report instead of a lone Notice that could be missed.
|
||
- **The same-folder misconfiguration is now caught up front, not just at move time.** Pointing an ingest rule at the recordings/attachments folder used to only fail (or churn) after a note was already created. `collectWork` now resolves the would-be recordings folder for each rule via the side-effect-free `resolveAttachmentFolder` and skips the rule with a clear Notice BEFORE processing any file, so no stray note is created. The comparison goes through `normalizeIngestFolder` (root + trailing-slash tolerant, exported/tested). The move-time `ingestTargetIsSameFolder` guard remains for the dynamic case (empty ReWrite attachments setting + a note-relative Obsidian attachment setting) that can only be known once the note exists. Do NOT drop the up-front check in favor of the backstop: the whole point is to avoid creating a note you then cannot dedupe.
|
||
- **Per-file failures surface their actual reason.** `runIngestBatch` collects each failure's message and shows them in a second sticky Notice (`• <file>: <reason>`, first 5, 15 s) on top of the "N processed, M failed" summary, plus `console.error`. A batch tool that only logged to the console left the user with no idea why a file failed; keep the reasons user-visible.
|
||
- **Ingest is newFile-templates only, enforced in two places.** `isIngestTemplate` gates both the rule-editor dropdown ([src/ui/ingest-rule-modal.ts](src/ui/ingest-rule-modal.ts)) and `collectWork` ([src/ingest.ts](src/ingest.ts)). Unattended runs have no active editor, so `cursor`/`append` cascade to `newFile` anyway; requiring newFile makes the note's destination explicit and predictable. A rule whose template is deleted or changed to non-newFile is skipped with a Notice, not silently run.
|
||
|
||
### Quick Record UI
|
||
|
||
- **Quick Record uses a custom floating div, not a `Notice`.** Obsidian `Notice` does not support real interactive buttons. The floater is a `position: fixed` div on `document.body`, owned by `QuickRecordController`, with `cancel()` wired into `onunload`.
|
||
- **Quick Record now carries optional per-invocation params for the modal's background handoff.** `QuickRecordController` / `startQuickRecord` accept `destinationOverride` + `contextHint` (used only by the modal's "Record in background" path). Switching templates via the floater popover clears both, since they were configured against the original template. The two Quick Record commands pass neither, so their behavior is unchanged.
|
||
- **Quick Record floater holds its own popover.** The floater grew a third button between the timer and Stop that opens a popover-style template list ([src/ui/quick-record.ts](src/ui/quick-record.ts)). The popover is a child of the floater div, listens for outside-click via a capture-phase `document` listener and Escape via `document keydown`, and cleans up both listeners on dismiss. The popover dismisses on selection, Escape, outside click, and when `setBusy` runs (the pipeline is in flight). Selecting a template updates `controller.template` but does NOT update `lastUsedTemplateId`; that only happens after a successful completion, matching pre-popover behavior. The floater also shows an optional stop-hotkey hint span (`.rewrite-quick-stop-hint`) before the Stop button when `startQuickRecord` is passed a `commandId` whose binding resolves (see next Gotcha).
|
||
- **The stop-hotkey hint reads Obsidian's internal `app.hotkeyManager`.** `formatCommandHotkey(app, commandId)` in [src/ui/quick-record.ts](src/ui/quick-record.ts) prefers `getHotkeys(id)` (user binding) over `getDefaultHotkeys(id)` (plugin default), formats it platform-aware via `Platform.isMacOS`, and returns `null` when unbound (the floater then shows only the Stop button, no placeholder text). The manager is not in the public `obsidian` typings, so it is reached through a narrow local `HotkeyManager` interface + `as unknown as` cast rather than disabling a lint rule. The hint is computed fresh each time a recording starts (`commandId` is `${manifest.id}:quick-record` or `:quick-record-fixed`), so rebinding takes effect on the next recording. If a future feature needs the same lookup, reuse this helper.
|
||
- **Quick Record's own mic-stream start is guarded against double-firing, separately from `activeQuickRecord`.** `ReWritePlugin.toggleQuickRecord` ([src/main.ts](src/main.ts)) only assigns `this.activeQuickRecord` after `await startQuickRecord(...)` resolves (it awaits `getUserMedia`), so two rapid hotkey presses could both observe `activeQuickRecord === null` and each open a mic stream, orphaning the first. A synchronous `quickRecordStarting` boolean is set before the `await` and cleared in a `finally`; the second press then no-ops instead of racing.
|
||
- **The floater's Cancel button keeps working after recording stops, not just during capture.** `QuickRecordController.finish()` builds an `AbortController` and threads its `signal` into `runPipeline`; the floater's Cancel button, previously a no-op while `busy` (processing), now calls `onAbortProcessing` in that state instead, which aborts the in-flight pipeline. The abort surfaces through the existing `catch` in `finish()` (Notice + reopen the main modal), same as any other post-capture failure.
|
||
- **The popover is keyboard-navigable.** `role="menu"`/`role="menuitem"` are set on the popover and its items, opening it moves focus to the active (or first) item, `ArrowUp`/`ArrowDown` cycle through items (wrapping), and closing it returns focus to the template button, so a keyboard user isn't dropped onto `document.body` or forced to Tab through the whole floater to reach the list.
|
||
|
||
### Settings tab UI
|
||
|
||
- **`setHeading()` instead of manual `<h2>`** inside settings tabs. `obsidianmd/settings-tab/no-manual-html-headings` forbids manual headings. Same applies anywhere else inside a settings tab that needs a section header. In [src/settings/tab.ts](src/settings/tab.ts), section headings go through the `sectionHeading(parent, name, icon)` helper, which builds the `setHeading()` Setting and prepends a Lucide icon (via `setIcon`) to its `nameEl` styled by `.rewrite-heading-icon`. It returns the `Setting` so callers that attach a status badge (profile, shared core) still reach `nameEl`. Add new headings via this helper, not a bare `new Setting(...).setHeading()`, so they keep an icon.
|
||
- **`window.confirm` is banned** by ESLint's `no-alert`. If a future phase needs an in-vault confirmation prompt, add a small `Modal` subclass rather than reaching for `window.confirm`.
|
||
- **Sentence-case lint covers brand and acronym lists** in [eslint.config.mts](eslint.config.mts). Adding a new provider, model family, or product name means adding it to `REWRITE_BRANDS` (or `REWRITE_ACRONYMS` for things like `LLM`). Dropdown option labels also pass through the rule; in [src/settings/tab.ts](src/settings/tab.ts) and [src/ui/setup-card.ts](src/ui/setup-card.ts), labels are iterated via `opt.label` (member access) to dodge the literal-string check.
|
||
- **Provider option arrays appear in both setup-card.ts and tab.ts.** Intentionally duplicated rather than extracted, per the "don't refactor beyond what the task requires" rule. If the lists drift, fix the user-visible inconsistency, not the duplication.
|
||
- **Settings tab re-renders the entire container on dropdown changes** that toggle conditional fields (provider, insertMode, activeProfileOverride). Text fields call `saveSettings()` on change but do not redraw, so focus is preserved while typing. Preserve this pattern when adding new conditional fields. (Obsidian's newer review linter flags the `display()` / `this.display()` redraw pattern as deprecated in favor of `getSettingDefinitions`, a declarative `1.13.0+` API that is not in our bundled typings. Migrating would force `minAppVersion` to 1.13.0+ and a full settings rewrite, so it is deliberately deferred; the redraw pattern still works.)
|
||
- **Profile sections wrap their settings in `.rewrite-profile-section`.** [src/settings/tab.ts](src/settings/tab.ts) `renderProfile()` creates a wrapper div per profile rather than rendering settings as direct children of `containerEl`. The active-on-this-device profile (per `detectActiveProfileKind`) gets `is-active-profile` (accent left border) and a `.rewrite-profile-active-badge` span inside the heading's `nameEl`. The inactive profile's body is wrapped in a `<details class="rewrite-profile-collapsed">` whose expand state lives on `ReWriteSettingTab.inactiveProfileExpanded` so it survives the full-container redraws triggered by dropdowns. New per-profile settings must take `body` as their parent (the wrapper or the `<details>`), not the original `parent` arg, or they will render outside the section's visual frame.
|
||
- **Async settings-tab buttons are concurrency-guarded.** Handlers that run an async op then `this.display()` (Populate buttons, whisper Start/Stop/probe, Lock) are wrapped in `runGuardedButton(b, fn)` in [src/settings/tab.ts](src/settings/tab.ts), which disables the button for the duration so a rapid double-click cannot launch the work (and its full-container re-render) twice. The encryption-mode dropdown uses a `modeChangeInFlight` flag for the same reason. Wrap new async-then-display buttons the same way. Each handler keeps its own try/catch; observability `console.error('ReWrite: <context>', e)` sits alongside the user-facing Notice in those catches (and in the swallowed catches in [src/main.ts](src/main.ts)).
|
||
- **`commit()` is the one place every field's `onChange` funnels through, and it swallows + surfaces save failures.** Setting components fire their `onChange` handler without awaiting or catching it, so an unhandled rejection inside (e.g. `saveSettings()` throwing because the OS keyring became unavailable mid-session while writing an API key) used to be silent. `commit()` in [src/settings/tab.ts](src/settings/tab.ts) wraps `this.plugin.saveSettings()` in try/catch, logs via `console.error`, and shows a Notice on failure. New fields should call `this.commit()` (or `await this.commit()`), not `this.plugin.saveSettings()` directly, to keep this coverage.
|
||
|
||
### Secrets and encryption
|
||
|
||
See [docs/SECRETS.md](docs/SECRETS.md) for the secrets/encryption gotchas (secretStorage probe, `saveManyKeys` no-op when locked, zxcvbn lazy-load, `.nosync` suffix).
|
||
|
||
### Whisper host (desktop)
|
||
|
||
See [docs/WHISPER_HOST.md](docs/WHISPER_HOST.md) for the whisper-host gotchas (lazy Node require, `splitArgs`, spawned/adopted/external ownership, PID sidecar, stop semantics, `onunload` fire-and-forget, status-bar poll).
|
||
|
||
### Mobile
|
||
|
||
- **Mobile keyboard avoidance is CSS-only: pin our popups to the top.** Obsidian mobile (Capacitor) overlays the soft keyboard on top of the WebView without resizing the layout or visual viewport, so there is no reliable JS signal (`visualViewport` does not shrink, the `resize` event does not fire) to react to. The earlier JS helper (`installMobileKeyboardScrollFix`, which read `visualViewport` and shrank `.modal-container`) was a confirmed no-op on the failing cases and has been removed. The fix lives entirely in [styles.css](styles.css): under `.is-mobile`, our modal classes (`.rewrite-modal`, which covers the main + passphrase modals, and `.rewrite-rename-modal`) get `align-self: flex-start; margin-top: 8px; margin-bottom: auto; max-height: calc(100% - 16px)`, pinning the popup to the top of the flex container that centers modals. The keyboard opens from the bottom, so a top-anchored popup and its near-top input fields stay visible above it. Scoped to our classes so core Obsidian modals are untouched. The settings tab (a tall scrollable surface) was never affected and needs no rule: Chromium's native keyboard-aware focus-scroll handles it. If you add a new popup with a text field, give it one of these classes (or add its class to the selector) rather than reaching for a JS keyboard helper. Top-anchoring alone is not enough when something pushes the focused element low within a tall popup, so three companion tweaks keep the relevant element high: (a) `.is-mobile .rewrite-modal .modal-content` gets `padding-top: 8px` and `.is-mobile .rewrite-modal h2` gets `margin-top: 0` to reclaim the empty band Obsidian leaves above the title; (b) the Paste textarea renders at `rows = 4` on mobile (vs 10 on desktop, set in [src/ui/modal.ts](src/ui/modal.ts)) with the desktop 160px `min-height` floor dropped to 80px under `.is-mobile`, so its submit button stays above the keyboard; (c) the change-passphrase tips block is a `<details>` ([src/ui/passphrase-modal.ts](src/ui/passphrase-modal.ts) `renderPassphraseTips`) expanded by default on every platform (opt-out security guidance), but on mobile it auto-collapses (`collapseTipsOnMobile`) when a passphrase field receives focus, so it is seen on open yet stops pushing the fields into the keyboard once the user starts typing. The first field's `autofocus` is disabled on mobile (`autofocus = !Platform.isMobile`) so the auto-collapse fires on the user's tap rather than a premature programmatic focus.
|
||
|
||
## Local install for testing
|
||
|
||
Build, then place/symlink `main.js`, `manifest.json`, and `styles.css` into `<Vault>/.obsidian/plugins/rewrite-voice-notes/` and reload Obsidian (Settings, Community plugins). `npm run release:prep` automates this copy into a scratch vault (see Dev tooling).
|
||
|
||
## Dev tooling
|
||
|
||
Two `.mjs`-at-root scripts + one project skill close the gap that Obsidian plugins have no headless UI test harness (event-wiring / UI-lifecycle bugs pass build/lint/test). Full detail in [docs/DEV_TOOLING.md](docs/DEV_TOOLING.md).
|
||
|
||
- **`npm run review`** / **`npm run review:docs`** ([local-review.mjs](local-review.mjs)): advisory, **always-exits-0** local review pass over the current diff (merge-base vs working tree by default; `--staged` / `--full` / `--base <ref>`), run against a locally-hosted llama.cpp model. Two deliberately-separate modes: `--code` (default; scope `src/`+`styles.css`+`test/`+`*.mjs`+`manifest.json`, bug-hunting prompt) and `--docs` (scope `*.md`, doc-consistency prompt) — split so each diff fits the model's context and each prompt stays specific. Untracked-and-not-ignored files in scope are folded in (plain `git diff` omits them, but a new source file must not be missed). Spawns/adopts the server (loopback-enforced, duplicating `whisper-host`'s `getHostArgs`/`isLoopbackHost`/`splitArgs` since that file can't load in plain Node), POSTs to `/v1/chat/completions`, prints findings and writes `docs/claude-scratch/local-review-<mode>-report.md`. Never chained into `npm run build`.
|
||
- **`npm run release:prep`** ([prepare-release-vault.mjs](prepare-release-vault.mjs)): **fails loudly** (exit 1); builds and copies the three release artifacts into `<releaseVault.vaultPath>/.obsidian/plugins/rewrite-voice-notes/`.
|
||
- **`release-checklist` skill** ([.claude/skills/release-checklist/](.claude/skills/release-checklist/)): runs `release:prep`, then walks `CHECKLIST.md` (the live feature-by-feature matrix that replaced the stale spec checklist) for a go/no-go. Keep `CHECKLIST.md` in sync with the feature set like the other in-sync docs.
|
||
|
||
Both scripts share one gitignored config, `dev-tools.config.json` (no baked defaults, mirroring `LocalWhisperSettings`); [dev-tools.config.example.json](dev-tools.config.example.json) is the committed template. The three scripts (`version-bump.mjs`, `local-review.mjs`, `prepare-release-vault.mjs`) all keep pure logic exported for `test/*.test.ts` and guard side effects behind `import.meta.url === process.argv[1]`.
|
||
|
||
## Never use em dashes in your own writing.
|
||
|
||
Do not use the em dash character in any prose, lists, code comments, or analysis you produce. Use commas, periods, parentheses, semicolons, or colons instead, whichever fits the sentence best. Exception: when directly quoting a source inside quotation marks, preserve em dashes exactly as they appear. Do not silently edit quoted text.
|
||
|
||
Why: Consistent formatting preference for original writing, while keeping quoted material faithful to the source.
|