diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6f0307b..291c971 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(npm run *)", "WebSearch", "WebFetch(domain:docs.obsidian.md)", - "WebFetch(domain:raw.githubusercontent.com)" + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(npm test *)" ] } } diff --git a/.claude/skills/release-checklist/CHECKLIST.md b/.claude/skills/release-checklist/CHECKLIST.md new file mode 100644 index 0000000..d33180b --- /dev/null +++ b/.claude/skills/release-checklist/CHECKLIST.md @@ -0,0 +1,114 @@ +# ReWrite (Voice Notes) — release feature checklist + +Manual pass to run in a **scratch** Obsidian vault against a fresh build (see `npm run release:prep`). This is the source of truth for pre-release verification; the old checklist in `obsidian-voice-notes-spec.md` is retired. + +Runnable standalone (no Claude Code needed). For each item, do the action and record the outcome on its `Result:` line as PASS / FAIL / SKIP plus any notes. Scope the pass to what changed, but always run **Core** and the clean-load check. + +Keep this file current: when a feature is added or changed, add or update its item here in the same change, the same way CLAUDE.md and the wiki are kept in sync. + +--- + +## 0. Load + +- [ ] Plugin loads with no errors in the Obsidian developer console (Ctrl/Cmd+Shift+I). Result: ____ +- [ ] Ribbon mic icon is present; clicking it opens the main modal. Result: ____ +- [ ] Settings tab opens and renders all sections without throwing. Result: ____ + +## 1. Core commands & entry points + +- [ ] `Open` command opens the modal with the last-used template selected. Result: ____ +- [ ] `Quick record (last used)` starts recording immediately with the floating mini-UI (no modal); pressing it again stops. Result: ____ +- [ ] `Quick record (set template)` records with the template chosen in Settings; with no template set it shows a Notice and does not start. Result: ____ +- [ ] `Process text with template` runs a template over the active editor's selection (or whole note if no selection); no editor active shows a Notice. Result: ____ +- [ ] `Reprocess audio file with template` opens the audio-file picker, then the template picker, then runs. Result: ____ +- [ ] Editor menu shows "ReWrite with template..."; and "Reprocess audio with template..." when the cursor sits in an `![[audio]]` embed. Result: ____ +- [ ] File-explorer right-click on an audio file shows "Reprocess audio with template...". Result: ____ + +## 2. Main modal + +- [ ] **Record** tab: mic permission prompts; timer and level indicator update; Stop closes the modal and runs the pipeline detached with a sticky Notice (Saving audio / Transcribing / Cleaning up / Inserting). Result: ____ +- [ ] Record tab: while recording, tab bar / template select / destination controls are disabled; only Record/Stop is interactive (the isLocked regression guard). Result: ____ +- [ ] "No audio detected" warning appears after ~3 s of silence (mute the mic to test). Result: ____ +- [ ] **Paste** tab: submitted text reaches the LLM; output inserted per the template; inline progress + Retry on error; the submit button disables during a run. Result: ____ +- [ ] **From note** tab: uses selection or whole note body; same in-modal progress. Result: ____ +- [ ] **Destination** override: changing insert mode / folder / filename affects this run only; "Reset to template default" clears it; the template file on disk is unchanged. Result: ____ +- [ ] **Context hint**: the `
` appears only for a template with `enableContextHint`; the typed hint reaches the LLM (visible in the result); resets on template change. Result: ____ +- [ ] Setup card blocks Record/Paste when the active profile is unconfigured, with the right voice-vs-text messaging. Result: ____ + +## 3. Templates + +- [ ] **Populate** seeds the 10 defaults (General cleanup, Todo list, Daily note, Meeting notes, Meeting transcript, Idea capture, Lecture, Podcast, Guides, Book log) plus SharedCore.md; re-running skips existing ids (non-destructive). Result: ____ +- [ ] **Update** reconciles edited defaults; writes `Template update report.md` beside the folder for anything it can't auto-merge; an already-current folder is a no-op. Result: ____ +- [ ] **Load prior versions** drops earlier shipped prompt versions in as selectable templates (or reports "none yet" when history is empty). Result: ____ +- [ ] Editing a template `.md` in the vault refreshes the picker (create/modify/delete/rename all tracked, debounced). Result: ____ +- [ ] **Note properties**: a template with `noteProperties` writes the declared keys into the new note's frontmatter (newFile only). Result: ____ +- [ ] **Note title** (`titleFromContent`): the LLM-generated title names the new file via `{{title}}` / whole-name; falls back to the static name when unusable. Result: ____ +- [ ] `disableSharedCore: true` runs a template without the shared-core preface (and settings shows the warning line naming it). Result: ____ +- [ ] `diarize: true` on a template forces speaker labels on a capable provider. Result: ____ + +## 4. Transcription providers + +- [ ] Each configured provider produces a transcript on the happy path (OpenAI/Whisper, Groq, AssemblyAI, Deepgram, Rev.ai, Mistral Voxtral, openai-compatible, whisper-local). Result: ____ +- [ ] `listModels` populates a dropdown for the providers that support it (OpenAI/Groq/Mistral/Voxtral/Deepgram); Refresh works; Custom... toggles a text field. Result: ____ +- [ ] Providers without listing (openai-compatible/AssemblyAI/Rev.ai) show a plain text field (with a docs link for AssemblyAI/Rev.ai). Result: ____ +- [ ] Over-limit recording surfaces a friendly per-provider byte/duration error (validated after persist, so the saved file survives). Result: ____ +- [ ] AssemblyAI / Rev.ai polling completes for a longer clip (duration-aware timeout, not a flat 60 s) and tolerates a transient blip. Result: ____ + +## 5. LLM providers + +- [ ] Each configured LLM provider cleans up text (OpenAI, Anthropic, Gemini, Mistral, openai-compatible). Result: ____ +- [ ] `listModels` populates the dropdown where supported (OpenAI/Groq/Mistral/Anthropic/Gemini). Result: ____ +- [ ] "Maximum note length" over a model's output cap surfaces the friendly "reduce Maximum note length" error (Anthropic/OpenAI 400; Gemini MAX_TOKENS). Result: ____ +- [ ] An OpenAI reasoning model (o-series / gpt-5) works (uses `max_completion_tokens`). Result: ____ + +## 6. Local whisper.cpp host (desktop) + +- [ ] Start (settings button or command) spawns the server; status bar dot goes green; transcription via `whisper-local` works. Result: ____ +- [ ] Stop stops it; status returns to stopped. Result: ____ +- [ ] A non-loopback `--host` in Extra args is refused before spawn. Result: ____ +- [ ] Restarting Obsidian adopts the orphaned server (status shows "adopted from previous session"); an externally-started server shows "external" and Stop is disabled. Result: ____ +- [ ] Status-bar item is hidden on mobile / when the active profile isn't whisper-local. Result: ____ + +## 7. Known nouns / assistant prompt (wake name) + +- [ ] Known nouns file entries are preserved verbatim in output; frontmatter is not sent to the LLM. Result: ____ +- [ ] Saying ", " mid-recording extracts an ad-hoc instruction (Notice confirms count) and the directive is honored. Result: ____ +- [ ] Assistant prompt file edits take effect; a missing/empty file falls back to the default preface. Result: ____ + +## 8. Secrets & encryption + +- [ ] Fresh install defaults to Obsidian secret storage when available; keys save and hydrate across reload. Result: ____ +- [ ] Passphrase mode: create with the entropy gate + strength meter + Generate button; lock/unlock works; a pipeline run prompts unlock when locked. Result: ____ +- [ ] Copy (inactive -> active, source kept) and Clear (wipe one method) show confirm modals + a count Notice. Result: ____ +- [ ] Corrupting `secrets.json.nosync` (bad JSON) preserves it as `.corrupt` and warns before any overwrite. Result: ____ + +## 9. Settings tab + +- [ ] Active-on-this-device profile shows the accent border + badge; the inactive profile's body is collapsed and its expand state survives dropdown redraws. Result: ____ +- [ ] Provider / insertMode / activeProfileOverride dropdowns show and hide the right conditional fields; text fields keep focus while typing. Result: ____ +- [ ] Shared core section shows the Enabled/Disabled badge matching whether SharedCore.md is present/non-empty. Result: ____ +- [ ] Async buttons (Populate/Update, whisper Start/Stop, Lock) can't be double-fired. Result: ____ + +## 10. Insert pipeline + +- [ ] `cursor` inserts at the cursor; falls back to `append` with no editor. Result: ____ +- [ ] `append` appends to the active markdown file; falls back to `newFile` with none open. Result: ____ +- [ ] `newFile` creates the file; `{{date}}` / `{{time}}` / `{{title}}` expand; collisions resolve per `newFileCollisionMode` (auto iterate vs prompt). Result: ____ +- [ ] For an audio source, the `![[]]` embed is prepended and frontmatter still lands at byte 0. Result: ____ + +## 11. Quick Record UI + +- [ ] Floater shows timer, template button (popover), and Stop; the stop-hotkey hint shows the current binding (or nothing when unbound). Result: ____ +- [ ] Template popover: opens, is keyboard-navigable (Arrow/Escape), dismisses on selection / outside click / when processing starts. Result: ____ +- [ ] Cancel works both during capture and during post-capture processing (aborts the pipeline). Result: ____ + +## 12. Mobile + +- [ ] On mobile, whisper-local and other desktop-only options are absent from dropdowns. Result: ____ +- [ ] Modals pin to the top so fields stay above the soft keyboard; Paste textarea is shorter (rows=4). Result: ____ +- [ ] Record + Paste paths work on mobile. Result: ____ + +## 13. Misc + +- [ ] Errors surface as provider-attributed Notices; no secret leaks into a Notice or log (query strings redacted). Result: ____ +- [ ] Cancelling a run (modal / Quick Record) leaves the persisted audio file as the recovery path and writes nothing further. Result: ____ diff --git a/.claude/skills/release-checklist/SKILL.md b/.claude/skills/release-checklist/SKILL.md new file mode 100644 index 0000000..3808c50 --- /dev/null +++ b/.claude/skills/release-checklist/SKILL.md @@ -0,0 +1,39 @@ +--- +name: release-checklist +description: Run the ReWrite (Voice Notes) plugin's pre-release verification. Use before tagging a release, or when the user asks to smoke-test / verify the plugin in a real vault. Automates the build-and-install-into-a-scratch-vault step, then walks the feature-by-feature CHECKLIST.md so the human effort goes into clicking through features rather than copying files. +--- + +# Release checklist + +This plugin has no headless UI test harness, so event-wiring and UI-lifecycle bugs are invisible to `npm run build` / `npm run lint` / `npm test`. This skill closes that gap: it installs a fresh build into a scratch Obsidian vault, then drives a human through a feature-by-feature manual pass. + +It is the successor to the stale "Testing Checklist" in `obsidian-voice-notes-spec.md` and the token "manual smoke test" step in `docs/RELEASING.md`. See `docs/DEV_TOOLING.md` for how the automated portion works. + +## Phase 1 — Setup check + +1. Confirm `dev-tools.config.json` exists at the repo root with a non-empty `releaseVault.vaultPath`. If not, tell the user to copy `dev-tools.config.example.json` to `dev-tools.config.json` and fill in `releaseVault.vaultPath` (a **scratch** vault, never a real personal one). Do not proceed without it. +2. Confirm the working tree is in the state the user intends to ship (everything they want tested is saved/committed). + +## Phase 2 — Build and install (automated) + +Run the release-prep script via Bash: + +```bash +npm run release:prep +``` + +This runs `npm run build`, then copies `main.js` / `manifest.json` / `styles.css` into `/.obsidian/plugins/rewrite-voice-notes/`. It fails loudly (non-zero exit) on a build error or a bad vault path; surface any failure to the user and stop. + +Then tell the user to open that scratch vault in Obsidian and reload the plugin (Settings -> Community plugins -> toggle it off and on, or reload Obsidian), and confirm it loads with no errors in the developer console. **A clean load is itself the first checklist item** — it is the exact step that would have caught the regression that motivated this tooling. + +## Phase 3 — Feature pass (human-driven) + +Open `CHECKLIST.md` (in this skill folder) and walk it area by area. For each item, ask the user to perform the action in the scratch vault and report the result; record PASS / FAIL / SKIP and any notes on the `Result: ____` line. + +- Scope the pass to what changed when the user only touched part of the plugin, but always include the "Core" area and a clean-load check. +- On any FAIL, capture the exact symptom (and console output if any) — that is the finding, not "it broke." +- `CHECKLIST.md` is also runnable standalone by a human with no Claude Code; keep it self-contained prose. + +## Phase 4 — Summary + +Save the filled-out run to `docs/claude-scratch/release-checklist-.md` (read `` from `manifest.json`; that folder is gitignored). Then give a clear **go / no-go**: list every FAIL with its symptom, note any SKIP and why, and state plainly whether the build is releasable. Do not soften a FAIL into a pass. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7748ceb..96c0377 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,4 +25,5 @@ jobs: - run: npm ci - run: npm run build --if-present - run: npm run lint + - run: npm test diff --git a/.github/workflows/wiki-sync.yml b/.github/workflows/wiki-sync.yml index 9aee08b..abed555 100644 --- a/.github/workflows/wiki-sync.yml +++ b/.github/workflows/wiki-sync.yml @@ -32,12 +32,17 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - wiki_url="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.wiki.git" + # Auth via a transient http.extraheader (passed per-invocation with -c) rather + # than embedding the token in the remote URL: a token-in-URL persists into + # wiki-repo/.git/config on disk and can be echoed by git's own error/verbose + # output, so it's the wrong place for a secret even for a short-lived runner. + auth_header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 -w0)" + wiki_url="https://github.com/${GITHUB_REPOSITORY}.wiki.git" git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - git clone --depth 1 "$wiki_url" wiki-repo + git -c http.extraheader="${auth_header}" clone --depth 1 "$wiki_url" wiki-repo # Replace the wiki contents with the repo's wiki/ folder, # preserving the wiki repo's own .git directory. @@ -51,4 +56,4 @@ jobs: exit 0 fi git commit -m "Sync wiki from ${GITHUB_SHA}" - git push origin HEAD + git -c http.extraheader="${auth_header}" push origin HEAD diff --git a/.gitignore b/.gitignore index 808fb6b..ee53c69 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ secrets.json* # Claude Code: per-user harness config (kept out of git) .claude/settings.local.json +# Dev tooling: per-machine paths for `npm run review` / `npm run release:prep`. +# Copy dev-tools.config.example.json to this file and fill it in; never commit it. +dev-tools.config.json + # Claude Code: ephemeral working docs (the folder is kept via .gitkeep) docs/claude-scratch/* !docs/claude-scratch/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md index 53720bd..0ffa6e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ When extending the plugin, follow the file layout the spec prescribes: provider 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)) 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. +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. @@ -50,14 +50,27 @@ 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 # 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 ``` -There is no test runner configured. Verification is `npm run build && npm run lint` (CI parity) plus the manual checklist in [obsidian-voice-notes-spec.md](obsidian-voice-notes-spec.md) (lines 454-476). +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) (version bump + bare-version tag + CI-built, attested assets; do not hand-upload). -CI ([.github/workflows/lint.yml](.github/workflows/lint.yml)) runs `npm ci`, `npm run build`, and `npm run lint` on Node 20.x and 22.x for every push/PR. +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`), `pipeline.extractFromBlock`, `secrets` (passphrase round trip, corrupt-envelope recovery), `settings mergeSettings`, `templates-folder` (parse/render round trip, `mergeTemplate`), `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 @@ -101,6 +114,7 @@ src/ │ ├── quick-record.ts # QuickRecordController + floating mini-UI for the Quick Record command │ ├── template-picker.ts # Lightweight modal for picking a template (used by Process text command and editor menu) │ ├── 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) ├── transcription/ │ ├── index.ts # TranscriptionProvider interface + createTranscriptionProvider() @@ -129,7 +143,9 @@ src/ 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)), reporting progress through a sticky `Notice` (`setMessage` per stage) and surfacing errors as a `Notice` (no inline Retry) — mirroring `runAudioFilePipeline` in [src/ui/audio-source.ts](src/ui/audio-source.ts). This is safe because the `persist-audio` stage saved the recording before transcription, so the vault file is the recovery path. 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). Destination override and context hint are captured into locals before `startRecordingPipeline` closes the modal, so a recorded run still honors them. +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. + +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 `![[]]\n\n` prepend. @@ -149,7 +165,7 @@ Full detail (per-adapter formatting, pipeline merge rules, label survival) lives ## 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 }`: 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. 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:/inference`; no API key. +`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 }`: 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:/inference`; no API key. Full detail (start/stop/probe lifecycle, the spawned/adopted/external ownership model, PID sidecar, Auto-detect, the build script, and all whisper-host gotchas) lives in [docs/WHISPER_HOST.md](docs/WHISPER_HOST.md). @@ -158,7 +174,7 @@ Full detail (start/stop/probe lifecycle, the spawned/adopted/external ownership `GlobalSettings` (defined in [src/types.ts](src/types.ts)) is the shape of `data.json`. Loading flow: 1. `plugin.loadData()` returns `Partial | null`. -2. `mergeSettings(DEFAULT_SETTINGS, stored)` deep-merges, preferring stored values. +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. Both guard 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`. 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`. @@ -174,6 +190,8 @@ The two stores **coexist**: the on-disk envelope retains passphrase `kdf`/`verif `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.4.4` (driven by `processFrontMatter`, not `secretStorage`, which is feature-detected). +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 @@ -187,6 +205,8 @@ Templates are Markdown files in a vault folder, not entries in `data.json`. The - 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`. @@ -235,7 +255,7 @@ Cache: `plugin.knownNouns: KnownNoun[]` (default `[]`), refreshed on the same tr 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)` then: seeds `properties` with every declared key = `''` (the scaffold), matches a leading ```` ```yaml ```` fence, `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`. When no block is present the whole output is the body. When a 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, `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. +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`. @@ -299,11 +319,12 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **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 silently clamps `maxOutputTokens` instead of erroring, so it gets no remap (and can therefore truncate without warning on long notes). +- **`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. @@ -316,6 +337,8 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **`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 `
` whose `` reads `"Destination: Default ()"` (no override) or `"Destination: Custom ()"` (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 @@ -325,6 +348,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma ### 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 `