code review + and new pre-release process

This commit is contained in:
WiseGuru 2026-07-07 15:06:43 -07:00
parent 4cc87a0b47
commit ccc54343a6
57 changed files with 3604 additions and 310 deletions

View file

@ -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 *)"
]
}
}

View file

@ -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 `<details>` 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 "<assistant name>, <directive>" 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 `![[<path>]]` 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: ____

View file

@ -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 `<vaultPath>/.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-<version>.md` (read `<version>` 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.

View file

@ -25,4 +25,5 @@ jobs:
- run: npm ci
- run: npm run build --if-present
- run: npm run lint
- run: npm test

View file

@ -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

4
.gitignore vendored
View file

@ -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

View file

@ -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 <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
```
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 `![[<path>]]\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:<port>/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:<port>/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<GlobalSettings> | 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 `<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
@ -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 `<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.
- **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.
@ -334,6 +358,9 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
- **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 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
@ -344,6 +371,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
- **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
@ -359,7 +387,17 @@ See [docs/WHISPER_HOST.md](docs/WHISPER_HOST.md) for the whisper-host gotchas (l
## 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).
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`** ([local-review.mjs](local-review.mjs)): advisory, **always-exits-0** local code-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 (Ornith 1.0). Spawns/adopts the server (loopback-enforced, duplicating `whisper-host`'s `getHostArgs`/`isLoopbackHost`/`splitArgs` since that file can't load in plain Node), POSTs the diff to `/v1/chat/completions`, prints findings and writes `docs/claude-scratch/local-review-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.

View file

@ -0,0 +1,24 @@
{
"//": "Copy this file to dev-tools.config.json (gitignored) and fill in the paths. Shared by npm run review (local-review.mjs) and npm run release:prep (prepare-release-vault.mjs). No baked defaults, mirroring the plugin's LocalWhisperSettings philosophy: required paths are validated present + existsSync before use.",
"localReview": {
"//binaryPath": "Required. Path to llama-server. On this machine: C:\\Users\\whyno\\AppData\\Local\\Microsoft\\WinGet\\Packages\\ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe\\llama-server.exe",
"binaryPath": "",
"//modelPath": "Required. Path to the Ornith 1.0 gguf (a 35B-parameter Q4_K_M quant, ~19.7 GB). On this machine: Y:\\llm-models\\llamacpp\\gguf\\ornith-1.0-35b-Q4_K_M.gguf",
"modelPath": "",
"port": 8090,
"//extraArgs": "Optional extra llama-server flags, whitespace-split. A non-loopback --host is rejected before spawn (no auth/TLS).",
"extraArgs": "",
"//baseRef": "Branch/ref the diff is computed against (git merge-base <baseRef> HEAD, then diff to the working tree). Overridable per-run with --base <ref>.",
"baseRef": "master",
"//readyTimeoutMs": "How long to wait for the model to load before giving up. The Ornith model is large; raise this if you see spurious ready-timeout errors on a slow disk or with heavy GPU offload.",
"readyTimeoutMs": 60000,
"requestTimeoutMs": 300000,
"maxDiffChars": 60000
},
"releaseVault": {
"//vaultPath": "Required for npm run release:prep. A SCRATCH Obsidian vault for release testing, never a real personal one. The build's main.js/manifest.json/styles.css are copied into <vaultPath>/.obsidian/plugins/rewrite-voice-notes/.",
"vaultPath": ""
}
}

60
docs/DEV_TOOLING.md Normal file
View file

@ -0,0 +1,60 @@
# Dev tooling: local review + release prep
> Extracted from CLAUDE.md. Subject to the same maintenance rule: when you change the behavior of either script or the shared config, update this file in the same change, and keep the summary in [CLAUDE.md](../CLAUDE.md) accurate.
Two `.mjs`-at-root scripts (same convention as [version-bump.mjs](../version-bump.mjs) / [esbuild.config.mjs](../esbuild.config.mjs): plain ESM, no TypeScript, no new runtime dependency, side effects guarded behind `import.meta.url === process.argv[1]` so the pure logic is unit-testable) plus a project-level Claude Code skill. They exist because Obsidian plugins have **no headless UI test harness**, so UI event-wiring bugs (the `isLocked()` self-lockout, a `--host` bypass, a greedy YAML-fence regex) sail past `npm run build`, `npm run lint`, and the Vitest suite. These tools add a cheap local review pass and a kept-current manual release checklist to catch that class of bug.
## Shared config: `dev-tools.config.json`
Both scripts read one gitignored file at the repo root, `dev-tools.config.json` (one config file, one `.gitignore` entry, one doc section). No baked defaults, mirroring `LocalWhisperSettings` in [src/whisper-host.ts](../src/whisper-host.ts): required paths are validated present + `existsSync` before use, with a clear thrown message otherwise. [dev-tools.config.example.json](../dev-tools.config.example.json) is the committed fill-in-and-rename template; its `//`-prefixed keys carry the confirmed paths for this machine as comments while the real JSON values stay empty strings (it is a personal-machine config, not something to bake into the repo).
```jsonc
{
"localReview": {
"binaryPath": "", // required: path to llama-server(.exe)
"modelPath": "", // required: path to the Ornith 1.0 gguf
"port": 8090,
"extraArgs": "", // whitespace-split; a non-loopback --host is rejected
"baseRef": "master", // diff is computed against this ref
"readyTimeoutMs": 60000,
"requestTimeoutMs": 300000,
"maxDiffChars": 60000
},
"releaseVault": {
"vaultPath": "" // required: a SCRATCH Obsidian vault, never a personal one
}
}
```
## `npm run review` — advisory local code review ([local-review.mjs](../local-review.mjs))
A one-shot, **always-exits-0**, advisory pass over the current diff, run against a locally-hosted llama.cpp model (Ornith 1.0, a 35B Q4_K_M quant, ~19.7 GB) before loading a build into Obsidian to test it by hand. It never blocks the build; it is a first-pass filter that costs no Anthropic tokens per local build-and-test cycle. It is invoked manually, not auto-chained into `npm run build`.
**Diff scope.** `git merge-base <baseRef> HEAD` then `git diff <mergeBase>` against the working tree, i.e. everything staged + unstaged + committed-since-branch ("everything you're about to go test"). `--staged` narrows to the index; `--full` to the last commit only; `--base <ref>` overrides the configured `baseRef`. An empty diff prints "nothing to review" and skips the model call entirely. All git calls go through `execFileSync('git', [...])` (never `execSync` — no shell interpolation); this is the first code in the repo shelling out to git.
**llama-server lifecycle** mirrors `WhisperHost`'s probe -> spawn-if-needed -> poll-ready -> use -> stop-only-if-we-started-it, simplified to a run-and-exit script (no PID sidecar / cross-session adoption — "adopt" here just means the port is already reachable, so use it and leave it running). If not reachable, it spawns `llama-server -m <model> --port <port> --host 127.0.0.1 [extraArgs]`, captures stdout/stderr into a bounded ring buffer, and polls `GET /health` every 250 ms up to `readyTimeoutMs` (default 60 s; the GGUF load is far slower than whisper's 5 s deadline, and llama-server can bind the port before the model finishes loading, so a single failed health check is not fatal). It then POSTs the diff to llama-server's native OpenAI-compatible `/v1/chat/completions` with an `AbortController`-driven `requestTimeoutMs`. In the `finally`, a spawned process is SIGTERM'd then SIGKILL'd after a grace period; an adopted one is left running.
**Prompt.** The system prompt asks for correctness bugs + simplification/reuse/efficiency (the `/code-review` sensibilities), plus an explicit repo-specific callout to flag DOM event wiring / enable-disable state / button lifecycle (the exact class of bug that motivated the tool) and a few house rules from [RELEASING.md](RELEASING.md)'s guideline-conflict checklist (no `!important`, no `eslint-disable`, popout-window safety). The user message is the diff, truncated to `maxDiffChars` with a visible marker.
**Output.** Findings print to stdout and are always also written to `docs/claude-scratch/local-review-report.md` (fixed name, overwritten each run; the folder is already gitignored). The report header carries the base ref, merge-base SHA, scope, timestamp, and a diff-stat. Setup errors (missing config, git failure, server never ready, a non-loopback `--host`) print a clearly marked `ERROR:` block, never a stack trace, and still exit 0.
The loopback guard (`getHostArgs` + `isLoopbackHost` + `splitArgs`) is **duplicated** from [src/whisper-host.ts](../src/whisper-host.ts) on purpose: that file's top-level `import ... from 'obsidian'` only resolves inside the Obsidian bundle (or behind the Vitest alias), so it can't be imported from plain Node. The copies carry the same test coverage the originals have in [test/whisper-host.test.ts](../test/whisper-host.test.ts), in [test/local-review.test.ts](../test/local-review.test.ts).
## `npm run release:prep` — build + install into a scratch vault ([prepare-release-vault.mjs](../prepare-release-vault.mjs))
Unlike the review script, this **fails loudly** (non-zero exit) on error — there is no advisory framing for release prep. It reads `releaseVault.vaultPath` (validated to exist up front, before any build or directory creation, so a bad path fails fast with no stray directory), warns (but does not hard-fail) when the vault has no `.obsidian` subfolder (an unopened vault), runs `npm run build` via `execFileSync`, reads the plugin id from `manifest.json` (derived, not duplicated), copies `main.js` / `manifest.json` / `styles.css` into `<vaultPath>/.obsidian/plugins/rewrite-voice-notes/`, and prints a reminder to reload/toggle the plugin in Obsidian (the one step it cannot automate).
## `release-checklist` skill ([.claude/skills/release-checklist/](../.claude/skills/release-checklist/))
The first project-level Claude Code skill in this repo. Two files, mirroring the repo's summary + linked-deep-dive convention:
- `SKILL.md` — process-oriented: setup check, run `prepare-release-vault.mjs`, walk `CHECKLIST.md` area by area recording PASS/FAIL/SKIP, then summarize a go/no-go. Filled-out runs save to `docs/claude-scratch/release-checklist-<version>.md`.
- `CHECKLIST.md` — the full feature-by-feature matrix, kept separate so future feature additions are a content-only edit and a human can run it standalone (no Claude Code needed). It replaces the stale "Testing Checklist" in [obsidian-voice-notes-spec.md](../obsidian-voice-notes-spec.md) (which referenced 5 templates instead of 10, a removed `webspeech` provider, a flat 60 s poll timeout, and a removed clipboard fallback). **Keep it in sync with the feature set** the same way CLAUDE.md and the wiki are: a feature change updates the checklist in the same change.
## Gotchas
- **The review script always exits 0.** It is advisory. A missing config, a git error, or a server that never became ready prints an `ERROR:` block and exits 0, never a non-zero code, so it can never block the local loop. `prepare-release-vault.mjs` is the opposite: it exits 1 on any failure.
- **The loopback guard is duplicated, not imported.** `getHostArgs` / `isLoopbackHost` / `splitArgs` live in both `src/whisper-host.ts` and `local-review.mjs`. If you change the guard's behavior, change both and update both test files. This duplication is deliberate (the src copy can't load in plain Node).
- **No cross-session process adoption.** Unlike `WhisperHost`, the review script has no PID sidecar. "Adopt" means only "the port is reachable, reuse it and leave it running." A TCP-reachable port is assumed to be llama-server; a dev tool doesn't need the full ownership model.
- **`npm` is `npm.cmd` on Windows.** `prepare-release-vault.mjs` picks the binary by `process.platform`, since `execFileSync('npm', ...)` would fail to resolve on Windows.
- **The Ornith model is large.** `readyTimeoutMs`'s 60 s default may be tight depending on disk speed / GPU offload. Raise it (rather than guessing a bigger number blind) if you see spurious ready-timeout errors.

View file

@ -40,7 +40,7 @@ The tag name must equal `manifest.json`'s `version` exactly. `.npmrc` already pi
1. `npm run build` passes (this is `tsc -noEmit` then esbuild production; a type error here is a release blocker).
2. `npm run lint` passes with zero warnings. The local `eslint-plugin-obsidianmd` is looser than the official review bot, so also eyeball the conflict checklist below.
3. Manual smoke test in a real vault for anything you touched. At minimum for a code change: record + Quick Record, run a template insert (cursor / new file / append), and on desktop start the local whisper.cpp server. Install by copying `main.js` / `manifest.json` / `styles.css` into `<Vault>/.obsidian/plugins/rewrite-voice-notes/` (folder name must match the plugin `id`) and reloading.
3. Manual feature pass via the **`release-checklist` skill** (`.claude/skills/release-checklist/`). `npm run release:prep` builds and installs `main.js` / `manifest.json` / `styles.css` into your scratch vault's `.obsidian/plugins/rewrite-voice-notes/` (configure `releaseVault.vaultPath` in `dev-tools.config.json` first), then the skill walks `CHECKLIST.md` feature by feature. `CHECKLIST.md` is also runnable standalone by a human without Claude Code. See [DEV_TOOLING.md](DEV_TOOLING.md). (You can also run the advisory `npm run review` local code-review pass before this.)
4. Update docs for any behavioral change ([CLAUDE.md](../CLAUDE.md), the user-facing [`wiki/`](../wiki/) pages, and the [README](../README.md)), per the doc-maintenance rules in CLAUDE.md.
5. Roll [ROADMAP.md](ROADMAP.md): every item shipping in this release should already have an **Unreleased** entry. Move them into a new `### <version> — <YYYY-MM-DD>` heading at the top of the **Released** archive, and leave `## Unreleased` empty for the next cycle. The version + date must match the tag.

View file

@ -37,13 +37,60 @@ Voxtral exposes a real-time STT model that doesn't accept whole-file uploads, an
- Provider-side gating: only enable for transcription providers that expose a realtime endpoint; document which.
- Honest assessment: this is lower-value than the rest of the plugin (transcript with no cleanup is what Obsidian's existing voice plugins already do). Ship only if users ask.
### 3. "Record in background" option on the main modal
The main modal's **Record** tab holds the modal open (blocking Obsidian) while capturing, then closes on Stop and runs the pipeline detached (see the 1.1.1 "recording modal now closes on Stop" fix). During the *capture* phase you still can't use the vault. Quick Record already solves this with its floating mini-UI (no modal), but it's a separate command with no template picker / destination / context-hint UI. Add an opt-in that gives the modal's richer setup the same non-blocking capture:
- A checkbox on the Record tab labelled roughly "Record in background", **backed by a remembered `GlobalSettings` flag** (e.g. `recordInBackground`, default off) rather than a per-session toggle: the checkbox reflects and writes the setting via the tab's `commit()` path, so a user who always wants it ticks it once. When on, pressing Record captures the modal's current template + destination override + context hint, then closes the modal and hands off to the Quick Record floating UI (`QuickRecordController`) so recording continues while Obsidian stays usable. When off, behavior is unchanged (modal stays open during capture).
- Reuse the existing floater rather than building a second detached-capture path: the floater already owns the wake-lock, silence warning, stop-hotkey hint, and detached pipeline run. The work is threading the modal's per-invocation params (template, `destinationOverride`, `contextHint`) into `startQuickRecord` / `QuickRecordController`, which today only carry a template.
**Desktop only.** Mobile is out of scope: Obsidian's Capacitor WebView already suspends `MediaRecorder` when the screen sleeps or the app backgrounds (the screen wake lock only partly mitigates this, and only while the app is foregrounded), so a "keep recording while you do other things" affordance on mobile invites exactly the recording-loss failure we already worry about. Gate the checkbox behind `Platform.isDesktop` (hide it entirely on mobile); when hidden, the setting is inert and the modal keeps its current blocking-capture behavior. Revisit only if mobile background-capture reliability is solved separately.
**Single-recording constraint (implementation note, not a UX question).** The intent is unchanged: a user can only ever have one recording going. This bullet is just a reminder that the *code* must preserve that. The plugin enforces single-recording through one `activeQuickRecord: QuickRecordController | null` ref on `ReWritePlugin` (plus a synchronous `quickRecordStarting` guard against a double-start), and `onunload` cleans up via `activeQuickRecord?.cancel()`. The background-modal handoff must reuse that same slot — simplest is to route it through the existing `toggleQuickRecord` entry (or a shared helper the commands and the modal both call) so there is one owner of the ref — rather than spinning up a parallel controller that could coexist with a Quick Record floater and would need its own unload cleanup. No new user-facing behavior; just don't accidentally break the existing guarantee.
### 4. Manage individual default templates: enable/disable + track/untrack, backed by a `managed` frontmatter flag
Today the built-in default set is all-or-nothing against the folder, and identity is inferred purely from frontmatter `id`. Populate ([`populateDefaultTemplates`](../src/templates-folder.ts)) re-adds any default whose `id` isn't already on disk; Update ([`updateDefaultTemplates`](../src/templates-folder.ts)) reconciles every file whose `id` matches a built-in and does a "superset top-up" that recreates any default missing from disk entirely. So a user who deletes a default they never use gets it silently resurrected the next Populate/Update, and there is no way to keep a default-derived file while freezing it against future prompt changes. Give each built-in default two independent, per-template controls.
**Two axes, two controls (a switch + a checkbox per default):**
- **Switch = enabled / disabled (governs existence).** Disabling *removes the on-disk file entirely* and records the default in a `GlobalSettings.disabledDefaultTemplateIds` ignore list so Populate and Update never recreate it — including a future plugin version re-shipping it. (Destructive, so the switch-off action needs a confirm `Modal`; `window.confirm` is banned by lint.) The user's note that "any new templates that follow the name are ignored" is a decision point: key the ignore list by `id` (canonical, survives renames) as the primary match, and consider *also* suppressing by name so a re-introduced default under a new id but the same name stays hidden. Document which.
- **Checkbox = tracked / untracked (governs updates; only meaningful while enabled).** Tracked (the default for plugin-created files) means Update reconciles the file against the current built-in via the existing 3-way `mergeTemplate`. Untracked freezes it: the file stays on disk and fully usable, but Update skips it entirely, so the user's copy is never touched again. This is effectively "adopt this default as my own." When disabled, track is moot.
**New `managed` frontmatter flag on `NoteTemplate` (this is the mechanism, and it fixes the name-collision worry).** Add a `managed?: boolean` field. Populate writes `managed: true` on the default-derived files it creates; the untrack checkbox flips it to `managed: false`. Update's file-matching changes from "id matches a built-in" to "id matches a built-in **and** the file is managed", so:
- A user's own hand-authored template is unmanaged by default (no `managed: true`), so even if it shares a `name` (or coincidentally an `id`) with a built-in, Update never overwrites it. This is the "unmanaged templates with the same name don't get clobbered" protection.
- Untracking a default writes `managed: false`, taking it out of reconciliation without deleting it.
- Parse/render follow the existing flag conventions in [src/templates-folder.ts](../src/templates-folder.ts) (`parseTemplateFile` / `renderTemplateFile`, boolean-or-`"true"` tolerance). **Polarity + backward-compat is a decision:** existing default files created before this feature have no `managed` key. Treat an absent flag on a file whose `id` matches a built-in as tracked (preserves today's behavior; only an explicit `managed: false` opts out), and only ever *write* `managed: true` for files Populate/Update themselves create. A user template (id not in the default set) ignores the flag entirely since Update never touches non-default ids anyway.
**Behavior wiring:**
- `populateDefaultTemplates`: skip any id in the disabled list; stamp `managed: true` on what it creates.
- `updateDefaultTemplates`: skip disabled ids in the top-up loop (the `for (const def of defaultsById.values())` block); in the reconcile loop, skip files that are untracked (`managed: false`) even when their id matches a default.
- **UI:** a checklist of the built-in defaults in the Templates settings section, each row = name + short description + the enable switch + the track checkbox, plus the file's current on-disk state (present / deleted / untracked). Drive the list off `freshDefaultTemplates()` so it stays in sync as defaults are added or removed.
- Orthogonal to Load prior versions (`<id>@<version>` snapshots): those are not current defaults and are already ignored by Populate/Update, so neither the ignore list nor the managed flag needs to special-case them.
- **Docs:** `managed` is a new `NoteTemplate` frontmatter field, so `wiki/Creating-Templates.md` (its frontmatter table + a dedicated section) and CLAUDE.md's Templates section must be updated in the same change, per the keep-the-guide-in-sync rule.
### 5. Auto-ingest folders: a command that batch-processes dropped audio with a chosen template
For users who capture audio outside Obsidian (a dedicated voice-recorder app, a call-recording export, a scripted dump) and drop the files into a vault folder, add semi-automatic ingestion: the user runs a **command** that scans one or more configured folders and, for each audio file found, runs the pipeline with that folder's preassigned template, then moves the source file in with the rest of the voice recordings. This is the reprocess-audio flow ([`runAudioFilePipeline`](../src/ui/audio-source.ts)) minus the manual picker — the same code path that already skips `persist-audio` and links the note back to the source file via the `![[<path>]]` embed.
- **Command-driven, not a live watcher.** A `rewrite-voice-notes:process-ingest-folders` command scans the configured folders on demand. This deliberately sidesteps the "startup storm" (Obsidian fires `create` for every existing file at vault load, so a live `vault.on('create')` watcher would try to reprocess the whole folder the moment a rule is enabled) — there is simply no watcher. It also means the user is in control of when a batch runs.
- **Move-on-success is the dedupe mechanism (and the requested last step).** After a file is processed successfully, its **last step is to move the recording in with the other voice recordings** — into `GlobalSettings.attachmentsFolderPath` (or Obsidian's own attachment location when that is empty), the same place a live recording is persisted. Do the move with `app.fileManager.renameFile`, which rewrites the just-created note's `![[embed]]` to the new path automatically, so the link stays valid. Because a processed file leaves the ingest folder, re-running the command never reprocesses it — no processed-path bookkeeping in `data.json` needed. A file that *fails* stays put in the ingest folder and is retried on the next command run (failures don't move), which is the desired recovery behavior.
- **This also settles the platform question: ship on all platforms, including mobile-only.** Because it's a user-run command that moves files rather than a cross-device watcher, there is no double-processing-across-synced-devices hazard, and a phone-only user (some people only use Obsidian on their phone) is a first-class case, not an afterthought. No `Platform.isDesktop` gate.
- **New-file templates only.** The template dropdown in the rule editor must be filtered to `insertMode === 'newFile'` templates. Unattended ingest has no active editor and no "current note", so `cursor` falls back to `append` and `append` falls back to `newFile` anyway — surfacing only `newFile` templates makes the destination explicit and predictable (the note lands where the template's `newFileFolder` / `newFileNameTemplate` say). Gate selection + validation on this.
- **Settings UI is a popup modal.** Per the request, rule creation/editing lives in a `Modal` (opened from a button in a new "Auto-ingest" settings section), not inline rows, so the folder picker + template dropdown (newFile-only) + enable toggle have room. The settings tab shows the existing rules as a summarized list with Edit/Delete; the modal adds/changes one. Config shape: `GlobalSettings.ingestRules: { folderPath: string; templateId: string; enabled: boolean }[]`.
- **Gating, failure, ordering:** each file gates on the full voice profile (`isProfileConfigured`) and unlocked secrets, exactly like manual reprocess. Serialize the batch (a queue, one pipeline at a time) rather than launch concurrent runs, and report a per-batch summary via `Notice` ("Processed N, failed M"); a failure logs + is left in place, never blocks the vault. Scan filters to `AUDIO_EXTENSIONS` (from [src/audio-persist.ts](../src/audio-persist.ts)).
---
## Unreleased
Merged to `master`, not yet tagged. These become the next version's release notes.
_Nothing yet._
### Dev tooling: local-LLM build review + release-verification skill
Added two `.mjs`-at-root scripts and the repo's first project-level Claude Code skill to close the gap that Obsidian plugins have no headless UI test harness (a self-inflicted `isLocked()` self-lockout regression shipped past build/lint/test and was only caught by hand in Obsidian). [local-review.mjs](../local-review.mjs) (`npm run review`): advisory, always-exits-0 code-review pass over the current diff, run against a locally-hosted llama.cpp model (Ornith 1.0), spawning/adopting the server (loopback-enforced, duplicating `whisper-host`'s `getHostArgs`/`isLoopbackHost`/`splitArgs` since that file can't load in plain Node), POSTing to `/v1/chat/completions`, printing findings + writing `docs/claude-scratch/local-review-report.md`. [prepare-release-vault.mjs](../prepare-release-vault.mjs) (`npm run release:prep`): fails-loudly build + copy of the three release artifacts into a scratch vault's plugin folder. [.claude/skills/release-checklist/](../.claude/skills/release-checklist/) (`SKILL.md` + `CHECKLIST.md`): the live feature-by-feature manual pass that replaced the stale `obsidian-voice-notes-spec.md` "Testing Checklist" (5-vs-10 templates, removed `webspeech`, flat 60 s poll, removed clipboard fallback). Both scripts share one gitignored `dev-tools.config.json` (no baked defaults, mirroring `LocalWhisperSettings`); pure logic is unit-tested in [test/local-review.test.ts](../test/local-review.test.ts) / [test/prepare-release-vault.test.ts](../test/prepare-release-vault.test.ts). New deep-dive [docs/DEV_TOOLING.md](DEV_TOOLING.md); CLAUDE.md and RELEASING.md updated.
---

View file

@ -44,6 +44,9 @@ Switching the active method and transferring keys are **deliberately decoupled**
## Gotchas
- **A `secrets.json.nosync` that fails to parse is treated as corrupt, not merely absent, and its bytes are preserved.** `parseEnvelope` throws a local `EnvelopeCorruptError` when `JSON.parse` fails or the parsed value isn't an object; a recognized-but-outdated shape (unknown `version`, unrecognized `mode`) is still a deliberate "fresh start" reset, not corruption, and does NOT throw. `readEnvelopeFromDisk` catches only `EnvelopeCorruptError`: it copies the raw file to `<path>.corrupt` via the vault adapter, `console.error`s, shows a sticky `Notice` telling the user recovery data was preserved, and falls back to `defaultEnvelope()`. Without this, a half-written file (crash, sync conflict) used to silently present as "no secrets configured," and the very next settings save (`writeEnvelope`) would overwrite the salvageable ciphertext with an empty envelope, permanently. `preserveCorruptSecretsFile` is best-effort: if even the `.corrupt` write fails, it just logs and moves on.
- **`writeEnvelope` writes via temp-file + remove + rename, not a single direct write.** A crash or interrupted write mid-`adapter.write` used to risk leaving a truncated/interleaved `secrets.json.nosync` on disk. It now writes the full JSON to `<path>.tmp`, removes any existing target, then renames the temp file into place, so a failure at any point before the rename leaves the OLD file intact and a failure after leaves the NEW one intact; there is no window where the file is partially written to its final path. Falls back to a direct write (logging the failure) if the adapter doesn't support `rename` (defensive; Obsidian's adapters do).
- **A keyring failure mid-session (as opposed to unavailable from the start) is surfaced once, not silently.** `probeSecretStorage`'s result is cached for the session, so `secretStorage` can keep reporting `available`/`configured` even after the OS keyring stops responding (service restart, permission revoked, etc.). `loadKey`'s `catch` used to just return `''`, which surfaces later as a confusing "missing API key" with no indication why. It now routes through `warnKeyringFailure(e)`: `console.error`s and shows a sticky `Notice` pointing at Settings → Encryption, gated by a module-level `keyringFailureNoticeShown` flag so repeated failed reads don't spam multiple notices in one session. Write-side failures (`saveKey`/`saveManyKeys` calling `writeToSecretStorage`, which throws when the store is unavailable) are not caught in `secrets.ts`; they propagate up to the settings tab's `commit()`, which has its own try/catch + Notice (see CLAUDE.md's Settings tab UI gotchas).
- **`app.secretStorage` is reached via a narrow cast + round-trip self-test, never assumed present.** [src/secrets.ts](../src/secrets.ts) `getSecretStorage(plugin)` casts `plugin.app` to a local `RawSecretStorage` shape and normalizes the method aliases; `probeSecretStorage` write/read/compares a sentinel before trusting it. Importing nothing Electron-specific keeps it mobile-safe. Old Obsidian (no API) and a broken OS store (Linux without a keyring) both read unavailable and fall back to passphrase. The probe result is module-cached and warmed in `onload` via `warmSecretStorage` **before `loadSettings`** (which reads + caches the envelope), so the synchronous `defaultEnvelope()` can prefer it on first run; warming it after `loadSettings` is a regression that re-defaults fresh installs to passphrase. Keys are namespaced with `manifest.id`; never store under a bare id (the store is shared across plugins).
- **Secret ids must be lowercase-alphanumeric + dashes only.** `app.secretStorage.setSecret(id, value)` throws when `id` contains a colon, underscore, or uppercase letter. `nsId` joins the manifest id and the secret id with a DASH for this reason, and the canonical ids in [src/settings/index.ts](../src/settings/index.ts) (`profile-desktop-transcription`, etc.) and the self-test id (`selftest`) are all dash-only. A regression here is silent and total: the round-trip probe's first `setSecret` throws, the `catch` swallows it, and `secretStorage` reports unavailable on EVERY platform (this was the original bug, when ids were colon/underscore-joined). Do not reintroduce a colon or underscore into any id passed through `nsId`.
- **`saveManyKeys` is a silent no-op when locked.** When `mode === 'passphrase'` and `unlockedKey === null`, `saveManyKeys` does nothing. This is deliberate: unrelated `plugin.saveSettings()` calls (e.g. user changes a model dropdown) would otherwise persist empty `apiKey` values for every profile, wiping the on-disk encrypted bag. The UI prevents this by disabling key fields and gating all pipeline entry points on `encryptionStatus.locked`. `saveKey` (single-key write) still throws so callers can react.

View file

@ -6,9 +6,9 @@
The companion CLI installer is [scripts/build-whisper-linux.sh](../scripts/build-whisper-linux.sh): it clones/builds whisper.cpp into `~/.local/share/whisper.cpp` (override `--source-dir`), symlinks the binary to `~/.local/bin/whisper-server` (override `--prefix`, skip with `--no-symlink`), and prints the final Binary path to paste into settings. Keep the Auto-detect candidate list in sync with this script's default paths.
`start()` validates the binary and model paths exist via `fs.existsSync`, probes the port via `net.createServer().listen(port)` to detect conflicts, spawns the user's whisper-server with `child_process.spawn`, captures stdout/stderr into a 1 MB ring buffer, then polls `net.createConnection` against the port every 250 ms for up to 5 s before declaring `'running'`. Any failure transitions status to `'crashed'` with the log tail surfaced in the error message. `stop()` sends SIGTERM, waits up to 3 s, then SIGKILL.
`start()` validates the binary and model paths exist via `fs.existsSync`, resolves the configured port via `resolvePort` (falls back to `8080` when the setting is missing, non-finite, `<= 0`, or `> 65535`; the upper bound used to be unchecked and a too-large port fell through to a confusing "did not become ready" timeout instead of a clear validation), probes it via `net.createServer().listen(port)` to detect conflicts, spawns the user's whisper-server with `child_process.spawn`, captures stdout/stderr into a 1 MB ring buffer, then polls `net.createConnection` against the port every 250 ms for up to 5 s before declaring `'running'`. Any failure transitions status to `'crashed'` with the log tail surfaced in the error message. `stop()` sends SIGTERM, waits up to 3 s, then SIGKILL (see the ownership-specific detail below for the adopted-PID case, which re-verifies the port first).
**Loopback binding is enforced.** whisper-server has no auth/TLS, so `start()` pins `--host 127.0.0.1` when the user did not supply a `--host` in `extraArgs` (so we don't depend on upstream's default binding), and throws before spawning if a user-supplied `--host` is non-loopback (`getHostArg` + `isLoopbackHost` in [src/whisper-host.ts](../src/whisper-host.ts); loopback = `127.0.0.1` / `localhost` / `::1`). This prevents a stray `--host 0.0.0.0` from silently exposing an unauthenticated transcription server to the LAN. The README's "Network exposure" disclosure and the Extra args field description both state this.
**Loopback binding is enforced.** whisper-server has no auth/TLS, so `start()` pins `--host 127.0.0.1` when the user did not supply a `--host` in `extraArgs` (so we don't depend on upstream's default binding), and throws before spawning if any user-supplied `--host` is non-loopback (`getHostArgs` + `isLoopbackHost` in [src/whisper-host.ts](../src/whisper-host.ts); loopback = `127.0.0.1` / `localhost` / `::1`). `getHostArgs` collects EVERY `--host` (or `--host=value`) occurrence in `extraArgs`, not just the first: whisper-server (like most CLI parsers) honors the LAST `--host` it's given, so checking only the first let `--host 127.0.0.1 --host 0.0.0.0` slip past the guard and bind an unauthenticated server to the LAN. This prevents a stray `--host 0.0.0.0` from silently exposing an unauthenticated transcription server to the LAN. The README's "Network exposure" disclosure and the Extra args field description both state this.
The `whisper-local` transcription provider ([src/transcription/whisper-local.ts](../src/transcription/whisper-local.ts)) is a thin shim that POSTs to `http://127.0.0.1:<port>/inference` (whisper.cpp's native server route, not OpenAI's `/v1/audio/transcriptions`). Before sending, the recorded blob is transcoded to 16 kHz mono 16-bit PCM WAV via `transcodeToWavPcm` in [src/audio-transcode.ts](../src/audio-transcode.ts) (Web Audio API: `AudioContext.decodeAudioData``OfflineAudioContext` for resample/downmix → hand-written RIFF header). whisper.cpp's server cannot decode WebM/Opus (the MediaRecorder default) without a custom ffmpeg-enabled build, so the transcode is mandatory. The same helper is reused by [src/transcription/mistral-voxtral.ts](../src/transcription/mistral-voxtral.ts) (Voxtral also rejects WebM). If you add a third audio-uploading provider that needs WAV, import from `audio-transcode.ts` rather than duplicating the helpers. The shim grabs the host reference via `bindWhisperHost(host)` called from [src/main.ts](../src/main.ts) on load. If the host status is anything other than `'running'`, the adapter throws a `ProviderError` with a "start it from settings" message; the pipeline surfaces that as a Notice. No API key is collected for this provider (no auth, no settings field).
@ -17,10 +17,11 @@ Mobile compatibility: `WhisperHost` and the `whisper-local` option are guarded e
## Gotchas
- **WhisperHost lazy-requires Node modules** inside a `Platform.isDesktop` guard. Importing `child_process` / `net` / `fs` at module top would crash on mobile load. The cached `nodeApiCache` is `null` on mobile, so any host method that needs it bails with a clear "desktop only" error.
- **`splitArgs` is a naive whitespace split.** The local-whisper `extraArgs` value is tokenized on whitespace only, so a single argument containing spaces (e.g. a quoted path) is not supported. Not a security issue (argv array, no shell). The Extra args settings field description states the limitation; add quote-aware parsing only if users actually hit it. The tokenized list is also scanned by `getHostArg` so a non-loopback `--host` is rejected before spawn (see Loopback binding above).
- **`splitArgs` is a naive whitespace split.** The local-whisper `extraArgs` value is tokenized on whitespace only, so a single argument containing spaces (e.g. a quoted path) is not supported. Not a security issue (argv array, no shell). The Extra args settings field description states the limitation; add quote-aware parsing only if users actually hit it. The tokenized list is also scanned by `getHostArgs` so every non-loopback `--host` occurrence is rejected before spawn (see Loopback binding above).
- **WhisperHost has three ownership states.** `'spawned'` (this session's child handle is live; log capture works), `'adopted'` (port bound, PID sidecar matches a live PID — we started it in a previous session, no log capture), `'external'` (port bound but no sidecar match — someone else started it). Status enum gains `'external'` alongside `stopped`/`starting`/`running`/`crashed`; `running` means we own it (spawned OR adopted) and Stop works, `external` means we don't and Stop is disabled. `WhisperHost.snapshot()` returns `{ status, baseUrl, ownership, pid }` for UI consumers; `formatWhisperStatus(snap)` produces labels like "Running on http://... (adopted from previous session, pid 12345)." or "External whisper-server on http://... (not started by ReWrite).".
- **Transcription never checks `WhisperHost.status()`.** [src/transcription/whisper-local.ts](../src/transcription/whisper-local.ts) just asks for `host.baseUrl()` and POSTs. The HTTP API doesn't care who owns the process — if the port is reachable, transcription works. `baseUrl()` returns the URL for both `'running'` and `'external'` states.
- **PID sidecar at `<plugin folder>/whisper-host.pid.json`** records `{ pid, port, binaryPath, startedAt }` once the server is ready. `stop()` and the child `exit` handler clear it. `WhisperHost.probe(config)` (called from `onload` and at the top of `start()`) reads the sidecar: if the port is reachable AND the sidecar PID is still alive AND the sidecar port matches the configured port, the host adopts it as `'running'` with ownership `'adopted'`. Otherwise (port bound, no sidecar match) the host transitions to `'external'`. Probe never disturbs state when we already hold a live spawned child. Uses `process.kill(pid, 0)` for liveness probing (added to the lazy `NodeAPI` cache alongside `cp`/`net`/`fs`).
- **Stop semantics depend on ownership.** Spawned: `child.kill()` via the live ChildProcess handle. Adopted: `process.kill(pid, signal)` since we only have the PID, then clear the sidecar. External: `stop()` throws "not started by ReWrite — stop it via OS tools." (the settings-tab button is disabled with a tooltip, the status-bar click shows a Notice, the `stop-whisper-host` command is hidden via `checkCallback`). This preserves the "never kill a process we didn't start" invariant — the sidecar is proof we started it.
- **Stop semantics depend on ownership.** Spawned: `child.kill()` via the live ChildProcess handle; the 3 s SIGKILL fallback `setTimeout` is cleared as soon as the child's `exit` event fires, so it does not leak or fire spuriously after a prompt SIGTERM exit. Adopted: `process.kill(pid, signal)` since we only have the PID, then clear the sidecar. External: `stop()` throws "not started by ReWrite — stop it via OS tools." (the settings-tab button is disabled with a tooltip, the status-bar click shows a Notice, the `stop-whisper-host` command is hidden via `checkCallback`). This preserves the "never kill a process we didn't start" invariant — the sidecar is proof we started it.
- **Stopping an adopted server re-verifies the port immediately before killing by PID.** The sidecar's PID could have been recycled by the OS for an unrelated process since we adopted it (e.g. after an OS crash between sessions); `probe()`'s `isPidAlive` check only confirms SOME process has that PID, not that it's actually bound to the tracked port or is whisper-server at all. `stop()`'s adopted-PID branch re-checks `isPortReachable` on the tracked port right before sending a signal: if nothing is listening there anymore, whatever we adopted is already gone, so it resets to `'stopped'` and clears the sidecar WITHOUT sending a signal to the (possibly reassigned) PID. This narrows, but does not eliminate, the PID-reuse window: a full port-plus-PID collision is far less likely than a bare PID collision, but not impossible.
- **`onunload` stops the whisper-host fire-and-forget.** `void this.whisperHost?.stop()` — Obsidian's `Plugin.onunload` signature is `() => void`, so we can't await. Stop() sends SIGTERM with a 3 s SIGKILL fallback, but the unload sequence may complete before that. Child processes do NOT auto-die with the parent on Linux (they reparent to init), on Windows (orphaned but still running), or reliably on macOS (SIGTERM may not land before Obsidian exits). The probe/adopt flow above is what closes this hole on next launch; don't try to make unload async.
- **Whisper status bar polls `whisperHost.status()` every 1 s** via `registerInterval`. The host has no event emitter; both [src/settings/tab.ts](../src/settings/tab.ts) (re-render on Start/Stop click) and [src/ui/whisper-status-bar.ts](../src/ui/whisper-status-bar.ts) (interval poll) re-read the status synchronously. If you add a third consumer, poll the same way; do not bolt on an event system.

View file

@ -66,7 +66,7 @@ export default tseslint.config(
parserOptions: {
projectService: {
allowDefaultProject: [
'eslint.config.js',
'eslint.config.mts',
'manifest.json'
]
},
@ -84,13 +84,41 @@ export default tseslint.config(
'obsidianmd/ui/sentence-case-locale-module': ['error', sentenceCaseOptions],
},
},
{
// test/ runs under Node via Vitest, not inside Obsidian, so the Node-builtin ban that
// keeps src/ portable to the Electron/mobile plugin runtime doesn't apply here.
files: ['test/**/*.ts'],
rules: {
'import/no-nodejs-modules': 'off',
},
},
{
// These two files test the plain-JS dev scripts (local-review.mjs /
// prepare-release-vault.mjs), which ship no type declarations, so every import from
// them is typed `any` and trips the type-safety rules purely on that (not a real unsafe
// access). `hardcoded-config-path` is an Obsidian-runtime rule about `Vault#configDir`;
// it does not apply to a release-prep build script that copies into a fixed
// `.obsidian/plugins/` path. Relaxations scoped to just these two files.
files: ['test/local-review.test.ts', 'test/prepare-release-vault.test.ts'],
rules: {
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'obsidianmd/hardcoded-config-path': 'off',
},
},
globalIgnores([
"node_modules",
"dist",
"esbuild.config.mjs",
"eslint.config.js",
"eslint.config.mts",
"version-bump.mjs",
"local-review.mjs",
"prepare-release-vault.mjs",
"dev-tools.config.example.json",
"versions.json",
"main.js",
"vitest.config.ts",
]),
);

392
local-review.mjs Normal file
View file

@ -0,0 +1,392 @@
// Advisory local code-review pass over the current diff, run against a locally-hosted
// llama.cpp model (Ornith 1.0) before loading a build into Obsidian to test by hand.
// Plain ESM, no TypeScript, no runtime dependency (Node's global fetch covers the HTTP
// call), same convention as version-bump.mjs / esbuild.config.mjs. Pure logic is exported
// for test/local-review.test.ts; the side-effecting entry point is guarded behind the
// import.meta.url === process.argv[1] check at the bottom, exactly like version-bump.mjs.
//
// Advisory-only by design: this ALWAYS exits 0. A setup failure (missing config, git
// error, server never became ready) prints a clearly marked ERROR: block, never a stack
// trace and never a non-zero exit, so it can never block the local build-and-test loop.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync, spawn } from 'node:child_process';
import { createConnection } from 'node:net';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const READY_POLL_MS = 250;
const STOP_KILL_GRACE_MS = 3_000;
const MAX_LOG_BYTES = 1_000_000;
// --- Loopback guard (duplicated from src/whisper-host.ts on purpose) -------------------
// src/whisper-host.ts can't be imported from plain Node: its top-level `import ... from
// 'obsidian'` only resolves inside the Obsidian bundle (or behind the Vitest alias). These
// ~20 lines get their own copy here, and their own coverage in test/local-review.test.ts,
// because llama-server (like whisper-server) has no auth or TLS and must never bind off
// loopback.
// Naive whitespace tokenizer for the extraArgs string (matches whisper-host's splitArgs;
// a single argument containing spaces is not supported, which is fine for an argv array).
export function splitArgs(s) {
const trimmed = String(s ?? '').trim();
if (!trimmed) return [];
return trimmed.split(/\s+/);
}
// Find the values of ALL --host arguments in an already-tokenized arg list. Every
// occurrence must be collected, not just the first: llama-server honors the LAST --host, so
// checking only the first would let `--host 127.0.0.1 --host 0.0.0.0` slip past the guard.
export function getHostArgs(args) {
const hosts = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--host') {
hosts.push(args[i + 1] ?? '');
} else if (a !== undefined && a.startsWith('--host=')) {
hosts.push(a.slice('--host='.length));
}
}
return hosts;
}
// Whether a host value binds only the loopback interface.
export function isLoopbackHost(host) {
const h = String(host ?? '').trim().toLowerCase();
return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '[::1]';
}
// --- Config -----------------------------------------------------------------------------
// dev-tools.config.json (gitignored, no baked defaults) mirrors LocalWhisperSettings'
// philosophy: required paths validated present + existsSync before use. `exists` is
// injectable so tests don't need real files on disk.
export function parseLocalReviewConfig(raw, exists = existsSync) {
if (!raw || typeof raw !== 'object') {
throw new Error('dev-tools.config.json is missing or is not a JSON object.');
}
const lr = raw.localReview;
if (!lr || typeof lr !== 'object') {
throw new Error('dev-tools.config.json has no "localReview" section. See dev-tools.config.example.json.');
}
const binaryPath = typeof lr.binaryPath === 'string' ? lr.binaryPath.trim() : '';
const modelPath = typeof lr.modelPath === 'string' ? lr.modelPath.trim() : '';
if (!binaryPath) throw new Error('localReview.binaryPath is required (path to the llama-server executable).');
if (!modelPath) throw new Error('localReview.modelPath is required (path to the Ornith 1.0 gguf).');
if (!exists(binaryPath)) throw new Error(`localReview.binaryPath does not exist: ${binaryPath}`);
if (!exists(modelPath)) throw new Error(`localReview.modelPath does not exist: ${modelPath}`);
return {
binaryPath,
modelPath,
port: Number.isFinite(lr.port) && lr.port > 0 && lr.port <= 65535 ? lr.port : 8090,
extraArgs: typeof lr.extraArgs === 'string' ? lr.extraArgs : '',
baseRef: typeof lr.baseRef === 'string' && lr.baseRef.trim() ? lr.baseRef.trim() : 'master',
readyTimeoutMs: Number.isFinite(lr.readyTimeoutMs) && lr.readyTimeoutMs > 0 ? lr.readyTimeoutMs : 60_000,
requestTimeoutMs: Number.isFinite(lr.requestTimeoutMs) && lr.requestTimeoutMs > 0 ? lr.requestTimeoutMs : 300_000,
maxDiffChars: Number.isFinite(lr.maxDiffChars) && lr.maxDiffChars > 0 ? lr.maxDiffChars : 60_000,
};
}
// --- CLI + diff scope -------------------------------------------------------------------
export function parseCliArgs(argv) {
const out = { base: null, staged: false, full: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--base') {
out.base = argv[i + 1] ?? null;
i++;
} else if (a.startsWith('--base=')) {
out.base = a.slice('--base='.length);
} else if (a === '--staged') {
out.staged = true;
} else if (a === '--full') {
out.full = true;
}
}
return out;
}
// Build the `git diff` argument list for a scope. Default scope diffs the merge-base
// against the working tree ("everything you're about to go test": staged + unstaged +
// committed-since-branch). --staged narrows to the index; --full to the last commit only.
export function buildDiffArgs(scope, mergeBase, { stat = false } = {}) {
const base = ['diff'];
if (stat) base.push('--stat');
if (scope.staged) return [...base, '--staged'];
if (scope.full) return [...base, 'HEAD~1', 'HEAD'];
return [...base, mergeBase];
}
export function scopeLabel(scope) {
if (scope.staged) return 'staged changes (index vs HEAD)';
if (scope.full) return 'last commit only (HEAD~1..HEAD)';
return 'working tree vs merge-base (staged + unstaged + committed since branch)';
}
// --- Prompt -----------------------------------------------------------------------------
export const REVIEW_SYSTEM_PROMPT = [
'You are a focused code reviewer for the ReWrite (Voice Notes) plugin for Obsidian, a TypeScript codebase.',
'You are given a git diff. Review ONLY the changed code. Report, most important first:',
'',
'1. Correctness bugs: logic errors, unhandled edge cases, off-by-one, wrong conditionals, broken control flow, missing awaits, resource leaks.',
'2. Simplification and reuse: duplicated logic, needless complexity, an existing helper that should have been reused.',
'3. Efficiency: obvious wasted work (redundant reloads, quadratic loops, re-parsing).',
'',
'Pay SPECIAL attention to this repo-specific class of bug, which has shipped past the build/lint/test suite before because there is no headless UI test harness:',
'- DOM event wiring, enable/disable state, and button/control lifecycle. Flag any handler that is registered but can never fire, a control disabled by an over-broad guard so the user is locked out (e.g. a Record/Stop button that a shared isLocked() guard disables during the very state it is meant to control), a listener added without a matching removal, or state that is read stale after a re-render.',
'- Regex or string parsing that only handles the happy path (e.g. a fence/marker regex that swallows unrelated content, a guard that checks only the first of several occurrences).',
'',
'Also flag, briefly, any violation of these house rules the Obsidian review bot enforces:',
'- No `!important` in CSS; no `eslint-disable` directives.',
'- Popout-window safety: prefer `activeDocument` / `activeWindow` and `window.setTimeout` over bare globals; do not use `globalThis`.',
'',
'Be concrete: name the file and the symptom, and give the failing input or state where you can. If you are unsure, say so rather than inventing a bug. If the diff looks clean, say that plainly. You are a first-pass filter running locally on a quantized model with no ability to explore the rest of the repo, so do not fabricate confidence.',
].join('\n');
export function buildReviewMessages(diffText) {
return [
{ role: 'system', content: REVIEW_SYSTEM_PROMPT },
{ role: 'user', content: `Here is the git diff to review:\n\n\`\`\`diff\n${diffText}\n\`\`\`` },
];
}
export function truncateDiff(diff, maxChars) {
if (diff.length <= maxChars) return { text: diff, truncated: false };
return {
text: `${diff.slice(0, maxChars)}\n\n[... diff truncated at ${maxChars} characters; raise localReview.maxDiffChars to see more ...]`,
truncated: true,
};
}
// --- Report -----------------------------------------------------------------------------
export function formatReport({ baseRef, mergeBase, timestamp, diffStat, findings, truncated, scope }) {
const lines = [];
lines.push('# Local review report');
lines.push('');
lines.push('> Advisory only. Generated by `npm run review` against a local llama.cpp model (Ornith 1.0). This is a first-pass filter, not a substitute for `/code-review` or human judgment. It always exits 0.');
lines.push('');
lines.push(`- Base ref: \`${baseRef}\``);
lines.push(`- Merge base: \`${mergeBase}\``);
lines.push(`- Scope: ${scopeLabel(scope)}`);
lines.push(`- Generated: ${timestamp}`);
if (truncated) lines.push('- Note: the diff was truncated before review (exceeded `localReview.maxDiffChars`).');
lines.push('');
lines.push('## Diff stat');
lines.push('');
lines.push('```');
lines.push((diffStat || '').trim() || '(no changes)');
lines.push('```');
lines.push('');
lines.push('## Findings');
lines.push('');
lines.push((findings || '').trim() || '(no findings returned)');
lines.push('');
return lines.join('\n');
}
// --- Runtime (not exported; not unit-tested) --------------------------------------------
function printError(msg) {
console.error(`\nERROR: local review could not run.\n\n${msg}\n\n(This is advisory tooling; exiting 0 so it never blocks your build-and-test loop.)\n`);
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isPortReachable(port, host = '127.0.0.1') {
return new Promise((resolve) => {
let settled = false;
const done = (reachable, socket) => {
if (settled) return;
settled = true;
try { socket?.destroy(); } catch { /* best effort */ }
resolve(reachable);
};
try {
const socket = createConnection({ host, port });
socket.once('connect', () => done(true, socket));
socket.once('error', () => done(false, socket));
} catch {
done(false);
}
});
}
// Deliberate simplification vs WhisperHost: no PID sidecar / cross-session adoption. This
// is a one-shot run-and-exit script, so "adopt" just means "the port is already reachable,
// use it and leave it running"; "spawn" means we start it and stop it in the finally block.
function spawnServer(config) {
const extra = splitArgs(config.extraArgs);
const hostArgs = getHostArgs(extra);
const args = [
'-m', config.modelPath,
'--port', String(config.port),
...(hostArgs.length === 0 ? ['--host', '127.0.0.1'] : []),
...extra,
];
const child = spawn(config.binaryPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let log = '';
const append = (s) => {
log += s;
if (log.length > MAX_LOG_BYTES) log = log.slice(-MAX_LOG_BYTES);
};
child.stdout?.on('data', (d) => append(d.toString()));
child.stderr?.on('data', (d) => append(d.toString()));
const handle = { child, exited: false, getLog: () => log };
child.on('exit', () => { handle.exited = true; });
return handle;
}
// llama-server can bind the port before the model finishes loading, so a single failed
// request is not fatal: poll GET /health (503 while loading, 200 when ready) up to the
// deadline. Returns false if the child died or the deadline passed.
async function waitForReady(baseUrl, deadline, handle) {
while (Date.now() < deadline) {
if (handle?.exited) return false;
try {
const res = await fetch(`${baseUrl}/health`, { method: 'GET' });
if (res.ok) return true;
} catch { /* not up yet */ }
await delay(READY_POLL_MS);
}
return false;
}
async function stopServer(handle) {
const { child } = handle;
await new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(killTimer);
resolve();
};
const killTimer = setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* best effort */ }
finish();
}, STOP_KILL_GRACE_MS);
child.once('exit', finish);
try { child.kill(); } catch { finish(); }
});
}
async function postReview(baseUrl, messages, requestTimeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
try {
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, temperature: 0.2, stream: false, max_tokens: 4096 }),
signal: controller.signal,
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`llama-server returned HTTP ${res.status}. ${body.slice(0, 300)}`);
}
const data = await res.json();
return data?.choices?.[0]?.message?.content ?? '(no content returned by the model)';
} finally {
clearTimeout(timer);
}
}
async function main() {
const repoRoot = dirname(fileURLToPath(import.meta.url));
const configPath = join(repoRoot, 'dev-tools.config.json');
if (!existsSync(configPath)) {
printError(`No dev-tools.config.json found at ${configPath}.\nCopy dev-tools.config.example.json to dev-tools.config.json and fill in localReview.binaryPath and localReview.modelPath.`);
return;
}
let config;
try {
const raw = JSON.parse(readFileSync(configPath, 'utf8'));
config = parseLocalReviewConfig(raw);
} catch (e) {
printError(`Could not load dev-tools.config.json: ${e.message}`);
return;
}
const badHost = getHostArgs(splitArgs(config.extraArgs)).find((h) => !isLoopbackHost(h));
if (badHost !== undefined) {
printError(`Refusing to start: --host ${badHost || '(empty)'} in localReview.extraArgs would bind llama-server to a non-loopback interface. llama-server has no auth or TLS. Remove it.`);
return;
}
const cli = parseCliArgs(process.argv.slice(2));
const baseRef = cli.base ?? config.baseRef;
let mergeBase;
let diff;
let diffStat;
try {
mergeBase = execFileSync('git', ['merge-base', baseRef, 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim();
diff = execFileSync('git', buildDiffArgs(cli, mergeBase), { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
diffStat = execFileSync('git', buildDiffArgs(cli, mergeBase, { stat: true }), { cwd: repoRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
} catch (e) {
printError(`git failed (is "${baseRef}" a valid ref?): ${e.message}`);
return;
}
if (!diff.trim()) {
console.log(`Nothing to review: no diff for scope "${scopeLabel(cli)}" against ${baseRef}. Skipping the model call.`);
return;
}
const { text: diffText, truncated } = truncateDiff(diff, config.maxDiffChars);
const baseUrl = `http://127.0.0.1:${config.port}`;
let spawned = null;
try {
if (await isPortReachable(config.port)) {
console.log(`Port ${config.port} is already reachable; adopting the running llama-server (it will be left running).`);
} else {
console.log(`Starting llama-server on ${baseUrl} (model load can take a while)...`);
spawned = spawnServer(config);
const ready = await waitForReady(baseUrl, Date.now() + config.readyTimeoutMs, spawned);
if (!ready) {
const tail = spawned.getLog().slice(-800);
throw new Error(`llama-server did not become ready within ${config.readyTimeoutMs / 1000}s (raise localReview.readyTimeoutMs if your disk/GPU load is slow). Log tail:\n${tail || '(empty)'}`);
}
}
console.log('Sending the diff to the model for review...');
const findings = await postReview(baseUrl, buildReviewMessages(diffText), config.requestTimeoutMs);
const report = formatReport({
baseRef,
mergeBase,
timestamp: new Date().toISOString(),
diffStat,
findings,
truncated,
scope: cli,
});
const reportPath = join(repoRoot, 'docs', 'claude-scratch', 'local-review-report.md');
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(reportPath, report);
console.log(`\n${'='.repeat(72)}\n${findings.trim()}\n${'='.repeat(72)}\n`);
console.log(`Report written to ${reportPath}`);
} catch (e) {
printError(e.message);
} finally {
if (spawned) {
console.log('Stopping the llama-server we started...');
await stopServer(spawned);
}
}
}
// Side effects run only when invoked as a script, not when imported by a test.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main()
.then(() => process.exit(0))
.catch((e) => {
printError(e?.message ?? String(e));
process.exit(0);
});
}

View file

@ -453,26 +453,7 @@ Use `esbuild` (not webpack):
## Testing Checklist
- [ ] Plugin loads without errors in the Obsidian developer console
- [ ] Auto-detection correctly activates Desktop profile on Electron, Mobile profile on mobile
- [ ] Manual profile override in settings works and persists
- [ ] Global API keys fall back correctly when profile-level keys are empty
- [ ] Settings tab renders all sections; provider-specific fields show/hide correctly
- [ ] Template CRUD: add, edit, delete, and drag-to-reorder all work
- [ ] Modal opens via command palette; both tabs are accessible
- [ ] Record tab: mic permission prompt fires; recording indicator and timer show; stop works
- [ ] Paste tab: text submitted correctly reaches LLM; output inserted per `insertMode`
- [ ] All three `insertMode` values produce correct output placement
- [ ] `{{date}}` and `{{time}}` in filename templates expand correctly
- [ ] All five default templates produce sensibly structured output
- [ ] Each transcription provider: happy path produces a transcript (use real or mocked API)
- [ ] Each LLM provider: happy path produces cleaned-up text
- [ ] Web Speech path: SpeechRecognition session starts on Record; transcript flows to LLM
- [ ] AssemblyAI and Rev.ai polling: completes successfully; timeout after 60s surfaces error
- [ ] Missing API key shows Notice and halts before any network call
- [ ] Transcription error surfaces to user; LLM error surfaces with clipboard fallback
- [ ] Quick Record command starts recording without opening the modal
- [ ] Plugin functions on Obsidian mobile (Web Speech + paste paths at minimum)
> **Retired.** This list went stale (5 templates instead of 10, a removed `webspeech` provider, a flat 60 s poll timeout, a removed clipboard fallback). The live, kept-current manual verification pass is the **`release-checklist` skill** at [`.claude/skills/release-checklist/`](.claude/skills/release-checklist/) (`CHECKLIST.md` there is runnable standalone). See [docs/DEV_TOOLING.md](docs/DEV_TOOLING.md). Heading kept as a stable anchor.
---

1143
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,26 +8,32 @@
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ."
"lint": "eslint .",
"test": "vitest run",
"review": "node local-review.mjs",
"release:prep": "node prepare-release-vault.mjs"
},
"keywords": [],
"license": "0BSD",
"devDependencies": {
"@eslint/js": "9.30.1",
"@types/node": "^16.11.6",
"@types/node": "^20.19.0",
"esbuild": "0.25.5",
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
"jiti": "2.6.1",
"tslib": "2.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1"
"typescript-eslint": "8.35.1",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^3.2.7",
"yaml": "^2.9.0"
},
"dependencies": {
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"hash-wasm": "^4.12.0",
"obsidian": "latest"
"obsidian": "^1.10.3"
}
}

95
prepare-release-vault.mjs Normal file
View file

@ -0,0 +1,95 @@
// Build the plugin and copy the three release artifacts into a scratch Obsidian vault's
// plugin folder, so the release-checklist's manual feature pass can start from a clean
// install without hand-copying files. Plain ESM at repo root, same convention as
// version-bump.mjs / local-review.mjs; pure helpers are exported for
// test/prepare-release-vault.test.ts and the side-effecting entry point is guarded behind
// the import.meta.url === process.argv[1] check.
//
// Unlike local-review.mjs, this FAILS LOUDLY (non-zero exit) on any error. There is no
// "advisory" framing for release prep: if the build fails or the vault path is wrong, the
// human needs to stop and fix it.
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
// The three loose asset files an Obsidian plugin ships (never zipped). Fixed list.
export const RELEASE_FILES = ['main.js', 'manifest.json', 'styles.css'];
export function parseReleaseVaultConfig(raw) {
if (!raw || typeof raw !== 'object') {
throw new Error('dev-tools.config.json is missing or is not a JSON object.');
}
const rv = raw.releaseVault;
if (!rv || typeof rv !== 'object') {
throw new Error('dev-tools.config.json has no "releaseVault" section. See dev-tools.config.example.json.');
}
const vaultPath = typeof rv.vaultPath === 'string' ? rv.vaultPath.trim() : '';
if (!vaultPath) throw new Error('releaseVault.vaultPath is required (path to a scratch Obsidian vault for release testing; never a real personal vault).');
return { vaultPath };
}
// The plugin id is derived from manifest.json, never duplicated. It is also the folder
// name Obsidian requires the plugin to live under.
export function readPluginId(manifest) {
if (!manifest || typeof manifest.id !== 'string' || !manifest.id.trim()) {
throw new Error('manifest.json has no "id".');
}
return manifest.id.trim();
}
export function computeTargetPluginDir(vaultPath, pluginId) {
return join(vaultPath, '.obsidian', 'plugins', pluginId);
}
// --- Runtime (not exported; not unit-tested) --------------------------------------------
function main() {
const repoRoot = dirname(fileURLToPath(import.meta.url));
const configPath = join(repoRoot, 'dev-tools.config.json');
if (!existsSync(configPath)) {
throw new Error(`No dev-tools.config.json found at ${configPath}. Copy dev-tools.config.example.json to dev-tools.config.json and fill in releaseVault.vaultPath.`);
}
const config = parseReleaseVaultConfig(JSON.parse(readFileSync(configPath, 'utf8')));
// Validate the vault path up front, before building or creating any directory, so a
// bad path fails fast with no stray directory creation.
if (!existsSync(config.vaultPath)) {
throw new Error(`releaseVault.vaultPath does not exist: ${config.vaultPath}`);
}
if (!existsSync(join(config.vaultPath, '.obsidian'))) {
console.warn(`Warning: ${config.vaultPath} has no .obsidian subfolder. If this vault has never been opened in Obsidian, open it once first so the plugins folder is recognized.`);
}
const pluginId = readPluginId(JSON.parse(readFileSync(join(repoRoot, 'manifest.json'), 'utf8')));
console.log('Running npm run build...');
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
execFileSync(npm, ['run', 'build'], { cwd: repoRoot, stdio: 'inherit' });
const targetDir = computeTargetPluginDir(config.vaultPath, pluginId);
mkdirSync(targetDir, { recursive: true });
for (const file of RELEASE_FILES) {
const src = join(repoRoot, file);
if (!existsSync(src)) {
throw new Error(`Expected build artifact not found: ${src}. Did npm run build succeed?`);
}
copyFileSync(src, join(targetDir, file));
console.log(`Copied ${file} -> ${join(targetDir, file)}`);
}
console.log(`\nDone. Now in Obsidian: open the vault at ${config.vaultPath}, then reload the plugin`);
console.log(`(Settings -> Community plugins -> toggle "${pluginId}" off and on, or reload Obsidian) to pick up the new build.`);
}
if (fileURLToPath(import.meta.url) === process.argv[1]) {
try {
main();
} catch (e) {
console.error(`\nERROR: ${e?.message ?? String(e)}\n`);
process.exit(1);
}
}

View file

@ -1,9 +1,24 @@
export const TARGET_SAMPLE_RATE = 16000;
export async function transcodeToWavPcm(audio: Blob, targetSampleRate: number = TARGET_SAMPLE_RATE): Promise<ArrayBuffer> {
function abortIfSignaled(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
}
// Decode + offline render can take multiple seconds for a long recording, with no
// cancellation checks of its own; check the signal around each stage so a cancel requested
// during transcode is observed promptly instead of only at the subsequent upload.
export async function transcodeToWavPcm(
audio: Blob,
targetSampleRate: number = TARGET_SAMPLE_RATE,
signal?: AbortSignal,
): Promise<ArrayBuffer> {
abortIfSignaled(signal);
const input = await audio.arrayBuffer();
abortIfSignaled(signal);
const decoded = await decodeAudio(input);
abortIfSignaled(signal);
const resampled = await resampleToMono(decoded, targetSampleRate);
abortIfSignaled(signal);
return encodeWav16(resampled, targetSampleRate);
}

View file

@ -1,6 +1,6 @@
import { App, MarkdownView, Modal, moment, normalizePath, Notice, Setting, TFile } from 'obsidian';
import { NewFileCollisionMode, NoteTemplate } from './types';
import { sanitizeFilename } from './templates-folder';
import { guardReservedName, sanitizeFilename } from './templates-folder';
export type InsertStage = 'cursor' | 'newFile' | 'append';
@ -231,10 +231,12 @@ function titleToFilename(title: string): string {
if (safe.length > MAX_TITLE_LEN) {
safe = safe.slice(0, MAX_TITLE_LEN).replace(/[ .]+$/, '');
}
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(safe)) safe = `_${safe}`;
// sanitizeFilename returns 'Untitled' for empty input; treat that as "no usable
// title" so the caller falls back to the date template rather than a literal
// "Untitled" file.
// Re-guard after the extra stripping/capping above: it can turn a name that wasn't
// reserved (or wasn't all-dots) right after sanitizeFilename into one that is.
safe = guardReservedName(safe);
// sanitizeFilename/guardReservedName return 'Untitled' for empty/dot-only input; treat
// that as "no usable title" so the caller falls back to the date template rather than a
// literal "Untitled" file.
return safe === 'Untitled' ? '' : safe;
}

View file

@ -50,11 +50,25 @@ export function createGeminiLLM(): LLMProvider {
if (candidate?.finishReason === 'SAFETY') {
throw new Error('gemini: response blocked by safety filter');
}
const text = candidate?.content?.parts?.[0]?.text;
if (typeof text !== 'string') {
if (candidate?.finishReason === 'RECITATION') {
throw new Error('gemini: response blocked (matched a recitation/citation filter)');
}
if (candidate?.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('gemini: response blocked (prohibited content)');
}
if (candidate?.finishReason === 'MAX_TOKENS') {
throw new Error(
'gemini: the requested note length exceeds this model\'s output limit. '
+ 'Lower "Maximum note length" in settings, or choose a model with a higher output cap.',
);
}
// Gemini can split a response across multiple parts; concatenate all of them rather
// than reading only parts[0], which would silently truncate long notes at the boundary.
const parts = candidate?.content?.parts;
if (!parts || parts.length === 0) {
throw new Error(`gemini: response missing text (finishReason=${candidate?.finishReason ?? 'unknown'})`);
}
return text.trim();
return parts.map((p) => p.text ?? '').join('').trim();
},
async listModels(config, signal) {
if (!config.apiKey) throw new Error('gemini: API key is not configured');

View file

@ -13,12 +13,14 @@ import { GlobalSettings, KnownNoun, NoteTemplate, PipelineHost } from './types';
import { WhisperHost } from './whisper-host';
import { bindWhisperHost } from './transcription/whisper-local';
import { resolveActiveProfile } from './platform';
import { isPathInTemplatesFolder, loadTemplatesFromFolder } from './templates-folder';
import { isPathInTemplatesFolder, loadTemplatesFromFolder, pickDefaultTemplateId } from './templates-folder';
import { isPathSharedCore, loadSharedCoreFromFile } from './shared-core';
import { isPathAssistantPrompt, loadAssistantPromptFromFile } from './assistant-prompt';
import { isPathKnownNouns, loadKnownNounsFromFile } from './known-nouns';
import { setEncryptionMode, EncryptionStatus, getEncryptionStatus, unlockSecrets, warmSecretStorage } from './secrets';
type RefreshKind = 'templates' | 'sharedCore' | 'assistantPrompt' | 'knownNouns';
export default class ReWritePlugin extends Plugin implements PipelineHost {
settings!: GlobalSettings;
whisperHost!: WhisperHost;
@ -28,6 +30,22 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
knownNouns: KnownNoun[] = [];
encryptionStatus!: EncryptionStatus;
private activeQuickRecord: QuickRecordController | null = null;
// Reserved synchronously before the async startQuickRecord() (which awaits getUserMedia)
// resolves. activeQuickRecord alone isn't enough: it's only assigned after that await, so
// two rapid hotkey presses would both see it null and each open a mic stream, orphaning
// the first one.
private quickRecordStarting = false;
// Debounce timers for vault-event-triggered refreshes: an editor autosaving a template file
// fires several `modify` events in quick succession, and each would otherwise trigger its
// own full folder reload. Keyed per refresh kind so unrelated vault files don't share a timer.
private pendingRefresh: Partial<Record<RefreshKind, number>> = {};
// Generation counters guard against a stale, slower-resolving reload overwriting a newer one:
// each refresh*() bumps its counter before awaiting and only applies the result if the
// counter is still current when the load resolves.
private templatesGen = 0;
private sharedCoreGen = 0;
private assistantPromptGen = 0;
private knownNounsGen = 0;
private unlockListeners = new Set<() => void>();
async onload(): Promise<void> {
@ -181,6 +199,10 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
this.activeQuickRecord?.cancel();
this.activeQuickRecord = null;
void this.whisperHost?.stop();
for (const handle of Object.values(this.pendingRefresh)) {
if (handle !== undefined) window.clearTimeout(handle);
}
this.pendingRefresh = {};
}
async saveSettings(): Promise<void> {
@ -243,34 +265,59 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
}).open();
}
// Each bumps its generation counter before awaiting the load and only applies the result if
// the counter is still current, so an in-flight reload superseded by a newer one (concurrent
// direct call + debounced vault-event call, or two vault events close together) cannot
// overwrite fresher state with stale data.
async refreshTemplates(): Promise<void> {
this.templates = await loadTemplatesFromFolder(this.app, this.settings.templatesFolderPath);
const gen = ++this.templatesGen;
const templates = await loadTemplatesFromFolder(this.app, this.settings.templatesFolderPath);
if (gen === this.templatesGen) this.templates = templates;
}
async refreshSharedCore(): Promise<void> {
this.sharedCore = await loadSharedCoreFromFile(this.app, this.settings.sharedCorePath);
const gen = ++this.sharedCoreGen;
const sharedCore = await loadSharedCoreFromFile(this.app, this.settings.sharedCorePath);
if (gen === this.sharedCoreGen) this.sharedCore = sharedCore;
}
async refreshAssistantPrompt(): Promise<void> {
this.assistantPrompt = await loadAssistantPromptFromFile(this.app, this.settings.assistantPromptPath);
const gen = ++this.assistantPromptGen;
const assistantPrompt = await loadAssistantPromptFromFile(this.app, this.settings.assistantPromptPath);
if (gen === this.assistantPromptGen) this.assistantPrompt = assistantPrompt;
}
async refreshKnownNouns(): Promise<void> {
this.knownNouns = await loadKnownNounsFromFile(this.app, this.settings.knownNounsPath);
const gen = ++this.knownNounsGen;
const knownNouns = await loadKnownNounsFromFile(this.app, this.settings.knownNounsPath);
if (gen === this.knownNounsGen) this.knownNouns = knownNouns;
}
// Debounces a vault-event-triggered refresh (a template file autosaving fires several
// `modify` events in quick succession) by waiting for a quiet period before reloading. Direct
// callers (e.g. the settings tab's Populate button) call refresh*() straight instead, so they
// still get an immediate, awaitable reload.
private debounceRefresh(kind: RefreshKind, fn: () => Promise<void>): void {
const existing = this.pendingRefresh[kind];
if (existing !== undefined) window.clearTimeout(existing);
this.pendingRefresh[kind] = window.setTimeout(() => {
this.pendingRefresh[kind] = undefined;
void fn();
}, 250);
}
private onVaultFileChanged(path: string): void {
if (this.isInTemplatesFolder(path)) {
void this.refreshTemplates();
this.debounceRefresh('templates', () => this.refreshTemplates());
}
if (isPathSharedCore(path, this.settings.sharedCorePath)) {
void this.refreshSharedCore();
this.debounceRefresh('sharedCore', () => this.refreshSharedCore());
}
if (isPathAssistantPrompt(path, this.settings.assistantPromptPath)) {
void this.refreshAssistantPrompt();
this.debounceRefresh('assistantPrompt', () => this.refreshAssistantPrompt());
}
if (isPathKnownNouns(path, this.settings.knownNounsPath)) {
void this.refreshKnownNouns();
this.debounceRefresh('knownNouns', () => this.refreshKnownNouns());
}
}
@ -305,6 +352,7 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
await this.activeQuickRecord.finish();
return;
}
if (this.quickRecordStarting) return;
const commandId = `${this.manifest.id}:${opts?.fixed ? 'quick-record-fixed' : 'quick-record'}`;
let template: NoteTemplate | undefined;
if (opts?.fixed) {
@ -314,9 +362,14 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
return;
}
}
this.activeQuickRecord = await startQuickRecord(this, () => {
this.activeQuickRecord = null;
}, { template, commandId });
this.quickRecordStarting = true;
try {
this.activeQuickRecord = await startQuickRecord(this, () => {
this.activeQuickRecord = null;
}, { template, commandId });
} finally {
this.quickRecordStarting = false;
}
}
private processTextWithTemplate(preResolved?: TextResolution): void {
@ -339,7 +392,7 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
new TemplatePickerModal({
app: this.app,
templates: this.templates,
defaultTemplateId: this.pickDefaultTemplateId(),
defaultTemplateId: pickDefaultTemplateId(this.settings, this.templates),
previewText,
onPick: (template) => {
void runTextPipeline(this, template, source.text);
@ -378,7 +431,7 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
new TemplatePickerModal({
app: this.app,
templates: this.templates,
defaultTemplateId: this.pickDefaultTemplateId(),
defaultTemplateId: pickDefaultTemplateId(this.settings, this.templates),
previewText: `Audio: ${file.path}`,
showContext: this.templates.some((t) => t.enableContextHint),
onPick: (template, contextHint) => {
@ -391,7 +444,7 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
const re = /!\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g;
const sourcePath = info instanceof MarkdownView ? info.file?.path ?? '' : info.file?.path ?? '';
const sourcePath = info.file?.path ?? '';
let match: RegExpExecArray | null;
while ((match = re.exec(line)) !== null) {
const start = match.index;
@ -405,14 +458,4 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
return null;
}
private pickDefaultTemplateId(): string {
const s = this.settings;
if (s.lastUsedTemplateId && this.templates.some((t) => t.id === s.lastUsedTemplateId)) {
return s.lastUsedTemplateId;
}
if (s.defaultTemplateId && this.templates.some((t) => t.id === s.defaultTemplateId)) {
return s.defaultTemplateId;
}
return this.templates[0]?.id ?? '';
}
}

View file

@ -208,7 +208,11 @@ async function cleanupTranscript(params: PipelineParams, transcript: string): Pr
// block IS present it is always stripped from the body, even if its YAML is malformed
// (the model emitted a properties block, not content); strict parse falls back to a
// tolerant line-based read so a stray quote does not drop every value.
function extractFromBlock(
// The fence must carry the yaml/yml tag (the prompt contract always specifies
// ```yaml): a bare ``` fence is treated as content, so a model that wraps its whole
// reply in a plain code fence does not have the entire note swallowed as "the block".
// Exported for tests.
export function extractFromBlock(
raw: string,
specs: NotePropertySpec[],
wantsTitle: boolean,
@ -216,7 +220,7 @@ function extractFromBlock(
const properties: Record<string, string> = {};
for (const spec of specs) properties[spec.name] = '';
const fence = /^\s*```(?:ya?ml)?\s*\n([\s\S]*?)\n```[ \t]*\n?/;
const fence = /^\s*```ya?ml[ \t]*\n([\s\S]*?)\n```[ \t]*\n?/;
const match = raw.match(fence);
if (!match) return { body: raw.trim(), properties };
@ -252,7 +256,11 @@ function extractFromBlock(
// Tolerant fallback: read `key: value` lines directly and trim surrounding
// quotes, so a single malformed value does not blank the whole scaffold.
for (const line of block.split(/\r?\n/)) {
const m = /^\s*([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
// The key is matched up to the first colon (not restricted to ASCII word chars) so a
// declared property name with a space or non-ASCII character still round-trips through
// this fallback; a spurious match against a prose line is harmless because the result is
// only kept when it's in `allowed` or is the reserved title key.
const m = /^\s*([^:\n]+?)\s*:\s*(.*)$/.exec(line);
if (!m) continue;
const key = m[1] ?? '';
const val = stripQuotes((m[2] ?? '').trim());

View file

@ -1,4 +1,4 @@
import { normalizePath, Plugin } from 'obsidian';
import { normalizePath, Notice, Plugin } from 'obsidian';
import { argon2id } from 'hash-wasm';
import { isPassphraseAcceptable } from 'passphrase-strength';
@ -93,6 +93,21 @@ let secretStorageCache: SecretStorageLike | null | undefined;
let secretStorageAvailableCache: boolean | undefined;
let cachedEnvelope: SecretsEnvelope | null = null;
let unlockedKey: CryptoKey | null = null;
// getEncryptionStatus() caches secretStorageAvailableCache from the startup probe, so a keyring
// that becomes unavailable mid-session (OS keyring service restarts, permission revoked, etc.)
// otherwise degrades silently: loadKey's catch used to just return '', surfacing later as a
// confusing "missing API key". Warn once per session instead of on every failed read.
let keyringFailureNoticeShown = false;
function warnKeyringFailure(e: unknown): void {
console.error('ReWrite: secret storage operation failed', e);
if (keyringFailureNoticeShown) return;
keyringFailureNoticeShown = true;
new Notice(
'ReWrite: could not reach the OS secret store for your API keys this session. '
+ 'Check Settings → ReWrite → Encryption; you may need to re-enter your keys.',
0,
);
}
// ---------- Obsidian secret storage (app.secretStorage) ----------
@ -266,14 +281,21 @@ function parseKdf(raw: unknown): PassphraseKdf | undefined {
return undefined;
}
// Thrown by parseEnvelope when the file content itself is unreadable (bad JSON, or JSON
// that isn't even an object) as opposed to a recognized-but-outdated shape (version
// mismatch / bad mode), which is a deliberate fresh-start reset, not corruption.
class EnvelopeCorruptError extends Error {}
function parseEnvelope(raw: string): SecretsEnvelope {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return defaultEnvelope();
} catch (e) {
throw new EnvelopeCorruptError(`secrets file is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
}
if (!isObject(parsed)) {
throw new EnvelopeCorruptError('secrets file does not contain a JSON object');
}
if (!isObject(parsed)) return defaultEnvelope();
const version = typeof parsed.version === 'number' ? parsed.version : 1;
if (version !== SECRETS_VERSION) {
// Pre-release: no migrations. Treat unknown shapes (incl. old 'plaintext'
@ -299,16 +321,46 @@ function parseEnvelope(raw: string): SecretsEnvelope {
return envelope;
}
// A half-written or otherwise corrupt secrets file must not be silently treated as "no
// secrets configured": that would let the very next save overwrite the salvageable
// ciphertext with an empty envelope. Preserve the raw bytes alongside the original path
// (best-effort) before falling back to a fresh envelope, and warn the user once.
async function preserveCorruptSecretsFile(plugin: Plugin, path: string, raw: string): Promise<void> {
try {
await plugin.app.vault.adapter.write(`${path}.corrupt`, raw);
} catch (e) {
console.error('ReWrite: failed to preserve corrupt secrets file for recovery', e);
}
new Notice(
'ReWrite: your encrypted secrets file could not be read and looked corrupted. '
+ 'A copy was saved as secrets.json.nosync.corrupt before resetting to an empty, '
+ 'unconfigured store. Do not reconfigure API keys until you have recovered that '
+ 'file if you need the old ones.',
0,
);
}
async function readEnvelopeFromDisk(plugin: Plugin): Promise<SecretsEnvelope> {
const path = secretsPath(plugin);
const exists = await plugin.app.vault.adapter.exists(path);
if (!exists) return defaultEnvelope();
let raw: string;
try {
const raw = await plugin.app.vault.adapter.read(path);
return parseEnvelope(raw);
} catch {
raw = await plugin.app.vault.adapter.read(path);
} catch (e) {
console.error('ReWrite: failed to read secrets file', e);
return defaultEnvelope();
}
try {
return parseEnvelope(raw);
} catch (e) {
if (e instanceof EnvelopeCorruptError) {
console.error('ReWrite: secrets file is corrupt', e);
await preserveCorruptSecretsFile(plugin, path, raw);
return defaultEnvelope();
}
throw e;
}
}
async function ensureEnvelope(plugin: Plugin): Promise<SecretsEnvelope> {
@ -317,9 +369,30 @@ async function ensureEnvelope(plugin: Plugin): Promise<SecretsEnvelope> {
return cachedEnvelope;
}
// Writes via a temp file + remove + rename so a crash or sync conflict mid-write leaves
// either the old file or the new one intact, never a truncated/interleaved one. Falls
// back to a direct write if the adapter doesn't support rename (defensive; Obsidian's
// adapters do).
async function writeEnvelope(plugin: Plugin, envelope: SecretsEnvelope): Promise<void> {
const path = secretsPath(plugin);
await plugin.app.vault.adapter.write(path, JSON.stringify(envelope));
const tmpPath = `${path}.tmp`;
const json = JSON.stringify(envelope);
const adapter = plugin.app.vault.adapter;
try {
await adapter.write(tmpPath, json);
if (await adapter.exists(path)) {
await adapter.remove(path);
}
await adapter.rename(tmpPath, path);
} catch (e) {
console.error('ReWrite: atomic secrets write failed, falling back to a direct write', e);
await adapter.write(path, json);
try {
if (await adapter.exists(tmpPath)) await adapter.remove(tmpPath);
} catch {
// best effort
}
}
cachedEnvelope = envelope;
}
@ -640,7 +713,8 @@ export async function loadKey(plugin: Plugin, id: string): Promise<string> {
if (!store) return '';
try {
return (await store.getSecret(nsId(plugin, id))) ?? '';
} catch {
} catch (e) {
warnKeyringFailure(e);
return '';
}
}

View file

@ -1,14 +1,39 @@
import { Plugin } from 'obsidian';
import {
ActiveProfileKind,
ActiveProfileOverride,
EnvironmentProfile,
GlobalSettings,
LLMConfig,
LLMProviderID,
LocalWhisperSettings,
NewFileCollisionMode,
RecordingFormatPreference,
TranscriptionConfig,
TranscriptionProviderID,
} from '../types';
import { loadAllKeys, saveManyKeys } from '../secrets';
// Enum/scalar values `mergeSettings`/`mergeProfile` accept from a stored data.json. A corrupt or
// hand-edited file could carry anything for these fields (spreading a non-object partial field
// over a nested config, e.g., would spread a string's characters into it), so every value read
// from `partial` is checked against this allowlist before use; anything else falls back to base.
const TRANSCRIPTION_PROVIDER_IDS: readonly TranscriptionProviderID[] = [
'none', 'openai', 'openai-compatible', 'groq', 'assemblyai', 'deepgram', 'revai', 'mistral-voxtral', 'whisper-local',
];
const LLM_PROVIDER_IDS: readonly LLMProviderID[] = ['none', 'anthropic', 'openai', 'openai-compatible', 'gemini', 'mistral'];
const ACTIVE_PROFILE_OVERRIDES: readonly ActiveProfileOverride[] = ['auto', 'desktop', 'mobile'];
const RECORDING_FORMATS: readonly RecordingFormatPreference[] = ['webm', 'mp4'];
const NEW_FILE_COLLISION_MODES: readonly NewFileCollisionMode[] = ['auto', 'prompt'];
function pickEnum<T extends string>(valid: readonly T[], value: unknown, fallback: T): T {
return typeof value === 'string' && (valid as readonly string[]).includes(value) ? (value as T) : fallback;
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
const EMPTY_TRANSCRIPTION_CONFIG: TranscriptionConfig = {
apiKey: '',
baseUrl: '',
@ -132,20 +157,29 @@ function profileFor(settings: GlobalSettings, kind: ActiveProfileKind): Environm
return kind === 'desktop' ? settings.desktopProfile : settings.mobileProfile;
}
function mergeSettings(
export function mergeSettings(
base: GlobalSettings,
partial: Partial<GlobalSettings>,
): GlobalSettings {
return {
...base,
...partial,
activeProfileOverride: pickEnum(ACTIVE_PROFILE_OVERRIDES, partial.activeProfileOverride, base.activeProfileOverride),
recordingFormat: pickEnum(RECORDING_FORMATS, partial.recordingFormat, base.recordingFormat),
newFileCollisionMode: pickEnum(NEW_FILE_COLLISION_MODES, partial.newFileCollisionMode, base.newFileCollisionMode),
desktopProfile: mergeProfile(base.desktopProfile, partial.desktopProfile),
mobileProfile: mergeProfile(base.mobileProfile, partial.mobileProfile),
modelCache: {
transcription: { ...base.modelCache.transcription, ...(partial.modelCache?.transcription ?? {}) },
llm: { ...base.modelCache.llm, ...(partial.modelCache?.llm ?? {}) },
transcription: {
...base.modelCache.transcription,
...(isPlainObject(partial.modelCache?.transcription) ? partial.modelCache.transcription : {}),
},
llm: {
...base.modelCache.llm,
...(isPlainObject(partial.modelCache?.llm) ? partial.modelCache.llm : {}),
},
},
localWhisper: { ...base.localWhisper, ...(partial.localWhisper ?? {}) },
localWhisper: { ...base.localWhisper, ...(isPlainObject(partial.localWhisper) ? partial.localWhisper : {}) },
};
}
@ -153,17 +187,19 @@ function mergeProfile(
base: EnvironmentProfile,
partial: Partial<EnvironmentProfile> | undefined,
): EnvironmentProfile {
if (!partial) return base;
if (!isPlainObject(partial)) return base;
return {
...base,
...partial,
transcriptionProvider: pickEnum(TRANSCRIPTION_PROVIDER_IDS, partial.transcriptionProvider, base.transcriptionProvider),
llmProvider: pickEnum(LLM_PROVIDER_IDS, partial.llmProvider, base.llmProvider),
transcriptionConfig: {
...base.transcriptionConfig,
...(partial.transcriptionConfig ?? {}),
...(isPlainObject(partial.transcriptionConfig) ? partial.transcriptionConfig : {}),
},
llmConfig: {
...base.llmConfig,
...(partial.llmConfig ?? {}),
...(isPlainObject(partial.llmConfig) ? partial.llmConfig : {}),
},
};
}

View file

@ -178,8 +178,17 @@ export class ReWriteSettingTab extends PluginSettingTab {
this.renderKnownNouns(containerEl);
}
// Central save chokepoint for every settings field's onChange. Fields call this without
// awaiting or catching (Obsidian's Setting components fire-and-forget the async handler),
// so a save failure (e.g. the OS keyring becoming unavailable mid-session while writing an
// API key) would otherwise be an unhandled rejection with zero user-visible feedback.
private async commit(): Promise<void> {
await this.plugin.saveSettings();
try {
await this.plugin.saveSettings();
} catch (e) {
console.error('ReWrite: failed to save settings', e);
new Notice(`ReWrite: could not save settings — ${e instanceof Error ? e.message : String(e)}`);
}
}
// Builds a section heading with a Lucide icon prepended to its label. Returns the

View file

@ -1,11 +1,24 @@
import { App, normalizePath, parseYaml, stringifyYaml, TFile, TFolder } from 'obsidian';
import { InsertMode, NotePropertySpec, NoteTemplate } from './types';
import { GlobalSettings, InsertMode, NotePropertySpec, NoteTemplate } from './types';
import { freshDefaultTemplates } from './settings/default-templates';
import { allPriorVersions, priorVersionsForId } from './settings/template-history';
import { writeTemplateUpdateReport } from './template-guide';
const VALID_INSERT_MODES: ReadonlySet<string> = new Set(['cursor', 'newFile', 'append']);
// Shared by the main modal (its default selection before a user override) and the plugin's
// entry points that need a template id without opening the modal (editor menu, reprocess-audio).
// Was duplicated verbatim in both call sites.
export function pickDefaultTemplateId(settings: GlobalSettings, templates: NoteTemplate[]): string {
if (settings.lastUsedTemplateId && templates.some((t) => t.id === settings.lastUsedTemplateId)) {
return settings.lastUsedTemplateId;
}
if (settings.defaultTemplateId && templates.some((t) => t.id === settings.defaultTemplateId)) {
return settings.defaultTemplateId;
}
return templates[0]?.id ?? '';
}
export async function loadTemplatesFromFolder(app: App, folderPath: string): Promise<NoteTemplate[]> {
const normalized = normalizeFolderPath(folderPath);
if (!normalized) return [];
@ -104,7 +117,7 @@ export interface UpdateResult {
// kept edit becomes a report conflict; scalars/flags are adopted-or-kept
// silently, and a user property the default dropped is always kept (never
// deleted). Pure and synchronous.
function mergeTemplate(onDisk: NoteTemplate, def: NoteTemplate, priors: NoteTemplate[]): {
export function mergeTemplate(onDisk: NoteTemplate, def: NoteTemplate, priors: NoteTemplate[]): {
merged: NoteTemplate;
conflicts: TemplateUpdateConflict[];
changes: string[];
@ -220,7 +233,10 @@ export async function updateDefaultTemplates(app: App, folderPath: string): Prom
for (const child of folder.children) {
if (!(child instanceof TFile)) continue;
if (child.extension !== 'md') continue;
const id = await readTemplateId(app, child);
// Single read per file, reused for the id check, the full parse, and the diff below
// (previously up to three separate reads of the same file).
const original = await app.vault.read(child);
const id = readTemplateIdFromContent(original);
if (!id) continue;
const def = defaultsById.get(id);
if (!def) continue; // user's own template (id not in the default set)
@ -228,7 +244,7 @@ export async function updateDefaultTemplates(app: App, folderPath: string): Prom
let onDisk: NoteTemplate | null = null;
try {
onDisk = await parseTemplateFile(app, child);
onDisk = parseTemplateContent(child, original);
} catch {
onDisk = null;
}
@ -243,7 +259,6 @@ export async function updateDefaultTemplates(app: App, folderPath: string): Prom
const { merged, conflicts: cf, changes } = mergeTemplate(onDisk, def, priorVersionsForId(id));
const rendered = renderTemplateFile(merged);
const original = await app.vault.read(child);
// Normalize CRLF so we don't rewrite a file purely over line endings.
const changedOnDisk = rendered !== original.replace(/\r\n/g, '\n');
@ -335,6 +350,13 @@ export function isPathInTemplatesFolder(path: string, folderPath: string): boole
async function parseTemplateFile(app: App, file: TFile): Promise<NoteTemplate | null> {
const content = await app.vault.read(file);
return parseTemplateContent(file, content);
}
// Split out from parseTemplateFile so callers that already hold the file content (the update
// walk previously read each candidate file up to three times: once for the id, once for the
// full parse, once more to diff against the rendered output) can parse without a redundant read.
export function parseTemplateContent(file: TFile, content: string): NoteTemplate | null {
const { frontmatter, body } = splitFrontmatter(content);
if (!frontmatter) return null;
const parsed: unknown = parseYaml(frontmatter);
@ -427,6 +449,14 @@ async function collectExistingIds(app: App, folder: TFolder): Promise<Set<string
async function readTemplateId(app: App, file: TFile): Promise<string | null> {
try {
const content = await app.vault.read(file);
return readTemplateIdFromContent(content);
} catch {
return null;
}
}
function readTemplateIdFromContent(content: string): string | null {
try {
const { frontmatter } = splitFrontmatter(content);
if (!frontmatter) return null;
const parsed: unknown = parseYaml(frontmatter);
@ -464,7 +494,7 @@ function splitFrontmatter(content: string): { frontmatter: string | null; body:
return { frontmatter: null, body: content };
}
function renderTemplateFile(template: NoteTemplate): string {
export function renderTemplateFile(template: NoteTemplate): string {
const fm = stringifyYaml({
id: template.id,
name: template.name,
@ -501,9 +531,22 @@ function renderTemplateFile(template: NoteTemplate): string {
return `---\n${fm}\n${disableLine}\n${contextLine}\n${diarizeLine}\n${titleLine}${propsBlock}\n---\n${template.prompt}\n`;
}
// Guards against names that are unsafe/unusable as a file basename: purely dots (would
// produce a hidden Unix file or an OS-trimmed Windows one), or one of the reserved Windows
// device names (case-insensitive; Windows treats these as special regardless of extension,
// e.g. "CON.md" is still the reserved device). Intended as the LAST step of a sanitization
// pipeline: an intermediate transform (dot-stripping, length capping) can turn a name that
// wasn't reserved into one that is, so callers with extra hardening beyond sanitizeFilename
// (see insert.ts's titleToFilename) should call this again on their own final result.
export function guardReservedName(name: string): string {
if (/^\.*$/.test(name)) return 'Untitled';
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(name)) return `_${name}`;
return name;
}
export function sanitizeFilename(name: string): string {
const cleaned = name.replace(/[\\/:*?"<>|]/g, '-').trim();
return cleaned || 'Untitled';
return guardReservedName(cleaned || 'Untitled');
}
function normalizeFolderPath(folderPath: string): string {

View file

@ -1,10 +1,21 @@
import { TranscriptionConfig } from '../types';
import { jsonGet, jsonPost, providerRequest, sleep } from '../http';
import { jsonGet, jsonPost, ProviderError, providerRequest, sleep } from '../http';
import { TranscriptionProvider } from './index';
import { pollTimeoutMs } from './limits';
const INITIAL_DELAY_MS = 1000;
const MAX_DELAY_MS = 8000;
// A single transient poll failure (network blip, 5xx) must not fail a long-running
// job that would otherwise have completed on the server. Tolerate this many
// consecutive transient failures before giving up; a 4xx or AbortError still
// rethrows immediately since retrying cannot fix those.
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
function isTransientPollError(e: unknown): boolean {
if (e instanceof DOMException && e.name === 'AbortError') return false;
if (e instanceof ProviderError) return e.status === 0 || e.status >= 500;
return true;
}
interface UploadResponse {
upload_url?: string;
@ -89,17 +100,28 @@ async function pollAssemblyAI(
): Promise<string> {
const start = Date.now();
let delay = INITIAL_DELAY_MS;
let consecutiveErrors = 0;
for (;;) {
const elapsed = Date.now() - start;
if (elapsed > timeoutMs) {
throw new Error(`assemblyai: poll timeout after ${Math.round(timeoutMs / 1000)}s`);
}
const status = await jsonGet<TranscriptStatusResponse>(
'assemblyai',
`https://api.assemblyai.com/v2/transcript/${id}`,
headers,
signal,
);
let status: TranscriptStatusResponse;
try {
status = await jsonGet<TranscriptStatusResponse>(
'assemblyai',
`https://api.assemblyai.com/v2/transcript/${id}`,
headers,
signal,
);
consecutiveErrors = 0;
} catch (e) {
consecutiveErrors++;
if (!isTransientPollError(e) || consecutiveErrors >= MAX_CONSECUTIVE_POLL_ERRORS) throw e;
await sleep(delay, signal);
delay = Math.min(delay * 2, MAX_DELAY_MS);
continue;
}
if (status.status === 'completed') {
if (diarize && Array.isArray(status.utterances) && status.utterances.length > 0) {
return formatUtterances(status.utterances);

View file

@ -34,8 +34,9 @@ export function createMistralVoxtralTranscription(): TranscriptionProvider {
if (!config.model) throw new Error('mistral-voxtral: model is not configured');
let wavBuffer: ArrayBuffer;
try {
wavBuffer = await transcodeToWavPcm(audio);
wavBuffer = await transcodeToWavPcm(audio, undefined, signal);
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') throw e;
const msg = e instanceof Error ? e.message : String(e);
throw new ProviderError('mistral-voxtral', 0, '', `Failed to transcode audio to WAV for Voxtral: ${msg}`);
}

View file

@ -1,10 +1,21 @@
import { TranscriptionConfig } from '../types';
import { jsonGet, MultipartPart, multipartPost, providerRequest, sleep } from '../http';
import { jsonGet, MultipartPart, multipartPost, ProviderError, providerRequest, sleep } from '../http';
import { audioFilename, TranscriptionProvider } from './index';
import { pollTimeoutMs } from './limits';
const INITIAL_DELAY_MS = 1000;
const MAX_DELAY_MS = 8000;
// A single transient poll failure (network blip, 5xx) must not fail a long-running
// job that would otherwise have completed on the server. Tolerate this many
// consecutive transient failures before giving up; a 4xx or AbortError still
// rethrows immediately since retrying cannot fix those.
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
function isTransientPollError(e: unknown): boolean {
if (e instanceof DOMException && e.name === 'AbortError') return false;
if (e instanceof ProviderError) return e.status === 0 || e.status >= 500;
return true;
}
interface JobCreateResponse {
id?: string;
@ -121,17 +132,28 @@ async function pollRevAI(
): Promise<void> {
const start = Date.now();
let delay = INITIAL_DELAY_MS;
let consecutiveErrors = 0;
for (;;) {
const elapsed = Date.now() - start;
if (elapsed > timeoutMs) {
throw new Error(`revai: poll timeout after ${Math.round(timeoutMs / 1000)}s`);
}
const status = await jsonGet<JobStatusResponse>(
'revai',
`https://api.rev.ai/speechtotext/v1/jobs/${id}`,
headers,
signal,
);
let status: JobStatusResponse;
try {
status = await jsonGet<JobStatusResponse>(
'revai',
`https://api.rev.ai/speechtotext/v1/jobs/${id}`,
headers,
signal,
);
consecutiveErrors = 0;
} catch (e) {
consecutiveErrors++;
if (!isTransientPollError(e) || consecutiveErrors >= MAX_CONSECUTIVE_POLL_ERRORS) throw e;
await sleep(delay, signal);
delay = Math.min(delay * 2, MAX_DELAY_MS);
continue;
}
if (status.status === 'transcribed') return;
if (status.status === 'failed') {
throw new Error(`revai: ${status.failure_detail ?? status.failure ?? 'transcription failed'}`);

View file

@ -28,8 +28,9 @@ export function createWhisperLocalTranscription(): TranscriptionProvider {
}
let wavBuffer: ArrayBuffer;
try {
wavBuffer = await transcodeToWavPcm(audio);
wavBuffer = await transcodeToWavPcm(audio, undefined, signal);
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') throw e;
const msg = e instanceof Error ? e.message : String(e);
throw new ProviderError('whisper-local', 0, '', `Failed to transcode audio to WAV for whisper.cpp: ${msg}`);
}

View file

@ -2,10 +2,10 @@ import { App, Notice, TAbstractFile, TFile } from 'obsidian';
import type ReWritePlugin from '../main';
import { NoteTemplate } from '../types';
import { resolveActiveProfile } from '../platform';
import { runPipeline } from '../pipeline';
import { AUDIO_EXTENSIONS, extensionToMime } from '../audio-persist';
import { isProfileConfigured } from './setup-card';
import { ReWriteModal } from './modal';
import { runBackgroundPipeline } from './pipeline-progress';
const AUDIO_EXT_SET: ReadonlySet<string> = new Set<string>(AUDIO_EXTENSIONS);
@ -45,10 +45,17 @@ export async function runAudioFilePipeline(
new ReWriteModal(plugin.app, plugin).open();
return;
}
const progress = new Notice('ReWrite: reprocessing audio...', 0);
let blob: Blob;
try {
const blob = await readAudioFileAsBlob(plugin.app, file);
await runPipeline({
blob = await readAudioFileAsBlob(plugin.app, file);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
new Notice(`ReWrite: ${message}`);
return;
}
await runBackgroundPipeline(
plugin,
{
app: plugin.app,
settings,
host: plugin,
@ -56,14 +63,7 @@ export async function runAudioFilePipeline(
template,
source: { kind: 'audio', audio: blob, sourcePath: file.path },
contextHint: contextHint?.trim() || undefined,
});
progress.hide();
plugin.settings.lastUsedTemplateId = template.id;
await plugin.saveSettings();
new Notice('ReWrite complete.');
} catch (e) {
progress.hide();
const message = e instanceof Error ? e.message : String(e);
new Notice(`ReWrite: ${message}`);
}
},
{ startMessage: 'ReWrite: reprocessing audio...', templateId: template.id },
);
}

View file

@ -1,11 +1,13 @@
import { App, Modal, Notice, Platform } from 'obsidian';
import type ReWritePlugin from '../main';
import { runPipeline, PipelineSource, PipelineStage } from '../pipeline';
import { runPipeline, PipelineSource } from '../pipeline';
import { isMediaRecorderAvailable, resolveActiveProfile } from '../platform';
import { Recorder } from '../recorder';
import { DestinationOverride, EnvironmentProfile, InsertMode, NoteTemplate } from '../types';
import { isProfileConfigured, isProfileConfiguredForText, renderSetupCard } from './setup-card';
import { resolveActiveTextSource } from './text-source';
import { formatDuration, runBackgroundPipeline, stageLabel } from './pipeline-progress';
import { pickDefaultTemplateId } from '../templates-folder';
// Continuous silence (ms) before the Record UI warns about a muted / dead mic.
const SILENCE_WARNING_MS = 3000;
@ -17,6 +19,13 @@ export class ReWriteModal extends Modal {
private recorder: Recorder | null = null;
private timerHandle: number | null = null;
private running = false;
// Hoisted from the old renderRecordTab closure local: a mid-recording render() (tab
// switch, template change, destination edit) used to rebuild the record tab with a
// fresh "Record" button while the old recorder/timer kept running with no way to stop
// them. Tab bar / template select / destination controls are now disabled while this
// (or `running`) is true, and the recording survives re-renders since it lives on the
// instance rather than a tab-local closure.
private isRecording = false;
private currentSource: PipelineSource | null = null;
private destinationOverride: DestinationOverride | null = null;
private destinationExpanded = false;
@ -29,7 +38,7 @@ export class ReWriteModal extends Modal {
initialTemplateId?: string,
) {
super(app);
this.templateId = initialTemplateId ?? this.pickDefaultTemplateId();
this.templateId = initialTemplateId ?? pickDefaultTemplateId(this.plugin.settings, this.plugin.templates);
}
onOpen(): void {
@ -42,16 +51,11 @@ export class ReWriteModal extends Modal {
this.contentEl.empty();
}
private pickDefaultTemplateId(): string {
const s = this.plugin.settings;
const templates = this.plugin.templates;
if (s.lastUsedTemplateId && templates.some((t) => t.id === s.lastUsedTemplateId)) {
return s.lastUsedTemplateId;
}
if (s.defaultTemplateId && templates.some((t) => t.id === s.defaultTemplateId)) {
return s.defaultTemplateId;
}
return templates[0]?.id ?? '';
// While a recording is in progress or a pipeline is running, template/destination/tab
// controls that would call render() must be disabled: a mid-flight render() would
// rebuild the tab body and orphan the recorder or the in-flight execute() progress UI.
private isLocked(): boolean {
return this.isRecording || this.running;
}
private render(): void {
@ -165,7 +169,9 @@ export class ReWriteModal extends Modal {
opt.value = t.id;
if (t.id === this.templateId) opt.selected = true;
}
select.disabled = this.isLocked();
select.addEventListener('change', () => {
if (this.isLocked()) return;
this.templateId = select.value;
this.destinationOverride = null;
this.destinationExpanded = false;
@ -209,7 +215,10 @@ export class ReWriteModal extends Modal {
opt.value = m.id;
if (m.id === effectiveMode) opt.selected = true;
}
const locked = this.isLocked();
select.disabled = locked;
select.addEventListener('change', () => {
if (this.isLocked()) return;
this.setDestinationOverride({ insertMode: select.value as InsertMode });
this.destinationExpanded = true;
this.render();
@ -220,6 +229,7 @@ export class ReWriteModal extends Modal {
const folderInput = folderLabel.createEl('input', { type: 'text' });
folderInput.value = effectiveFolder;
folderInput.placeholder = '(vault root)';
folderInput.disabled = locked;
folderInput.addEventListener('change', () => {
this.setDestinationOverride({ newFileFolder: folderInput.value });
});
@ -228,6 +238,7 @@ export class ReWriteModal extends Modal {
const nameInput = nameLabel.createEl('input', { type: 'text' });
nameInput.value = effectiveName;
nameInput.placeholder = 'ReWrite {{date}} {{time}}';
nameInput.disabled = locked;
nameInput.addEventListener('change', () => {
this.setDestinationOverride({ newFileNameTemplate: nameInput.value });
});
@ -235,7 +246,9 @@ export class ReWriteModal extends Modal {
if (this.destinationOverride) {
const reset = body.createEl('button', { text: 'Reset to template default', cls: 'rewrite-destination-reset' });
reset.disabled = locked;
reset.addEventListener('click', () => {
if (this.isLocked()) return;
this.destinationOverride = null;
this.destinationExpanded = false;
this.render();
@ -297,15 +310,22 @@ export class ReWriteModal extends Modal {
if (this.activeTab === 'record') record.addClass('is-active');
else if (this.activeTab === 'paste') paste.addClass('is-active');
else fromNote.addClass('is-active');
const locked = this.isLocked();
record.disabled = locked;
paste.disabled = locked;
fromNote.disabled = locked;
record.addEventListener('click', () => {
if (this.isLocked()) return;
this.activeTab = 'record';
this.render();
});
paste.addEventListener('click', () => {
if (this.isLocked()) return;
this.activeTab = 'paste';
this.render();
});
fromNote.addEventListener('click', () => {
if (this.isLocked()) return;
this.activeTab = 'fromNote';
this.render();
});
@ -333,17 +353,23 @@ export class ReWriteModal extends Modal {
});
warning.hide();
let isRecording = false;
// isRecording is hoisted onto the instance (this.isRecording) rather than kept as a tab-local
// closure variable so it survives a render() the instant one is disallowed to fire: tab bar,
// template select, and destination controls are disabled via isLocked() for as long as it (or
// this.running) is true, so this record button is the only interactive control left.
const handleClick = async (): Promise<void> => {
// Only guard on a pipeline being in flight, not on isRecording: this button is what
// toggles isRecording, so gating on isLocked() here would make Stop unreachable the
// moment recording starts (isLocked() is true precisely because isRecording is true).
if (this.running) return;
if (!isRecording) {
if (!this.isRecording) {
try {
await this.beginCapture();
} catch (e) {
new Notice(e instanceof Error ? e.message : String(e));
return;
}
isRecording = true;
this.isRecording = true;
button.setText('Stop');
dot.show();
this.startTimerLoop(timer, warning);
@ -351,7 +377,7 @@ export class ReWriteModal extends Modal {
button.disabled = true;
try {
const source = await this.endCapture();
isRecording = false;
this.isRecording = false;
button.setText('Record');
dot.hide();
warning.hide();
@ -359,7 +385,7 @@ export class ReWriteModal extends Modal {
this.startRecordingPipeline(source);
} catch (e) {
new Notice(e instanceof Error ? e.message : String(e));
isRecording = false;
this.isRecording = false;
button.setText('Record');
dot.hide();
warning.hide();
@ -380,12 +406,14 @@ export class ReWriteModal extends Modal {
textarea.rows = Platform.isMobile ? 4 : 10;
const button = parent.createEl('button', { text: 'Clean up', cls: 'mod-cta' });
button.addEventListener('click', () => {
if (this.running) return;
const text = textarea.value.trim();
if (!text) {
new Notice('Paste some text first.');
return;
}
void this.execute({ kind: 'paste', text });
button.disabled = true;
void this.execute({ kind: 'paste', text }).finally(() => { button.disabled = false; });
});
textarea.focus();
}
@ -404,6 +432,7 @@ export class ReWriteModal extends Modal {
const button = parent.createEl('button', { text: 'Run', cls: 'mod-cta' });
if (!source) button.disabled = true;
button.addEventListener('click', () => {
if (this.running) return;
const fresh = resolveActiveTextSource(this.app);
if (!fresh) {
new Notice('Open a Markdown note or select text first.');
@ -414,7 +443,8 @@ export class ReWriteModal extends Modal {
new Notice('Source text is empty.');
return;
}
void this.execute({ kind: 'text', text: fresh.text });
button.disabled = true;
void this.execute({ kind: 'text', text: fresh.text }).finally(() => { button.disabled = false; });
});
}
@ -472,32 +502,24 @@ export class ReWriteModal extends Modal {
const app = this.app;
this.close();
const progress = new Notice('ReWrite: working...', 0);
void (async () => {
try {
await runPipeline({
app,
settings: plugin.settings,
host: plugin,
profile,
template,
source,
destinationOverride,
contextHint,
onStage: (stage) => progress.setMessage(`ReWrite: ${stageLabel(stage)}`),
});
progress.hide();
plugin.settings.lastUsedTemplateId = template.id;
await plugin.saveSettings();
new Notice('ReWrite complete.');
} catch (e) {
progress.hide();
new Notice(`ReWrite: ${e instanceof Error ? e.message : String(e)}`);
}
})();
void runBackgroundPipeline(
plugin,
{
app,
settings: plugin.settings,
host: plugin,
profile,
template,
source,
destinationOverride,
contextHint,
},
{ startMessage: 'ReWrite: working...', templateId: template.id },
);
}
private async execute(source: PipelineSource): Promise<void> {
if (this.running) return;
const template = this.plugin.templates.find((t) => t.id === this.templateId);
if (!template) {
new Notice('Please pick a template.');
@ -556,26 +578,6 @@ export class ReWriteModal extends Modal {
}
}
function stageLabel(stage: PipelineStage): string {
switch (stage) {
case 'persist-audio':
return 'Saving audio...';
case 'transcribe':
return 'Transcribing...';
case 'cleanup':
return 'Cleaning up...';
case 'insert':
return 'Inserting...';
}
}
function formatDuration(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function describeDestination(mode: InsertMode, folder: string, nameTemplate: string): string {
switch (mode) {
case 'cursor':

View file

@ -0,0 +1,69 @@
import { Notice } from 'obsidian';
import type ReWritePlugin from '../main';
import { PipelineParams, PipelineStage, runPipeline } from '../pipeline';
// Notice.noticeEl is the element to append a Cancel button into, but the installed typings mark
// it deprecated in favor of messageEl/containerEl, which only exist since Obsidian 1.8.7 (newer
// than this plugin's minAppVersion 1.4.4). Reached through a narrow cast, mirroring the
// hotkeyManager/secretStorage pattern elsewhere in this codebase for API surfaces the public
// typings don't cleanly expose across the supported version range.
interface NoticeElementLike {
noticeEl: HTMLElement;
}
function noticeContainer(notice: Notice): HTMLElement {
return (notice as unknown as NoticeElementLike).noticeEl;
}
export function stageLabel(stage: PipelineStage): string {
switch (stage) {
case 'persist-audio':
return 'Saving audio...';
case 'transcribe':
return 'Transcribing...';
case 'cleanup':
return 'Cleaning up...';
case 'insert':
return 'Inserting...';
}
}
export function formatDuration(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
// Runs a pipeline in the background behind a sticky Notice that shows live stage progress and a
// Cancel button. Obsidian's requestUrl has no AbortSignal, so cancel only takes effect between
// network calls, not mid-transfer; it still stops a pipeline that would otherwise sit at a hung
// stage with no way to end it short of reloading the plugin. On success, saves lastUsedTemplateId.
// On error (including a user cancel, which surfaces as an AbortError), shows the failure message
// and calls the optional onError hook so a caller can react further (e.g. Quick Record reopening
// the main modal on a captured-audio failure).
export async function runBackgroundPipeline(
plugin: ReWritePlugin,
params: Omit<PipelineParams, 'onStage' | 'signal'>,
opts: { startMessage: string; templateId: string; onError?: (message: string) => void },
): Promise<void> {
const controller = new AbortController();
const progress = new Notice(opts.startMessage, 0);
const cancelBtn = noticeContainer(progress).createEl('button', { text: 'Cancel', cls: 'rewrite-notice-cancel' });
cancelBtn.addEventListener('click', () => controller.abort());
try {
await runPipeline({
...params,
signal: controller.signal,
onStage: (stage) => progress.setMessage(`ReWrite: ${stageLabel(stage)}`),
});
progress.hide();
plugin.settings.lastUsedTemplateId = opts.templateId;
await plugin.saveSettings();
new Notice('ReWrite complete.');
} catch (e) {
progress.hide();
const message = e instanceof Error ? e.message : String(e);
new Notice(`ReWrite: ${message}`);
opts.onError?.(message);
}
}

View file

@ -1,11 +1,12 @@
import { App, Notice, Platform } from 'obsidian';
import type ReWritePlugin from '../main';
import { PipelineSource, PipelineStage, runPipeline } from '../pipeline';
import { PipelineSource, runPipeline } from '../pipeline';
import { isMediaRecorderAvailable, resolveActiveProfile } from '../platform';
import { Recorder } from '../recorder';
import { NoteTemplate } from '../types';
import { isProfileConfigured } from './setup-card';
import { ReWriteModal } from './modal';
import { formatDuration, stageLabel } from './pipeline-progress';
// Continuous silence (ms) before the Quick Record floater warns about a muted / dead mic.
const SILENCE_WARNING_MS = 3000;
@ -17,6 +18,10 @@ export class QuickRecordController {
private floater: QuickRecordFloater | null = null;
private settled = false;
private template: NoteTemplate;
// Wired into runPipeline's signal during finish() so the floater's Cancel button keeps
// working after recording stops, instead of becoming a no-op once processing starts (a
// hung transcription/LLM call previously had no way to be stopped short of reloading).
private controller: AbortController | null = null;
constructor(
private readonly plugin: ReWritePlugin,
@ -35,6 +40,7 @@ export class QuickRecordController {
void this.finish();
},
onCancel: () => this.cancel(),
onAbortProcessing: () => this.controller?.abort(),
getTemplates: () => this.plugin.templates,
getActiveTemplateId: () => this.template.id,
onPickTemplate: (t) => {
@ -68,6 +74,7 @@ export class QuickRecordController {
this.settled = true;
this.stopTimer();
this.floater?.setBusy('Processing...');
this.controller = new AbortController();
try {
const source = await this.endCapture();
const { profile } = resolveActiveProfile(this.plugin.settings);
@ -78,6 +85,7 @@ export class QuickRecordController {
profile,
template: this.template,
source,
signal: this.controller.signal,
onStage: (stage) => this.floater?.setBusy(stageLabel(stage)),
});
this.plugin.settings.lastUsedTemplateId = this.template.id;
@ -207,6 +215,9 @@ function formatCommandHotkey(app: App, commandId: string): string | null {
interface QuickRecordFloaterOptions {
onStop: () => void;
onCancel: () => void;
// Called instead of onCancel when Cancel is clicked while busy (processing), aborting the
// in-flight pipeline rather than being a no-op.
onAbortProcessing: () => void;
getTemplates: () => NoteTemplate[];
getActiveTemplateId: () => string;
onPickTemplate: (t: NoteTemplate) => void;
@ -268,7 +279,10 @@ class QuickRecordFloater {
cls: 'rewrite-quick-cancel',
});
cancelBtn.addEventListener('click', () => {
if (this.busy) return;
if (this.busy) {
options.onAbortProcessing();
return;
}
options.onCancel();
});
@ -322,17 +336,25 @@ class QuickRecordFloater {
if (templates.length === 0) return;
const activeId = this.options.getActiveTemplateId();
const popover = this.el.createDiv({ cls: 'rewrite-quick-popover' });
popover.setAttribute('role', 'menu');
const items: HTMLButtonElement[] = [];
let activeItem: HTMLButtonElement | null = null;
for (const t of templates) {
const item = popover.createEl('button', {
cls: 'rewrite-quick-popover-item',
text: t.name,
});
if (t.id === activeId) item.addClass('is-active');
item.setAttribute('role', 'menuitem');
if (t.id === activeId) {
item.addClass('is-active');
activeItem = item;
}
item.addEventListener('click', (e) => {
e.stopPropagation();
this.options.onPickTemplate(t);
this.closePopover();
});
items.push(item);
}
this.popover = popover;
@ -343,16 +365,33 @@ class QuickRecordFloater {
this.closePopover();
};
this.keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') this.closePopover();
if (e.key === 'Escape') {
this.closePopover();
return;
}
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
const doc = this.popoverDoc;
const idx = doc ? items.indexOf(doc.activeElement as HTMLButtonElement) : -1;
if (idx === -1) return;
e.preventDefault();
const next = e.key === 'ArrowDown'
? items[(idx + 1) % items.length]
: items[(idx - 1 + items.length) % items.length];
next?.focus();
};
const doc = activeDocument;
this.popoverDoc = doc;
doc.addEventListener('click', this.outsideClickHandler, true);
doc.addEventListener('keydown', this.keyHandler, true);
// Move focus into the list so a keyboard user doesn't have to Tab through the whole
// floater to reach it; land on the active template when there is one.
(activeItem ?? items[0])?.focus();
}
private closePopover(): void {
const doc = this.popoverDoc ?? activeDocument;
const hadPopover = this.popover !== null;
if (this.outsideClickHandler) {
doc.removeEventListener('click', this.outsideClickHandler, true);
this.outsideClickHandler = null;
@ -364,25 +403,10 @@ class QuickRecordFloater {
this.popoverDoc = null;
this.popover?.remove();
this.popover = null;
// Return focus to the trigger so a keyboard user isn't dropped onto the document body.
// Guarded on the floater still being attached (dispose() also routes through here while
// tearing the whole floater down, in which case there's nothing sensible to focus).
if (hadPopover && this.el.isConnected) this.templateBtn.focus();
}
}
function stageLabel(stage: PipelineStage): string {
switch (stage) {
case 'persist-audio':
return 'Saving audio...';
case 'transcribe':
return 'Transcribing...';
case 'cleanup':
return 'Cleaning up...';
case 'insert':
return 'Inserting...';
}
}
function formatDuration(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}

View file

@ -2,9 +2,9 @@ import { App, Editor, MarkdownView, Notice } from 'obsidian';
import type ReWritePlugin from '../main';
import { NoteTemplate } from '../types';
import { resolveActiveProfile } from '../platform';
import { runPipeline } from '../pipeline';
import { isProfileConfiguredForText } from './setup-card';
import { ReWriteModal } from './modal';
import { runBackgroundPipeline } from './pipeline-progress';
export interface TextResolution {
text: string;
@ -40,23 +40,16 @@ export async function runTextPipeline(
new ReWriteModal(plugin.app, plugin).open();
return;
}
const progress = new Notice('ReWrite: processing text...', 0);
try {
await runPipeline({
await runBackgroundPipeline(
plugin,
{
app: plugin.app,
settings,
host: plugin,
profile,
template,
source: { kind: 'text', text },
});
progress.hide();
plugin.settings.lastUsedTemplateId = template.id;
await plugin.saveSettings();
new Notice('ReWrite complete.');
} catch (e) {
progress.hide();
const message = e instanceof Error ? e.message : String(e);
new Notice(`ReWrite: ${message}`);
}
},
{ startMessage: 'ReWrite: processing text...', templateId: template.id },
);
}

View file

@ -188,7 +188,7 @@ export class WhisperHost {
if (!api.fs.existsSync(config.modelPath)) {
throw new Error(`Model not found: ${config.modelPath}`);
}
const port = Number.isFinite(config.port) && config.port > 0 ? config.port : 8080;
const port = resolvePort(config);
// Discover any existing server on the configured port before spawning.
const probed = await this.probe(config);
if (probed.status === 'running') {
@ -212,14 +212,15 @@ export class WhisperHost {
// silently expose an open transcription service to the LAN, and pin
// 127.0.0.1 ourselves when the user did not specify a host (so we don't
// rely on upstream's default binding staying loopback).
const hostArg = getHostArg(extra);
if (hostArg !== null && !isLoopbackHost(hostArg)) {
throw new Error(`Refusing to start: --host ${hostArg || '(empty)'} would bind whisper-server to a non-loopback interface, exposing an unauthenticated transcription server to your network. Remove it from Extra args; ReWrite always binds 127.0.0.1.`);
const hostArgs = getHostArgs(extra);
const badHost = hostArgs.find((h) => !isLoopbackHost(h));
if (badHost !== undefined) {
throw new Error(`Refusing to start: --host ${badHost || '(empty)'} would bind whisper-server to a non-loopback interface, exposing an unauthenticated transcription server to your network. Remove it from Extra args; ReWrite always binds 127.0.0.1.`);
}
const args = [
'-m', config.modelPath,
'--port', String(port),
...(hostArg === null ? ['--host', '127.0.0.1'] : []),
...(hostArgs.length === 0 ? ['--host', '127.0.0.1'] : []),
...extra,
];
const child = api.cp.spawn(config.binaryPath, args, {
@ -299,24 +300,40 @@ export class WhisperHost {
await new Promise<void>((resolve) => {
let settled = false;
const killTimer = window.setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* best effort */ }
finish();
}, STOP_KILL_GRACE_MS);
const finish = (): void => {
if (settled) return;
settled = true;
window.clearTimeout(killTimer);
resolve();
};
child.once('exit', finish);
try { child.kill(); } catch { /* best effort */ }
window.setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* best effort */ }
finish();
}, STOP_KILL_GRACE_MS);
});
await this.clearPidFile();
return;
}
// Adopted (no live child handle) or stopped: kill via PID if we have one.
// Adopted (no live child handle) or stopped: kill via PID if we have one. The sidecar's
// PID could have been recycled by the OS for an unrelated process since we adopted it
// (e.g. after an OS crash), so re-verify the port is still bound to *something* right
// before killing by PID: if it isn't, whatever we adopted is already gone, and sending a
// signal to the stale PID would risk hitting a process that has nothing to do with it.
// This narrows, but does not eliminate, the PID-reuse window (a full port-plus-PID
// collision is far less likely than a bare PID collision).
const api = getNodeApi();
const pid = this.currentPid;
const port = this.currentPort;
if (pid !== null && api && port !== null && !(await isPortReachable(api.net, port))) {
this.statusValue = 'stopped';
this.currentPort = null;
this.currentPid = null;
this.ownershipValue = null;
await this.clearPidFile();
return;
}
if (pid !== null && api) {
this.stoppingDeliberately = true;
this.statusValue = 'stopped';
@ -349,7 +366,7 @@ export class WhisperHost {
if (!api) return this.snapshot();
// If we already own a spawned child, leave state alone.
if (this.child && this.statusValue === 'running') return this.snapshot();
const port = Number.isFinite(config.port) && config.port > 0 ? config.port : 8080;
const port = resolvePort(config);
const reachable = await isPortReachable(api.net, port);
if (!reachable) {
// Nothing bound. Clear any stale sidecar and reset to stopped if we
@ -430,6 +447,12 @@ export class WhisperHost {
}
}
// A port above 65535 previously fell through to the ready-timeout path with a confusing "did not
// become ready" error rather than a clear validation message.
function resolvePort(config: LocalWhisperSettings): number {
return Number.isFinite(config.port) && config.port > 0 && config.port <= 65535 ? config.port : 8080;
}
function isPidAlive(proc: ProcessAPI, pid: number): boolean {
try {
proc.kill(pid, 0);
@ -445,25 +468,29 @@ function splitArgs(s: string): string[] {
return trimmed.split(/\s+/);
}
// Find the value of a --host argument in an already-tokenized arg list.
// Supports both `--host 127.0.0.1` and `--host=127.0.0.1`. Returns the value
// (possibly empty string) when present, or null when no --host is given.
function getHostArg(args: string[]): string | null {
// Find the values of ALL --host arguments in an already-tokenized arg list.
// Supports both `--host 127.0.0.1` and `--host=127.0.0.1`. Every occurrence
// must be collected, not just the first: whisper-server (like most CLI
// parsers) honors the LAST --host, so checking only the first would let
// `--host 127.0.0.1 --host 0.0.0.0` slip past the loopback guard. Values may
// be empty strings. Exported for tests.
export function getHostArgs(args: string[]): string[] {
const hosts: string[] = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--host') {
return args[i + 1] ?? '';
}
if (a !== undefined && a.startsWith('--host=')) {
return a.slice('--host='.length);
hosts.push(args[i + 1] ?? '');
} else if (a !== undefined && a.startsWith('--host=')) {
hosts.push(a.slice('--host='.length));
}
}
return null;
return hosts;
}
// Whether a host value binds only the loopback interface. Anything else
// (0.0.0.0, a LAN IP, a hostname) would expose the unauthenticated server.
function isLoopbackHost(host: string): boolean {
// Exported for tests.
export function isLoopbackHost(host: string): boolean {
const h = host.trim().toLowerCase();
return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '[::1]';
}

View file

@ -1,5 +1,10 @@
/* ReWrite plugin styles */
.rewrite-notice-cancel {
display: block;
margin-top: 6px;
}
.rewrite-modal .rewrite-template-row {
display: flex;
flex-direction: column;
@ -126,15 +131,6 @@
font-size: var(--font-ui-small);
}
.rewrite-modal .rewrite-live-transcript {
min-height: 60px;
padding: 8px;
margin-top: 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
white-space: pre-wrap;
}
.rewrite-modal .rewrite-paste {
width: 100%;
min-height: 160px;

224
test/local-review.test.ts Normal file
View file

@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest';
import {
buildDiffArgs,
buildReviewMessages,
formatReport,
getHostArgs,
isLoopbackHost,
parseCliArgs,
parseLocalReviewConfig,
scopeLabel,
splitArgs,
truncateDiff,
} from '../local-review.mjs';
// The loopback guard is duplicated from src/whisper-host.ts on purpose (see the comment in
// local-review.mjs); mirror test/whisper-host.test.ts so the copy keeps the same coverage.
describe('getHostArgs', () => {
it('returns an empty array when no --host is present', () => {
expect(getHostArgs(['-m', 'model.gguf', '--port', '8090'])).toEqual([]);
});
it('collects a single --host value (space form)', () => {
expect(getHostArgs(['--host', '127.0.0.1'])).toEqual(['127.0.0.1']);
});
it('collects a single --host value (= form)', () => {
expect(getHostArgs(['--host=127.0.0.1'])).toEqual(['127.0.0.1']);
});
it('collects every --host occurrence, not just the first', () => {
expect(getHostArgs(['--host', '127.0.0.1', '--host', '0.0.0.0'])).toEqual(['127.0.0.1', '0.0.0.0']);
});
it('treats a --host with no following value as empty string', () => {
expect(getHostArgs(['--host'])).toEqual(['']);
});
});
describe('isLoopbackHost', () => {
it.each([
['127.0.0.1', true],
['localhost', true],
['LOCALHOST', true],
['::1', true],
['[::1]', true],
[' 127.0.0.1 ', true],
['0.0.0.0', false],
['192.168.1.5', false],
['example.com', false],
['', false],
])('isLoopbackHost(%s) === %s', (host, expected) => {
expect(isLoopbackHost(host)).toBe(expected);
});
});
describe('splitArgs', () => {
it('returns an empty array for empty/whitespace input', () => {
expect(splitArgs('')).toEqual([]);
expect(splitArgs(' ')).toEqual([]);
});
it('splits on runs of whitespace', () => {
expect(splitArgs('--ctx-size 4096 --threads 8')).toEqual(['--ctx-size', '4096', '--threads', '8']);
});
});
describe('parseCliArgs', () => {
it('defaults to no flags', () => {
expect(parseCliArgs([])).toEqual({ base: null, staged: false, full: false });
});
it('reads --base with a following value', () => {
expect(parseCliArgs(['--base', 'main'])).toEqual({ base: 'main', staged: false, full: false });
});
it('reads --base=value form', () => {
expect(parseCliArgs(['--base=develop'])).toEqual({ base: 'develop', staged: false, full: false });
});
it('reads --staged and --full', () => {
expect(parseCliArgs(['--staged'])).toMatchObject({ staged: true });
expect(parseCliArgs(['--full'])).toMatchObject({ full: true });
});
});
describe('buildDiffArgs', () => {
it('diffs the merge-base against the working tree by default', () => {
expect(buildDiffArgs({ staged: false, full: false }, 'abc123')).toEqual(['diff', 'abc123']);
});
it('narrows to the index with --staged', () => {
expect(buildDiffArgs({ staged: true, full: false }, 'abc123')).toEqual(['diff', '--staged']);
});
it('narrows to the last commit with --full', () => {
expect(buildDiffArgs({ staged: false, full: true }, 'abc123')).toEqual(['diff', 'HEAD~1', 'HEAD']);
});
it('adds --stat when requested', () => {
expect(buildDiffArgs({ staged: false, full: false }, 'abc123', { stat: true })).toEqual(['diff', '--stat', 'abc123']);
});
});
describe('scopeLabel', () => {
it('describes each scope distinctly', () => {
const def = scopeLabel({ staged: false, full: false });
const staged = scopeLabel({ staged: true, full: false });
const full = scopeLabel({ staged: false, full: true });
expect(new Set([def, staged, full]).size).toBe(3);
expect(staged).toMatch(/staged/i);
expect(full).toMatch(/commit/i);
});
});
describe('truncateDiff', () => {
it('leaves a short diff untouched', () => {
expect(truncateDiff('abc', 60000)).toEqual({ text: 'abc', truncated: false });
});
it('truncates and marks an over-long diff', () => {
const long = 'x'.repeat(200);
const out = truncateDiff(long, 50);
expect(out.truncated).toBe(true);
expect(out.text.startsWith('x'.repeat(50))).toBe(true);
expect(out.text).toMatch(/truncated at 50 characters/);
});
});
describe('parseLocalReviewConfig', () => {
const ok = { localReview: { binaryPath: '/bin/llama-server', modelPath: '/models/ornith.gguf' } };
const yes = () => true;
it('throws when the top-level value is not an object', () => {
expect(() => parseLocalReviewConfig(null, yes)).toThrow(/JSON object/);
});
it('throws when localReview is missing', () => {
expect(() => parseLocalReviewConfig({}, yes)).toThrow(/localReview/);
});
it('throws when binaryPath is empty', () => {
expect(() => parseLocalReviewConfig({ localReview: { modelPath: '/m' } }, yes)).toThrow(/binaryPath/);
});
it('throws when modelPath is empty', () => {
expect(() => parseLocalReviewConfig({ localReview: { binaryPath: '/b' } }, yes)).toThrow(/modelPath/);
});
it('throws when a configured path does not exist', () => {
expect(() => parseLocalReviewConfig(ok, () => false)).toThrow(/does not exist/);
});
it('applies defaults for optional fields', () => {
const cfg = parseLocalReviewConfig(ok, yes);
expect(cfg).toMatchObject({
binaryPath: '/bin/llama-server',
modelPath: '/models/ornith.gguf',
port: 8090,
extraArgs: '',
baseRef: 'master',
readyTimeoutMs: 60000,
requestTimeoutMs: 300000,
maxDiffChars: 60000,
});
});
it('keeps valid custom values and rejects an out-of-range port', () => {
const cfg = parseLocalReviewConfig({
localReview: { binaryPath: '/b', modelPath: '/m', port: 70000, baseRef: 'dev', maxDiffChars: 1000 },
}, yes);
expect(cfg.port).toBe(8090); // 70000 > 65535 falls back to default
expect(cfg.baseRef).toBe('dev');
expect(cfg.maxDiffChars).toBe(1000);
});
it('trims path values', () => {
const cfg = parseLocalReviewConfig({ localReview: { binaryPath: ' /b ', modelPath: ' /m ' } }, yes);
expect(cfg.binaryPath).toBe('/b');
expect(cfg.modelPath).toBe('/m');
});
});
describe('buildReviewMessages', () => {
it('produces a system + user pair carrying the diff', () => {
const msgs = buildReviewMessages('diff body');
expect(msgs).toHaveLength(2);
expect(msgs[0].role).toBe('system');
expect(msgs[1].role).toBe('user');
expect(msgs[1].content).toContain('diff body');
});
});
describe('formatReport', () => {
it('includes the header fields and findings', () => {
const report = formatReport({
baseRef: 'master',
mergeBase: 'deadbeef',
timestamp: '2026-07-07T00:00:00.000Z',
diffStat: ' src/main.ts | 2 +-',
findings: 'Looks fine.',
truncated: false,
scope: { staged: false, full: false },
});
expect(report).toContain('master');
expect(report).toContain('deadbeef');
expect(report).toContain('Looks fine.');
expect(report).toContain('src/main.ts');
expect(report).not.toContain('was truncated');
});
it('notes truncation when set', () => {
const report = formatReport({
baseRef: 'master',
mergeBase: 'deadbeef',
timestamp: 't',
diffStat: '',
findings: '',
truncated: true,
scope: { staged: false, full: false },
});
expect(report).toMatch(/truncated/);
expect(report).toContain('(no findings returned)');
});
});

119
test/mocks/obsidian.ts Normal file
View file

@ -0,0 +1,119 @@
// Minimal runtime stand-in for the `obsidian` package, aliased in via vitest.config.ts.
// The real `obsidian` npm package ships only type declarations (no runtime JS), so any module
// under test that imports a value (not just a type) from 'obsidian' needs something real to
// resolve to. This file implements just enough of the surface actually exercised by the
// modules covered under test/ — it is not a full Obsidian API shim.
import { parse, stringify } from 'yaml';
export function parseYaml(yamlText: string): unknown {
if (!yamlText || !yamlText.trim()) return null;
return parse(yamlText);
}
export function stringifyYaml(obj: unknown): string {
return stringify(obj);
}
// Approximates Obsidian's normalizePath: forward slashes, no doubled slashes, no leading "./",
// no trailing slash. Not a byte-exact reimplementation, just enough for the path shapes the
// tested modules produce (template/attachment folder paths, the whisper-host PID sidecar path).
export function normalizePath(path: string): string {
let p = path.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
p = p.replace(/^\.\//, '');
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
if (p === '.') p = '';
return p;
}
export function moment(): { format: (fmt?: string) => string } {
return { format: () => '' };
}
export async function requestUrl(): Promise<never> {
throw new Error('requestUrl is not implemented in the test environment');
}
export const Platform = {
isDesktop: true,
isMobile: false,
isMacOS: false,
isWin: false,
};
export class Notice {
constructor(message?: unknown, duration?: number) {}
setMessage(): this {
return this;
}
hide(): void {}
}
export class TFile {
path = '';
basename = '';
extension = '';
stat = { mtime: 0, ctime: 0, size: 0 };
}
export class TFolder {
path = '';
children: unknown[] = [];
}
export class TAbstractFile {
path = '';
}
export class Modal {
app: unknown;
contentEl: unknown = {};
constructor(app: unknown) {
this.app = app;
}
open(): void {}
close(): void {}
onOpen(): void {}
onClose(): void {}
}
export class Setting {
constructor(containerEl?: unknown) {}
setName(): this {
return this;
}
setDesc(): this {
return this;
}
setHeading(): this {
return this;
}
addText(): this {
return this;
}
addButton(): this {
return this;
}
addToggle(): this {
return this;
}
addDropdown(): this {
return this;
}
}
export class Plugin {
app: unknown;
manifest: { id: string; dir?: string } = { id: 'rewrite-voice-notes', dir: '.' };
constructor(app?: unknown, manifest?: { id: string; dir?: string }) {
this.app = app;
if (manifest) this.manifest = manifest;
}
async loadData(): Promise<unknown> {
return null;
}
async saveData(): Promise<void> {}
}
export class App {}
export class MarkdownView {}

70
test/pipeline.test.ts Normal file
View file

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { extractFromBlock } from '../src/pipeline';
import { NotePropertySpec } from '../src/types';
const PROPS: NotePropertySpec[] = [
{ name: 'subject', instruction: 'the meeting subject' },
{ name: 'participants', instruction: 'who attended' },
];
describe('extractFromBlock', () => {
it('parses a tagged ```yaml block and strips it from the body', () => {
const raw = '```yaml\nsubject: Roadmap sync\nparticipants: Alice, Bob\n```\n\nThe body starts here.';
const result = extractFromBlock(raw, PROPS, false);
expect(result.properties).toEqual({ subject: 'Roadmap sync', participants: 'Alice, Bob' });
expect(result.body).toBe('The body starts here.');
expect(result.title).toBeUndefined();
});
// Regression test for the fix in pipeline.ts: the fence regex used to make the yaml/yml tag
// optional, so a model that wrapped its ENTIRE reply in a bare ``` fence had the whole note
// swallowed as "the properties block" and left with an empty body.
it('does not treat a bare ``` fence (no yaml tag) as the properties block', () => {
const raw = '```\nJust some fenced content, not a properties block.\n```';
const result = extractFromBlock(raw, PROPS, false);
expect(result.body).toBe(raw);
expect(result.properties).toEqual({ subject: '', participants: '' });
});
it('accepts a ```yml tag as well as ```yaml', () => {
const raw = '```yml\nsubject: Standup\nparticipants: Team\n```\n\nBody.';
const result = extractFromBlock(raw, PROPS, false);
expect(result.properties.subject).toBe('Standup');
expect(result.body).toBe('Body.');
});
it('returns the whole trimmed output as body when no block is present', () => {
const raw = ' Just plain cleaned-up text, no properties requested. ';
const result = extractFromBlock(raw, PROPS, false);
expect(result.body).toBe('Just plain cleaned-up text, no properties requested.');
expect(result.properties).toEqual({ subject: '', participants: '' });
});
it('falls back to the tolerant line parser on malformed YAML, still stripping the block', () => {
// An unterminated quote is invalid YAML and makes js-yaml's strict parser throw.
const raw = '```yaml\nsubject: "Unterminated quote\nparticipants: Alice\n```\n\nBody text.';
const result = extractFromBlock(raw, PROPS, false);
expect(result.properties.participants).toBe('Alice');
expect(result.body).toBe('Body text.');
});
it('extracts the reserved noteTitle key into title, not into properties', () => {
const raw = '```yaml\nsubject: Kickoff\nparticipants: Everyone\nnoteTitle: Kickoff meeting notes\n```\n\nBody.';
const result = extractFromBlock(raw, PROPS, true);
expect(result.title).toBe('Kickoff meeting notes');
expect(result.properties).toEqual({ subject: 'Kickoff', participants: 'Everyone' });
expect(Object.keys(result.properties)).not.toContain('noteTitle');
});
it('ignores noteTitle when wantsTitle is false', () => {
const raw = '```yaml\nsubject: Kickoff\nparticipants: Everyone\nnoteTitle: Should be ignored\n```\n\nBody.';
const result = extractFromBlock(raw, PROPS, false);
expect(result.title).toBeUndefined();
});
it('leaves declared properties blank when the model omits them', () => {
const raw = '```yaml\nsubject: Only subject given\n```\n\nBody.';
const result = extractFromBlock(raw, PROPS, false);
expect(result.properties).toEqual({ subject: 'Only subject given', participants: '' });
});
});

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { join } from 'node:path';
import {
RELEASE_FILES,
computeTargetPluginDir,
parseReleaseVaultConfig,
readPluginId,
} from '../prepare-release-vault.mjs';
describe('RELEASE_FILES', () => {
it('is exactly the three loose Obsidian assets', () => {
expect(RELEASE_FILES).toEqual(['main.js', 'manifest.json', 'styles.css']);
});
});
describe('parseReleaseVaultConfig', () => {
it('throws when the top-level value is not an object', () => {
expect(() => parseReleaseVaultConfig(null)).toThrow(/JSON object/);
});
it('throws when releaseVault is missing', () => {
expect(() => parseReleaseVaultConfig({})).toThrow(/releaseVault/);
});
it('throws when vaultPath is empty', () => {
expect(() => parseReleaseVaultConfig({ releaseVault: { vaultPath: ' ' } })).toThrow(/vaultPath/);
});
it('returns the trimmed vault path', () => {
expect(parseReleaseVaultConfig({ releaseVault: { vaultPath: ' /vaults/scratch ' } })).toEqual({
vaultPath: '/vaults/scratch',
});
});
});
describe('readPluginId', () => {
it('reads the id from a manifest object', () => {
expect(readPluginId({ id: 'rewrite-voice-notes' })).toBe('rewrite-voice-notes');
});
it('throws when the id is missing', () => {
expect(() => readPluginId({})).toThrow(/id/);
expect(() => readPluginId({ id: ' ' })).toThrow(/id/);
});
});
describe('computeTargetPluginDir', () => {
it('joins vault path, .obsidian/plugins, and the plugin id', () => {
expect(computeTargetPluginDir('/vaults/scratch', 'rewrite-voice-notes')).toBe(
join('/vaults/scratch', '.obsidian', 'plugins', 'rewrite-voice-notes'),
);
});
});

120
test/secrets.test.ts Normal file
View file

@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// secrets.ts keeps module-level caches (the parsed envelope, the unlocked key, the
// secretStorage-availability probe result) that aren't keyed by which fake plugin instance is
// passed in. vi.resetModules() + a fresh dynamic import before each test gives every test a
// clean slate instead of leaking state (or an unlocked key) from a previous test.
let secrets: typeof import('../src/secrets');
class FakeVaultAdapter {
private files = new Map<string, string>();
async exists(path: string): Promise<boolean> {
return this.files.has(path);
}
async read(path: string): Promise<string> {
const v = this.files.get(path);
if (v === undefined) throw new Error(`ENOENT: ${path}`);
return v;
}
async write(path: string, data: string): Promise<void> {
this.files.set(path, data);
}
async remove(path: string): Promise<void> {
this.files.delete(path);
}
async rename(oldPath: string, newPath: string): Promise<void> {
const v = this.files.get(oldPath);
if (v === undefined) throw new Error(`ENOENT: ${oldPath}`);
this.files.delete(oldPath);
this.files.set(newPath, v);
}
// Test-only helpers, not part of the real Obsidian DataAdapter interface.
seed(path: string, data: string): void {
this.files.set(path, data);
}
peek(path: string): string | undefined {
return this.files.get(path);
}
}
const PLUGIN_DIR = 'rewrite-test-dir';
const SECRETS_PATH = `${PLUGIN_DIR}/secrets.json.nosync`;
function fakePlugin(): { manifest: { id: string; dir: string }; app: { vault: { adapter: FakeVaultAdapter } } } {
return {
manifest: { id: 'rewrite-voice-notes', dir: PLUGIN_DIR },
app: { vault: { adapter: new FakeVaultAdapter() } },
};
}
// Long enough to clear the zxcvbn MIN_PASSPHRASE_SCORE gate reliably.
const STRONG_PASSPHRASE = 'xqplerith journeys woven sapphire meadow glacier 84213';
beforeEach(async () => {
vi.resetModules();
secrets = await import('../src/secrets');
});
describe('passphrase mode round trip', () => {
it('saves and loads a key after setting a passphrase', async () => {
const plugin = fakePlugin() as unknown as import('obsidian').Plugin;
await secrets.setEncryptionMode(plugin, 'passphrase', STRONG_PASSPHRASE);
await secrets.saveKey(plugin, 'profile-desktop-llm', 'sk-test-key');
expect(await secrets.loadKey(plugin, 'profile-desktop-llm')).toBe('sk-test-key');
});
it('locks and requires the correct passphrase to unlock', async () => {
const plugin = fakePlugin() as unknown as import('obsidian').Plugin;
await secrets.setEncryptionMode(plugin, 'passphrase', STRONG_PASSPHRASE);
await secrets.saveKey(plugin, 'profile-desktop-llm', 'sk-test-key');
secrets.lockSecrets();
expect((await secrets.getEncryptionStatus(plugin)).locked).toBe(true);
// Locked reads degrade to '' rather than throwing.
expect(await secrets.loadKey(plugin, 'profile-desktop-llm')).toBe('');
expect(await secrets.unlockSecrets(plugin, 'the wrong passphrase entirely')).toBe(false);
expect(await secrets.unlockSecrets(plugin, STRONG_PASSPHRASE)).toBe(true);
expect(await secrets.loadKey(plugin, 'profile-desktop-llm')).toBe('sk-test-key');
});
it('deleting a key clears it back to empty', async () => {
const plugin = fakePlugin() as unknown as import('obsidian').Plugin;
await secrets.setEncryptionMode(plugin, 'passphrase', STRONG_PASSPHRASE);
await secrets.saveKey(plugin, 'profile-desktop-llm', 'sk-test-key');
await secrets.deleteKey(plugin, 'profile-desktop-llm');
expect(await secrets.loadKey(plugin, 'profile-desktop-llm')).toBe('');
});
});
describe('corrupt secrets file recovery', () => {
it('treats invalid JSON as corrupt, preserves it as a .corrupt sidecar, and starts fresh', async () => {
const plugin = fakePlugin() as unknown as import('obsidian').Plugin;
const adapter = (plugin as unknown as { app: { vault: { adapter: FakeVaultAdapter } } }).app.vault.adapter;
const garbage = '{not valid json,,,';
adapter.seed(SECRETS_PATH, garbage);
const status = await secrets.getEncryptionStatus(plugin);
// Falls back to a fresh, unconfigured envelope rather than throwing or silently
// pretending the (unreadable) file's contents were empty/valid.
expect(status.mode).toBe('passphrase');
expect(status.passphraseConfigured).toBe(false);
// The original corrupt bytes must still be recoverable, not overwritten.
expect(adapter.peek(`${SECRETS_PATH}.corrupt`)).toBe(garbage);
});
it('does not treat a merely-empty (never-configured) file as corrupt', async () => {
const plugin = fakePlugin() as unknown as import('obsidian').Plugin;
const adapter = (plugin as unknown as { app: { vault: { adapter: FakeVaultAdapter } } }).app.vault.adapter;
// A well-formed but unconfigured envelope (no kdf/verifier) is not corruption.
adapter.seed(SECRETS_PATH, JSON.stringify({ version: 2, mode: 'passphrase', keys: {} }));
await secrets.getEncryptionStatus(plugin);
expect(adapter.peek(`${SECRETS_PATH}.corrupt`)).toBeUndefined();
});
});
afterEach(() => {
secrets.lockSecrets();
});

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_SETTINGS, mergeSettings } from '../src/settings';
describe('mergeSettings', () => {
it('keeps defaults when partial is empty', () => {
const merged = mergeSettings(DEFAULT_SETTINGS, {});
expect(merged).toEqual(DEFAULT_SETTINGS);
});
it('accepts a valid enum override', () => {
const merged = mergeSettings(DEFAULT_SETTINGS, { recordingFormat: 'mp4' });
expect(merged.recordingFormat).toBe('mp4');
});
// Regression test: a corrupt/hand-edited data.json could carry garbage for an enum field.
// mergeSettings must fall back to the base value rather than propagate it, since spreading a
// non-object partial field over a nested config (or accepting an unrecognized enum string)
// would otherwise leak garbage into runtime state.
it('falls back to the base value for an invalid enum string', () => {
const merged = mergeSettings(DEFAULT_SETTINGS, {
// @ts-expect-error deliberately invalid to simulate a corrupt data.json
recordingFormat: 'not-a-real-format',
});
expect(merged.recordingFormat).toBe(DEFAULT_SETTINGS.recordingFormat);
});
it('falls back to the base transcriptionProvider for an invalid profile enum', () => {
const merged = mergeSettings(DEFAULT_SETTINGS, {
desktopProfile: {
...DEFAULT_SETTINGS.desktopProfile,
// @ts-expect-error deliberately invalid
transcriptionProvider: 'not-a-real-provider',
},
});
expect(merged.desktopProfile.transcriptionProvider).toBe(DEFAULT_SETTINGS.desktopProfile.transcriptionProvider);
});
it('does not spread a non-object localWhisper into the base config', () => {
const merged = mergeSettings(DEFAULT_SETTINGS, {
// @ts-expect-error deliberately invalid to simulate a corrupt data.json
localWhisper: 'garbage-string',
});
expect(merged.localWhisper).toEqual(DEFAULT_SETTINGS.localWhisper);
});
it('does not spread a non-object transcriptionConfig into the base profile config', () => {
const merged = mergeSettings(DEFAULT_SETTINGS, {
desktopProfile: {
...DEFAULT_SETTINGS.desktopProfile,
// @ts-expect-error deliberately invalid
transcriptionConfig: 'garbage-string',
},
});
expect(merged.desktopProfile.transcriptionConfig).toEqual(DEFAULT_SETTINGS.desktopProfile.transcriptionConfig);
});
it('merges a partial nested config over the base rather than replacing it wholesale', () => {
const merged = mergeSettings(DEFAULT_SETTINGS, {
desktopProfile: {
...DEFAULT_SETTINGS.desktopProfile,
transcriptionConfig: { ...DEFAULT_SETTINGS.desktopProfile.transcriptionConfig, model: 'whisper-1' },
},
});
expect(merged.desktopProfile.transcriptionConfig.model).toBe('whisper-1');
expect(merged.desktopProfile.transcriptionConfig.baseUrl).toBe(DEFAULT_SETTINGS.desktopProfile.transcriptionConfig.baseUrl);
});
});

9
test/setup.ts Normal file
View file

@ -0,0 +1,9 @@
// Obsidian's real runtime (Electron/browser) always has a global `crypto` (Web Crypto), which
// secrets.ts relies on without importing it. Node exposes it as a global automatically only on
// newer runtimes; polyfill it from node:crypto so the test environment matches regardless of
// which Node version is running the suite.
import { webcrypto } from 'node:crypto';
if (typeof globalThis.crypto === 'undefined') {
Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true });
}

View file

@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import { mergeTemplate, parseTemplateContent, renderTemplateFile } from '../src/templates-folder';
import { NoteTemplate } from '../src/types';
import { TFile } from '../test/mocks/obsidian';
function baseTemplate(overrides: Partial<NoteTemplate> = {}): NoteTemplate {
return {
id: 'general-cleanup',
name: 'General cleanup',
prompt: 'Clean up the transcript.',
insertMode: 'cursor',
newFileFolder: '',
newFileNameTemplate: 'ReWrite {{date}} {{time}}',
...overrides,
};
}
function fakeFile(basename: string): TFile {
const file = new TFile();
file.basename = basename;
return file;
}
describe('renderTemplateFile / parseTemplateContent round trip', () => {
it('round-trips a template with no optional fields', () => {
const template = baseTemplate();
const rendered = renderTemplateFile(template);
const parsed = parseTemplateContent(fakeFile('General cleanup'), rendered);
expect(parsed).not.toBeNull();
expect(parsed).toMatchObject({
id: template.id,
name: template.name,
prompt: template.prompt,
insertMode: template.insertMode,
newFileFolder: template.newFileFolder,
newFileNameTemplate: template.newFileNameTemplate,
disableSharedCore: false,
enableContextHint: false,
diarize: false,
titleFromContent: false,
});
});
it('round-trips the four boolean flags and noteProperties', () => {
const template = baseTemplate({
disableSharedCore: true,
enableContextHint: true,
diarize: true,
titleFromContent: true,
noteProperties: [
{ name: 'subject', instruction: 'the meeting subject' },
{ name: 'participants', instruction: 'who attended' },
],
});
const rendered = renderTemplateFile(template);
const parsed = parseTemplateContent(fakeFile('x'), rendered);
expect(parsed?.disableSharedCore).toBe(true);
expect(parsed?.enableContextHint).toBe(true);
expect(parsed?.diarize).toBe(true);
expect(parsed?.titleFromContent).toBe(true);
expect(parsed?.noteProperties).toEqual(template.noteProperties);
});
it('falls back to the file basename when frontmatter name is blank', () => {
const rendered = renderTemplateFile(baseTemplate({ name: '' }));
const parsed = parseTemplateContent(fakeFile('Fallback Name'), rendered);
expect(parsed?.name).toBe('Fallback Name');
});
it('returns null when the frontmatter has no id', () => {
const content = '---\nname: No id\n---\nBody.';
expect(parseTemplateContent(fakeFile('x'), content)).toBeNull();
});
});
describe('mergeTemplate', () => {
it('keeps an on-disk prompt matching the current default unchanged, no conflict', () => {
const def = baseTemplate({ prompt: 'Current default prompt.' });
const onDisk = baseTemplate({ prompt: 'Current default prompt.' });
const { merged, conflicts, changes } = mergeTemplate(onDisk, def, []);
expect(merged.prompt).toBe('Current default prompt.');
expect(conflicts).toHaveLength(0);
expect(changes).toHaveLength(0);
});
it('silently adopts the current default when the on-disk prompt matches a prior shipped version', () => {
const priorPrompt = 'Old shipped prompt.';
const def = baseTemplate({ prompt: 'New default prompt.' });
const onDisk = baseTemplate({ prompt: priorPrompt });
const priors = [baseTemplate({ prompt: priorPrompt })];
const { merged, conflicts, changes } = mergeTemplate(onDisk, def, priors);
expect(merged.prompt).toBe('New default prompt.');
expect(conflicts).toHaveLength(0);
expect(changes.length).toBeGreaterThan(0);
});
it('keeps a genuinely edited prompt and reports it as a body conflict', () => {
const def = baseTemplate({ prompt: 'New default prompt.' });
const onDisk = baseTemplate({ prompt: 'My own custom prompt that matches no known version.' });
const { merged, conflicts } = mergeTemplate(onDisk, def, []);
expect(merged.prompt).toBe(onDisk.prompt);
expect(conflicts).toEqual([{ kind: 'body', defaultValue: def.prompt, userValue: onDisk.prompt }]);
});
it('never deletes a user property the default has dropped', () => {
const def = baseTemplate({ noteProperties: [] });
const onDisk = baseTemplate({ noteProperties: [{ name: 'custom', instruction: 'user added this' }] });
const { merged, conflicts } = mergeTemplate(onDisk, def, []);
expect(merged.noteProperties).toEqual([{ name: 'custom', instruction: 'user added this' }]);
expect(conflicts.some((c) => c.kind === 'removedProperty')).toBe(true);
});
it('adds a newly-introduced default property the user file lacks', () => {
const def = baseTemplate({ noteProperties: [{ name: 'subject', instruction: 'fill this in' }] });
const onDisk = baseTemplate({ noteProperties: [] });
const { merged, changes } = mergeTemplate(onDisk, def, []);
expect(merged.noteProperties).toEqual([{ name: 'subject', instruction: 'fill this in' }]);
expect(changes.some((c) => c.includes('subject'))).toBe(true);
});
});

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { pollTimeoutMs, validateRecording } from '../src/transcription/limits';
const MB = 1024 * 1024;
const MIN = 60 * 1000;
const HOUR = 60 * MIN;
describe('validateRecording', () => {
it('allows a recording exactly at the byte limit (exclusive boundary)', () => {
expect(() => validateRecording(25 * MB, undefined, 'openai')).not.toThrow();
});
it('rejects a recording one byte over the limit', () => {
expect(() => validateRecording(25 * MB + 1, undefined, 'openai')).toThrow(/25 MB limit/);
});
it('allows a recording exactly at the duration limit (exclusive boundary)', () => {
expect(() => validateRecording(1 * MB, 10 * HOUR, 'assemblyai')).not.toThrow();
});
it('rejects a recording over the duration limit', () => {
expect(() => validateRecording(1 * MB, 10 * HOUR + 1, 'assemblyai')).toThrow(/10 h limit|min limit/);
});
it('never throws for a provider with no client-side cap', () => {
expect(() => validateRecording(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, 'whisper-local')).not.toThrow();
});
it('ignores duration when undefined even for a duration-capped provider', () => {
expect(() => validateRecording(1 * MB, undefined, 'assemblyai')).not.toThrow();
});
});
describe('pollTimeoutMs', () => {
it('falls back to the ceiling when duration is unknown', () => {
expect(pollTimeoutMs(undefined)).toBe(2 * HOUR);
});
it('falls back to the ceiling for a zero or negative duration', () => {
expect(pollTimeoutMs(0)).toBe(2 * HOUR);
expect(pollTimeoutMs(-1)).toBe(2 * HOUR);
});
it('scales as floor + 2x duration for a short clip', () => {
const oneMinute = MIN;
expect(pollTimeoutMs(oneMinute)).toBe(MIN + oneMinute * 2);
});
it('clamps to the ceiling for a very long recording', () => {
expect(pollTimeoutMs(10 * HOUR)).toBe(2 * HOUR);
});
});

11
test/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node"],
"lib": ["ES2022"],
"noUnusedLocals": false
},
"include": [
"**/*.ts"
]
}

25
test/version-bump.test.ts Normal file
View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { shouldRecordVersion } from '../version-bump.mjs';
describe('shouldRecordVersion', () => {
// Regression test: the original guard checked `!Object.values(versions).includes(minAppVersion)`,
// which meant once ANY prior release shared the same minAppVersion, no new release version was
// ever recorded again (checked the wrong dimension: value instead of key).
it('returns true for a target version not yet present as a key', () => {
expect(shouldRecordVersion('1.2.0', { '1.0.0': '1.4.4', '1.1.0': '1.4.4' })).toBe(true);
});
it('returns false when the target version is already a key', () => {
expect(shouldRecordVersion('1.1.0', { '1.0.0': '1.4.4', '1.1.0': '1.4.4' })).toBe(false);
});
it('returns true even when minAppVersion repeats across releases', () => {
// This is exactly the case that broke: several releases in a row share one minAppVersion.
const versions = { '1.0.0': '1.4.4', '1.1.0': '1.4.4', '1.1.1': '1.4.4' };
expect(shouldRecordVersion('1.1.2', versions)).toBe(true);
});
it('returns true for the very first version against an empty map', () => {
expect(shouldRecordVersion('1.0.0', {})).toBe(true);
});
});

47
test/whisper-host.test.ts Normal file
View file

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { getHostArgs, isLoopbackHost } from '../src/whisper-host';
describe('getHostArgs', () => {
it('returns an empty array when no --host is present', () => {
expect(getHostArgs(['-m', 'model.bin', '--port', '8080'])).toEqual([]);
});
it('collects a single --host value (space form)', () => {
expect(getHostArgs(['--host', '127.0.0.1'])).toEqual(['127.0.0.1']);
});
it('collects a single --host value (= form)', () => {
expect(getHostArgs(['--host=127.0.0.1'])).toEqual(['127.0.0.1']);
});
// Regression test for the bypass: whisper-server honors the LAST --host, so every
// occurrence must be collected, not just the first.
it('collects every --host occurrence, not just the first', () => {
expect(getHostArgs(['--host', '127.0.0.1', '--host', '0.0.0.0'])).toEqual(['127.0.0.1', '0.0.0.0']);
});
it('collects a mix of space and = forms', () => {
expect(getHostArgs(['--host=127.0.0.1', '--host', '0.0.0.0'])).toEqual(['127.0.0.1', '0.0.0.0']);
});
it('treats a --host with no following value as empty string, not missing', () => {
expect(getHostArgs(['--host'])).toEqual(['']);
});
});
describe('isLoopbackHost', () => {
it.each([
['127.0.0.1', true],
['localhost', true],
['LOCALHOST', true],
['::1', true],
['[::1]', true],
[' 127.0.0.1 ', true],
['0.0.0.0', false],
['192.168.1.5', false],
['example.com', false],
['', false],
])('isLoopbackHost(%s) === %s', (host, expected) => {
expect(isLoopbackHost(host)).toBe(expected);
});
});

View file

@ -1,17 +1,34 @@
import { readFileSync, writeFileSync } from "fs";
import { fileURLToPath } from "url";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
// Only record a new versions.json entry when the target version itself isn't already a key.
// (Previously checked whether minAppVersion was already a *value*, which meant no new entry was
// ever recorded once any prior version shared the same minAppVersion — silently dropping every
// release after the first from the compatibility map.) Exported for tests.
export function shouldRecordVersion(targetVersion, versions) {
return !(targetVersion in versions);
}
function run() {
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync("versions.json", "utf8"));
if (shouldRecordVersion(targetVersion, versions)) {
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
}
}
// Guards the file-writing side effects behind "run as a script", not "imported as a module", so
// tests can import shouldRecordVersion() without touching manifest.json/versions.json on disk.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
run();
}

View file

@ -1,3 +1,5 @@
{
"1.0.0": "1.4.4"
"1.0.0": "1.4.4",
"1.1.0": "1.4.4",
"1.1.1": "1.4.4"
}

25
vitest.config.ts Normal file
View file

@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'url';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [
// src/*.ts uses bare imports like `from 'passphrase-strength'` relying on tsconfig.json's
// baseUrl: "src" (resolved natively by esbuild's bundler at build time); Vite/Vitest don't
// know about baseUrl without this plugin reading tsconfig for us.
tsconfigPaths(),
],
test: {
environment: 'node',
include: ['test/**/*.test.ts'],
setupFiles: ['test/setup.ts'],
},
resolve: {
alias: {
// The real `obsidian` npm package ships no runtime JS, only .d.ts files. Modules
// under test import runtime values (Notice, normalizePath, parseYaml, ...) from it,
// so tests need something real to resolve to; see test/mocks/obsidian.ts.
obsidian: fileURLToPath(new URL('./test/mocks/obsidian.ts', import.meta.url)),
},
},
});

View file

@ -25,7 +25,7 @@ For fully local setups, see [Self-hosting: whisper.cpp](Self-Hosting-Whisper) an
| Anthropic Claude (`anthropic`) | Yes | Yes | Calls go through Obsidian's `requestUrl` (no browser CORS). |
| OpenAI GPT (`openai`) | Yes | Yes | Reasoning models (o-series, gpt-5) handled automatically. |
| OpenAI-compatible (`openai-compatible`) | Yes | No (type the id) | Cloud or local; base URL must include the version path. |
| Google Gemini (`gemini`) | Yes | Yes | Silently clamps overlong output rather than erroring. |
| Google Gemini (`gemini`) | Yes | Yes | Reports the same "Maximum note length" error as other providers when the cap is too high. |
| Mistral (`mistral`) | Yes | Yes | |
| None (`none`) | n/a | n/a | Skips cleanup; inserts the raw transcript. |
@ -44,7 +44,7 @@ Whatever the control, the value saved is the model id sent to the provider.
## Maximum note length (output cap)
"Maximum note length" frames the LLM's output-token cap in minutes of speech. If the cap is set higher than a model's own output ceiling, OpenAI and Anthropic return a friendly error pointing back at this setting; Gemini silently truncates instead. The Advanced "LLM max tokens" field edits the same value raw.
"Maximum note length" frames the LLM's output-token cap in minutes of speech. If the cap is set higher than a model's own output ceiling, every provider (including Gemini) returns a friendly error pointing back at this setting rather than silently truncating the note. The Advanced "LLM max tokens" field edits the same value raw.
## OpenAI-compatible base URLs

View file

@ -28,6 +28,10 @@ A typical move from passphrase to secret storage: switch the dropdown to **Obsid
The deeper implementation detail (envelope schema, KDF parameters, the switch/copy/clear model) is developer-facing and lives in the repo's `docs/SECRETS.md`.
## If the secrets file gets corrupted
If `secrets.json.nosync` is ever truncated or damaged (a crash mid-write, a sync conflict merging two versions into one file), the plugin does not silently discard it. On the next load you will see a notice explaining that the file looked corrupted and that a copy was saved as `secrets.json.nosync.corrupt` next to it, before the plugin falls back to a fresh, unconfigured encryption setup. Your keys are not automatically recovered from that copy (a corrupted file usually can't be repaired), but it is preserved on disk in case you need to hand it to someone for recovery attempts, or just want the failure evidence. Once you see that notice, treat existing keys as gone: re-enter them rather than trying to save new settings first, since the fresh envelope will not merge with the old one.
## Excluding `secrets.json.nosync` from sync
If you use **passphrase mode** and do not want the encrypted key file copied between devices, exclude it from your sync and enter keys once per device. Configure the exclusion **before the first sync**, since files already uploaded usually remain on the remote. (In secret-storage mode this file holds no keys, so excluding it has no effect on the keys themselves.)

View file

@ -8,12 +8,14 @@ Start here, then follow the link into the page with the detail for your problem.
- **No templates in the dropdown.** You have not populated them yet. Settings, Templates, Populate. See [Quick start](Quick-Start).
- **The plugin asks for a passphrase / says it is locked.** You are in passphrase encryption mode and the key is not unlocked. Enter your passphrase. See [Secrets and sync](Secrets-and-Sync).
- **Keys disappeared after syncing to another device.** Encrypted keys do not decrypt cross-device in some modes; this is expected. Re-enter the key on that device, and consider excluding `secrets.json.nosync` from sync. See [Secrets and sync](Secrets-and-Sync).
- **A notice says the secrets file looked corrupted.** A crash or sync conflict damaged `secrets.json.nosync`. The plugin preserves the damaged file as `secrets.json.nosync.corrupt` and starts fresh rather than risk overwriting it. Re-enter your keys. See [Secrets and sync](Secrets-and-Sync#if-the-secrets-file-gets-corrupted).
## Provider errors
- **An error names the provider and an HTTP status.** That is the provider rejecting the request (bad key, wrong model id, rate limit, oversized audio). Read the message; it is attributed to the provider on purpose.
- **"Maximum note length" / max-tokens error.** Your output cap is above the model's limit. Lower it in the profile. See [Providers](Providers).
- **Recording too large.** You hit a provider's size or duration ceiling. Switch to a higher-ceiling provider (AssemblyAI, Rev.ai) or shorten the clip. Limits are listed in [Providers](Providers) and [Commands and menus](Commands-and-Menus).
- **The "ReWrite: ..." progress notice seems stuck.** Click **Cancel** on the notice to stop the run. Note that a request already in flight to a provider can't be interrupted mid-transfer (Obsidian's networking layer has no cancel signal), so Cancel takes effect at the next stage boundary rather than instantly.
## Recording