Real-time dictation (AssemblyAI/Deepgram, own provider/key/model), auto-ingest
folders, record-in-background, per-invocation diarization, manage built-in
templates (enable/track), local whisper.cpp auto-start/idle-stop, and dev tooling
(local review + release-checklist skill).

Voxtral realtime was reverse-engineered but pulled (not WebView-reachable); its
adapter is kept on disk, unwired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
WiseGuru 2026-07-08 21:38:30 -07:00
parent 8c48390353
commit 8657bc53b3
52 changed files with 3088 additions and 257 deletions

View file

@ -34,6 +34,7 @@ Keep this file current: when a feature is added or changed, add or update its it
- [ ] **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: ____
- [ ] **Record in background** (desktop): the checkbox shows on the Record tab, persists across reopen; ticking it and pressing Record closes the modal and continues in the Quick Record floater carrying the template/destination/context; hidden on mobile. Result: ____
## 3. Templates
@ -44,7 +45,8 @@ Keep this file current: when a feature is added or changed, add or update its it
- [ ] **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: ____
- [ ] **Diarization is per-invocation**: the modal shows an "Identify speakers" checkbox only on a capable provider (AssemblyAI/Deepgram/Rev.ai), defaulting to the template's `diarize` flag (on for Meeting transcript); it can be unticked per run; there is no "Identify speakers" toggle in Settings; a non-diarize template (e.g. Daily note) produces no `Speaker` labels. Result: ____
- [ ] **Manage built-in templates**: each row has a clearly-labelled Enabled switch (caption reads "Enabled" when on, "Disabled" when off) and a Tracked checkbox with its label to the LEFT of the box (reads "Tracked" when checked, "Untracked" when unchecked); turning Enabled off (confirm modal) removes the file and Populate/Update no longer re-add it; turning it back on restores it; unchecking Tracked writes `managed: false` and Update then leaves it untouched (reported as untracked). Result: ____
## 4. Transcription providers
@ -68,6 +70,8 @@ Keep this file current: when a feature is added or changed, add or update its it
- [ ] 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: ____
- [ ] **Start automatically**: with the toggle on and the profile using whisper-local, the server starts on Obsidian launch (a still-running one is adopted, not doubled). Result: ____
- [ ] **Stop when idle**: with a small minutes value set, the server stops after that idle time; it never stops mid-transcription or an externally-started server. Result: ____
## 7. Known nouns / assistant prompt (wake name)
@ -102,13 +106,32 @@ Keep this file current: when a feature is added or changed, add or update its it
- [ ] 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
## 12. Real-time transcription
- [ ] `Real-time transcription (start/stop)` requires a **Real-time provider** set (AssemblyAI/Deepgram, independent of the batch transcription provider; errors with a steer otherwise), its real-time key set, and an open Markdown note. Verify the batch provider and real-time provider can differ (e.g. batch Voxtral + real-time AssemblyAI). Result: ____
- [ ] Settings shows a self-contained **Real-time transcription** section: a Real-time provider dropdown (None + realtime-capable only, so **only AssemblyAI/Deepgram, NOT Voxtral**) that re-renders on change, plus key + adaptive model field when a provider is picked; the model field is a dropdown with Refresh for Deepgram and a text field for AssemblyAI; the key persists/hydrates across reload under its own secret. Result: ____
- [ ] AssemblyAI/Deepgram: starting shows the floating bar (dot + rolling interim text + Stop); spoken words appear at the cursor as finalized segments; interim text is never inserted. Result: ____
- [ ] **Voxtral real-time is NOT offered** (pulled: not WebView-reachable). Confirm it is absent from the Real-time provider dropdown. (The adapter stays on disk, unwired.) Result: ____
- [ ] Stop flushes the last segment and closes the connection; running the command again while active stops it. Result: ____
## 13. Auto-ingest folders
- [ ] Settings "Auto-ingest folders": Add opens the rule popup; the template dropdown lists only newFile templates; rules list with enable toggle / Edit / Delete. Result: ____
- [ ] `Process auto-ingest folders` processes each audio file in an enabled folder with that rule's template, one at a time, behind a sticky Notice with Cancel. Result: ____
- [ ] On success each recording is moved to the attachments location and its note's `![[embed]]` still resolves; a re-run does not reprocess it. Result: ____
- [ ] A file that fails stays in the ingest folder and is retried next run; the summary Notice reports processed/failed counts **and** a second sticky Notice lists the concrete failure reason per file (first 5, then "…and N more"). Result: ____
- [ ] **Ingest folder == recordings/attachments folder** is caught UP FRONT: setting a rule's folder to the same folder recordings are stored in (either the ReWrite Attachments folder, or Obsidian's own attachment folder when ReWrite's is blank) skips the rule with a clear "this folder is also where recordings are stored; point the rule at a different folder" Notice BEFORE any note is created. Result: ____
- [ ] **Move-after-success failure**: if the note is created but the recording can't be moved out, the error says the note was created and to move/delete the source manually (so it isn't reprocessed). Result: ____
## 14. 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: ____
- [ ] While recording on mobile, a yellow "keep Obsidian in the foreground" caution shows in the Record tab and the Quick Record floater; it's absent on desktop and hidden once processing starts. Result: ____
- [ ] Auto-ingest and real-time transcription work on mobile (both are cross-platform). Result: ____
## 13. Misc
## 15. 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

@ -1,39 +1,54 @@
---
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.
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. Runs the automated pre-checks (build, lint, test, and the advisory local reviews), installs a fresh build into a test vault, 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.
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 runs every automated check available, installs a fresh build into a test 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.
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`. The mechanics of each tool are in `docs/DEV_TOOLING.md`; the tag/push release steps are in `docs/RELEASING.md`. This skill is the front door that sequences them.
## Phase 1 — Setup check
## Phase 1 — Automated pre-checks
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).
Run all of these first; they are cheap and catch the machine-checkable problems before a human spends time clicking:
## Phase 2 — Build and install (automated)
```bash
npm run build && npm run lint && npm test # CI parity — must all pass
npm run review # advisory local code review of the working-tree diff
npm run review:docs # advisory local doc-consistency review
```
Run the release-prep script via Bash:
- `build`/`lint`/`test` are release blockers if they fail.
- The two `review` runs are **advisory** (always exit 0). Read the reports in `docs/claude-scratch/local-review-<mode>-report.md` and surface anything real. They will not block, and they are imperfect (local, quantized model), so treat findings as a first-pass filter, not a gate.
- **If a review report says "(no findings returned)" or looks empty/garbled**, it is almost always a model/tooling issue, not a clean bill of health — see `docs/DEV_TOOLING.md` Gotchas (reasoning models need thinking disabled; the context must fit the prompt; a huge diff truncates). Fix the tooling or note it; do not read empty output as "no problems."
## Phase 2 — 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 it in. A dedicated **scratch** vault is ideal; a real vault is acceptable because `release:prep` only overwrites the three build artifacts (see Phase 3), but never point it anywhere you'd mind a plugin reload.
2. Confirm the working tree holds exactly what the user intends to ship. For a real release you also want the version already bumped (`npm version <patch|minor|major> --no-git-tag-version`) so the manual pass exercises the actual versioned artifact **before** you commit and tag — do not tag first.
## Phase 3 — Build and install
```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.
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 and stop. It **only overwrites those three files**`data.json` and `secrets.json.nosync` in the target folder are left untouched, so a re-install keeps the user's settings and keys (this is why running against a vault with real data is safe).
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.
Then tell the user to open the vault 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)
## Phase 4 — 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.
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 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.
- 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. Give the newest, least-tested features the most attention.
- 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.
- Mobile-only items need a phone; SKIP is fine if the user isn't testing mobile that round.
- `CHECKLIST.md` is also runnable standalone by a human with no Claude Code; keep it self-contained prose, and keep it in sync with the feature set (a feature change updates it in the same commit, like CLAUDE.md and the wiki).
## Phase 4 — Summary
## Phase 5 — Summary and hand-off to release
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.
On a **go**, the actual release (commit the bump + rolled `docs/ROADMAP.md`, tag the bare version, push, let CI build the attested assets) follows `docs/RELEASING.md` — this skill stops at the go/no-go decision; it does not tag or push.

View file

@ -35,6 +35,7 @@ Page map (keep `_Sidebar.md` and `Home.md`'s contents list in sync when adding/r
- `Commands-and-Menus.md`: command palette, ribbon, editor/file menus, Quick Record UI ([src/main.ts](src/main.ts)).
- `Creating-Templates.md`: template file format + authoring guide. This is the canonical user-facing template guide (it replaced the old in-vault `Template guide.md`); keep it in sync with the `NoteTemplate` schema.
- `Providers.md`: provider tables, model selection, base-URL conventions, diarization, context hint, known nouns, recording limits.
- `Voxtral-Realtime.md`: the reverse-engineered Voxtral realtime WebSocket protocol, the browser-auth caveat (why it is disabled/unwired, not shipped), how a contributor would re-enable and test it, and a contributor ask. Keep in sync with `src/realtime/voxtral.ts`.
- `Self-Hosting-Whisper.md`: local whisper.cpp host (mirrors user-facing parts of [docs/WHISPER_HOST.md](docs/WHISPER_HOST.md)).
- `Self-Hosting-LLMs.md`: Ollama/llama.cpp local and remote, plus low-spec model recommendations (refresh these as models age).
- `Secrets-and-Sync.md`: encryption modes summary + the 7 sync-exclusion recipes.
@ -64,7 +65,7 @@ CI ([.github/workflows/lint.yml](.github/workflows/lint.yml)) runs `npm ci`, `np
### 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.
[vitest.config.ts](vitest.config.ts) drives a small [Vitest](https://vitest.dev) suite under [test/](test/) covering pure/isolable logic: `whisper-host` (`getHostArgs`/`isLoopbackHost`/`shouldStopWhenIdle`), `pipeline.extractFromBlock`, `secrets` (passphrase round trip, corrupt-envelope recovery), `settings mergeSettings` (incl. `disabledDefaultTemplateIds` / `ingestRules` sanitization), `templates-folder` (parse/render round trip, `mergeTemplate`, `managed` tri-state), `realtime/pcm` (`downsampleBuffer`/`floatTo16BitPcm`), `ingest` (`isIngestTemplate`/`collectIngestFiles`), `transcription/limits`, and the `version-bump.mjs` guard. There is no UI/integration test layer; UI behavior is still verified manually per the spec checklist.
Two things make `src/` runnable under Vitest even though it targets the Obsidian runtime, not Node:
- **The `obsidian` npm package ships only `.d.ts` files, no runtime JS.** [test/mocks/obsidian.ts](test/mocks/obsidian.ts) is a minimal runtime stand-in (just the classes/functions the tested modules actually import: `Notice`, `Plugin`, `TFile`, `parseYaml`/`stringifyYaml` via the `yaml` package, `normalizePath`, etc.), aliased in via `resolve.alias` in [vitest.config.ts](vitest.config.ts). It is not a full API shim; extend it only as new modules come under test.
@ -96,7 +97,8 @@ src/
├── audio-transcode.ts # WebAudio decode + resample to 16 kHz mono PCM WAV (shared by whisper-local and mistral-voxtral)
├── pipeline.ts # transcribe → cleanup → insert orchestrator
├── insert.ts # cursor/newFile/append + {{date}}/{{time}} expansion
├── whisper-host.ts # Spawns/stops a user-supplied whisper-server child process (desktop only)
├── ingest.ts # Auto-ingest batch runner: scan rule folders, pipeline each audio file, move on success
├── whisper-host.ts # Spawns/stops a user-supplied whisper-server child process (desktop only; Phase B auto-start + idle-stop)
├── templates-folder.ts # Load templates from a vault folder + populate it with the 10 defaults
├── template-guide.ts # Writes the "Template update report.md" (the Update button's worklist) next to the templates folder; never loaded by the plugin. (The in-vault user guide was removed; the template guide now lives in the wiki.)
├── shared-core.ts # Load the shared cleanup preface from a vault Markdown file + populate default (prepended to every template prompt)
@ -109,13 +111,20 @@ src/
│ ├── tab.ts # PluginSettingTab: active profile, two profile sections, templates folder, recording
│ └── default-templates.ts # The 10 default templates used by the populate button (General cleanup, Todo list, Daily note, Meeting notes, Meeting transcript, Idea capture, Lecture, Podcast, Guides, Book log); each prompt = per-template rules only (the shared preface is prepended at runtime from shared-core.ts)
├── ui/
│ ├── modal.ts # Main modal: template select + Record/Paste/From note tabs + setup-card injection
│ ├── modal.ts # Main modal: template select + Record/Paste/From note tabs + setup-card injection + record-in-background handoff
│ ├── setup-card.ts # Inline blocker when active profile is unconfigured (voice vs text purpose)
│ ├── quick-record.ts # QuickRecordController + floating mini-UI for the Quick Record command
│ ├── quick-record.ts # QuickRecordController + floating mini-UI for the Quick Record command (also hosts modal handoffs)
│ ├── realtime.ts # RealtimeController + floating bar for live dictation at the cursor
│ ├── template-picker.ts # Lightweight modal for picking a template (used by Process text command and editor menu)
│ ├── ingest-rule-modal.ts # Popup editor for one auto-ingest rule (folder + newFile template + enabled)
│ ├── text-source.ts # resolveActiveTextSource + runTextPipeline helpers for text-source flows
│ ├── pipeline-progress.ts # runBackgroundPipeline: shared sticky-Notice-with-Cancel wrapper for detached pipeline runs; stageLabel/formatDuration
│ └── whisper-status-bar.ts # Status-bar dot for whisper-host start/stop (desktop + whisper-local profile only)
├── realtime/
│ ├── index.ts # RealtimeProvider/RealtimeSession interfaces + factory + transcriptionProviderSupportsRealtime()
│ ├── pcm.ts # PcmCapture (ScriptProcessor mic tap) + pure downsample/quantize helpers
│ ├── deepgram.ts # Deepgram live WS adapter (auth via ['token', key] subprotocol)
│ └── assemblyai.ts # AssemblyAI Universal-Streaming WS adapter (ephemeral temp token)
├── transcription/
│ ├── index.ts # TranscriptionProvider interface + createTranscriptionProvider()
│ ├── limits.ts # Per-provider maxBytes/maxDurationMs + validateRecording (called from pipeline)
@ -145,6 +154,8 @@ The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed b
The modal has two pipeline-running paths. The **Record** tab closes the modal the moment recording stops and runs the pipeline detached via `startRecordingPipeline` ([src/ui/modal.ts](src/ui/modal.ts)), which delegates to the shared `runBackgroundPipeline` helper in [src/ui/pipeline-progress.ts](src/ui/pipeline-progress.ts), mirroring `runAudioFilePipeline` in [src/ui/audio-source.ts](src/ui/audio-source.ts) and `runTextPipeline` in [src/ui/text-source.ts](src/ui/text-source.ts), all three of which now go through the same helper. `runBackgroundPipeline` opens a sticky `Notice` with a **Cancel** button (`setMessage` per stage), wires an `AbortController` into `PipelineParams.signal`, and on completion saves `lastUsedTemplateId` + shows a completion `Notice`; on error (including a user cancel, which surfaces as an `AbortError`) it shows the failure message and calls an optional `onError` hook. This is safe because the `persist-audio` stage saved the recording before transcription, so the vault file is the recovery path even if a cancel/error happens after that point. The **Paste** and **From note** tabs instead use `execute`, which keeps the modal open with an inline `.rewrite-progress` line and renders an inline **Retry** button on error (they have no persisted recovery, so closing would lose the input); `execute` is guarded against re-entrancy (`if (this.running) return;`) and the Paste/From-note buttons disable themselves for the duration, since nothing previously stopped a double-click from launching two concurrent pipeline runs. Destination override and context hint are captured into locals before `startRecordingPipeline` closes the modal, so a recorded run still honors them.
**Record in background** (desktop only): the Record tab shows a checkbox backed by `GlobalSettings.recordInBackground` (persisted; default off, hidden on mobile where the WebView suspends backgrounded `MediaRecorder`). When on, pressing Record does not capture in the modal at all: `startBackgroundHandoff` ([src/ui/modal.ts](src/ui/modal.ts)) captures the template / destination override / context hint into locals, closes the modal, and calls `plugin.startBackgroundRecording(opts)` ([src/main.ts](src/main.ts)), which routes into the SAME `activeQuickRecord` slot + `quickRecordStarting` guard as the two Quick Record commands (single-recording guarantee, one `onunload` cleanup path). `QuickRecordController` carries the handed-off `destinationOverride` / `contextHint` into `runPipeline`; picking a *different* template from the floater's popover clears both (they were set up against the original template). When the checkbox is off, capture stays in-modal, and Stop still closes the modal + runs detached as described above, so processing never blocks Obsidian either way.
Note on cancellation: Obsidian's `requestUrl` (used by every provider adapter, see [src/http.ts](src/http.ts)) accepts no `AbortSignal`, so Cancel only takes effect between network calls (e.g. before the next poll in `assemblyai.ts`/`revai.ts`), not mid-transfer. It still recovers a pipeline stuck at a stage that would otherwise never resolve.
The `PipelineSource` union has three variants: `audio` (recorded blob, optional `sourcePath` for reprocess flows), `paste` (textarea input), `text` (input from an existing note via selection or whole body). Text-source flows skip transcription entirely and only require the LLM half of the profile. The `audio` variant's `sourcePath` is set by the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) to point at an existing vault file; when present, the persist stage is skipped and that path is reused for the `![[<path>]]\n\n` prepend.
@ -153,29 +164,31 @@ The `PipelineSource` union has three variants: `audio` (recorded blob, optional
[src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) each define a small interface plus a `create...Provider(id)` factory. Provider families share one adapter file where the API shapes match: OpenAI Whisper, `openai-compatible`, and Groq all dispatch into [src/transcription/openai.ts](src/transcription/openai.ts) with different base URLs; OpenAI GPT, `openai-compatible`, and Mistral all dispatch into [src/llm/openai.ts](src/llm/openai.ts). Mistral Voxtral does NOT share with `openai.ts` (see [src/transcription/mistral-voxtral.ts](src/transcription/mistral-voxtral.ts)) because Voxtral's response is JSON-only (no `response_format=text`) and it rejects WebM input (so the blob is always transcoded to 16 kHz mono WAV via [src/audio-transcode.ts](src/audio-transcode.ts)).
API keys are stored per profile on `EnvironmentProfile.transcriptionConfig.apiKey` / `llmConfig.apiKey`. Two slots per profile, one for transcription and one for the LLM. No global by-family map; the desktop and mobile profiles each carry their own keys even when both use the same provider (deliberate: per-profile keys make per-function usage tracking easier). Persistence is in [src/secrets.ts](src/secrets.ts) using the key IDs `profile-desktop-transcription`, `profile-desktop-llm`, `profile-mobile-transcription`, `profile-mobile-llm`. These ids (and the namespace prefix `secrets.ts` prepends for `secretStorage`) are dash-joined, NOT colon-joined: Obsidian's `app.secretStorage.setSecret` throws on any id that is not lowercase-alphanumeric + dashes, so a colon/underscore id silently fails the availability probe on every platform. Do not reintroduce colons or underscores into a secret id.
API keys are stored per profile on `EnvironmentProfile.transcriptionConfig.apiKey` / `llmConfig.apiKey` / `realtimeConfig.apiKey`. Three slots per profile: transcription, LLM, and real-time (the last a separate key + model because streaming models/keys differ from batch; see Real-time transcription). No global by-family map; the desktop and mobile profiles each carry their own keys even when both use the same provider (deliberate: per-profile keys make per-function usage tracking easier). Persistence is in [src/secrets.ts](src/secrets.ts) via [src/settings/index.ts](src/settings/index.ts) using the key IDs `profile-desktop-transcription`, `profile-desktop-llm`, `profile-desktop-realtime`, and the three `profile-mobile-*` equivalents. These ids (and the namespace prefix `secrets.ts` prepends for `secretStorage`) are dash-joined, NOT colon-joined: Obsidian's `app.secretStorage.setSecret` throws on any id that is not lowercase-alphanumeric + dashes, so a colon/underscore id silently fails the availability probe on every platform. Do not reintroduce colons or underscores into a secret id.
Providers may optionally implement `listModels(config, signal)` returning a string array of model IDs the configured API key can access. Implemented by: OpenAI / Groq / Mistral (via `openai.ts` shared adapter), Mistral Voxtral (own adapter, filters Mistral's `/v1/models` catalog by ID substring `voxtral`), Anthropic, Gemini, Deepgram. Not implemented for `openai-compatible` (URL-specific, list-shape varies), AssemblyAI, Rev.ai. The settings tab caches results to `GlobalSettings.modelCache` per side and provider ID; the Refresh button in the model field triggers `listModels` and updates the cache. The model field is a single adaptive control (`populateModelField` in [src/settings/tab.ts](src/settings/tab.ts), shared by both sides via a `side: 'transcription' | 'llm'` arg): a **dropdown** when the provider supports `listModels` and the cache is non-empty, otherwise a **plain text field**. There is never both at once. The dropdown carries a trailing "Custom..." option (sentinel `CUSTOM_MODEL_OPTION`, never written to `config.model`) that toggles the same control into the text field (the `forceText` arg) so a model ID not in the catalog can still be typed; a "Back to list" button returns to the dropdown. A custom value not in the cache is shown as a selected `<id> (custom)` dropdown option. The Refresh button accompanies the dropdown (and the empty-cache text field, so the first fetch can flip it to a dropdown); on success the field re-renders in dropdown mode. `openai-compatible` / AssemblyAI / Rev.ai (no `listModels`) always show a plain text field with no Refresh. For AssemblyAI and Rev.ai the field description appends a "list of models" external link to the provider's docs (`transcriptionModelDocsUrl` + `applyModelFieldDesc` in [src/settings/tab.ts](src/settings/tab.ts)); `openai-compatible` has none (the model list is the user's own server). Whichever control is active, the canonical source is `profile.config.model`.
## Speaker diarization
Opt-in `Speaker X:` labels, supported only on `assemblyai` / `deepgram` / `revai`. Two switches: a per-profile `TranscriptionConfig.diarize` toggle and a per-template `NoteTemplate.diarize` override (frontmatter `diarize: true`, raises the effective setting only; the Meeting transcript default ships with it). Capability is gated by `transcriptionProviderSupportsDiarization(id)` ([src/transcription/index.ts](src/transcription/index.ts)); on a non-capable provider the flag is a documented no-op. Labels survive cleanup via a `DEFAULT_SHARED_CORE` clause.
Opt-in `Speaker X:` labels, supported only on `assemblyai` / `deepgram` / `revai`. Chosen **per invocation**, not as a persisted setting (a profile-wide toggle would wrongly diarize e.g. daily notes, producing stray `Speaker A` labels). Two inputs: the per-template `NoteTemplate.diarize` flag (frontmatter `diarize: true`; the Meeting transcript default ships with it) and the main modal's per-run **"Identify speakers"** checkbox (`renderDiarizeToggle` in [src/ui/modal.ts](src/ui/modal.ts), shown only when the active provider supports diarization, defaulting to the active template's flag and reset on template change). The pipeline ([src/pipeline.ts](src/pipeline.ts) `collectTranscript`) computes `effectiveDiarize = (template.diarize || params.diarize) && transcriptionProviderSupportsDiarization(id)` and always sets `transcriptionConfig.diarize` explicitly (so a stale value from an older `data.json` can't leak through). `PipelineParams.diarize` carries the modal toggle; it is threaded through the Record tab, `startRecordingPipeline`, and the "Record in background" handoff (`startBackgroundRecording` -> `startQuickRecord` -> `QuickRecordController`), and cleared when the Quick Record floater switches templates. There is **no** settings-tab diarization toggle and `TranscriptionConfig.diarize` is now purely the internal pipeline->adapter transport. Capability is gated by `transcriptionProviderSupportsDiarization(id)` ([src/transcription/index.ts](src/transcription/index.ts)); on a non-capable provider the flag is a documented no-op. Labels survive cleanup via a `DEFAULT_SHARED_CORE` clause.
Full detail (per-adapter formatting, pipeline merge rules, label survival) lives in [docs/DIARIZATION.md](docs/DIARIZATION.md).
## Local whisper.cpp host (desktop)
`WhisperHost` ([src/whisper-host.ts](src/whisper-host.ts), instantiated in `onload`, stopped in `onunload`) manages a user-supplied whisper-server child process. Config lives at `GlobalSettings.localWhisper = { binaryPath, modelPath, port, extraArgs }`: 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.
`WhisperHost` ([src/whisper-host.ts](src/whisper-host.ts), instantiated in `onload`, stopped in `onunload`) manages a user-supplied whisper-server child process. Config lives at `GlobalSettings.localWhisper = { binaryPath, modelPath, port, extraArgs, autoStart, idleStopMinutes }`: no auto-discovery, no auto-download, no baked defaults. Desktop-only (Node modules lazy-required behind `Platform.isDesktop`; the option is filtered out of mobile dropdowns). **Loopback binding is enforced** (a non-loopback `--host` throws before spawn) because whisper-server has no auth/TLS; `getHostArgs` collects EVERY `--host` occurrence in `extraArgs` (not just the first), since whisper-server honors the last one and checking only the first let a duplicate `--host 127.0.0.1 --host 0.0.0.0` bypass the guard. The `whisper-local` transcription provider ([src/transcription/whisper-local.ts](src/transcription/whisper-local.ts)) is a thin shim POSTing transcoded 16 kHz mono WAV to `http://127.0.0.1:<port>/inference`; no API key.
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).
Phase B lifecycle (both knobs default off): `autoStart` starts the server after `onload`'s probe settles (only when the active profile uses `whisper-local` and the probe found nothing to adopt); `idleStopMinutes > 0` stops a ReWrite-owned (spawned/adopted, NEVER external) server after that many minutes without a transcription, via a 60 s interval calling `whisperHost.stopIfIdle()`. The decision logic is the pure `shouldStopWhenIdle(...)` (unit-tested); the `whisper-local` adapter brackets each transcription with `beginUse()`/`endUse()` so an in-flight job blocks the stop and resets the idle clock.
Full detail (start/stop/probe lifecycle, the spawned/adopted/external ownership model, PID sidecar, Auto-detect, the build script, the Phase B auto-start/idle-stop wiring, and all whisper-host gotchas) lives in [docs/WHISPER_HOST.md](docs/WHISPER_HOST.md).
## Settings
`GlobalSettings` (defined in [src/types.ts](src/types.ts)) is the shape of `data.json`. Loading flow:
1. `plugin.loadData()` returns `Partial<GlobalSettings> | null`.
2. `mergeSettings(DEFAULT_SETTINGS, stored)` deep-merges, preferring stored values. Enum fields (`recordingFormat`, `activeProfileOverride`, `newFileCollisionMode`, `transcriptionProvider`, `llmProvider`) are validated against an allowlist and fall back to the base value on an unrecognized string; nested config objects (`localWhisper`, `modelCache`, `transcriptionConfig`, `llmConfig`) are only spread when they're actually plain objects. 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`.
2. `mergeSettings(DEFAULT_SETTINGS, stored)` deep-merges, preferring stored values. Enum fields (`recordingFormat`, `activeProfileOverride`, `newFileCollisionMode`, `transcriptionProvider`, `llmProvider`) are validated against an allowlist and fall back to the base value on an unrecognized string; nested config objects (`localWhisper`, `modelCache`, `transcriptionConfig`, `llmConfig`) are only spread when they're actually plain objects. The two array fields (`disabledDefaultTemplateIds`, `ingestRules`) go through `sanitizeDisabledIds` / `sanitizeIngestRules` (exported, tested): a non-array falls back to base, and malformed elements are dropped (a rule needs a non-blank `folderPath` and `templateId`; `enabled` is coerced to a strict boolean). All of this guards against a corrupt/hand-edited `data.json` leaking garbage into runtime state (a non-object partial field would otherwise spread its characters into the nested config).
3. `hydrateSecrets()` reads keys from `secrets.json.nosync` and writes them into each profile's `transcriptionConfig.apiKey` / `llmConfig.apiKey` / `realtimeConfig.apiKey`.
Saving flow strips secrets out of `data.json` and writes them to `secrets.json.nosync` instead (see [src/secrets.ts](src/secrets.ts)). Never persist API keys to `data.json`.
@ -196,9 +209,9 @@ Full detail (envelope schema, KDF params, lock/unlock flow, opportunistic PBKDF2
## Templates
Templates are Markdown files in a vault folder, not entries in `data.json`. The folder path lives on `GlobalSettings.templatesFolderPath` (default `ReWrite/Templates`). Each `.md` file in the folder is one template: YAML frontmatter holds `id`, `name`, `insertMode`, `newFileFolder`, `newFileNameTemplate`, plus the three optional boolean flags `disableSharedCore`, `enableContextHint`, and `diarize`; the file body is the LLM prompt. Files are sorted by basename in the modal/picker so users can prefix names (`01-...`, `02-...`) to control order.
Templates are Markdown files in a vault folder, not entries in `data.json`. The folder path lives on `GlobalSettings.templatesFolderPath` (default `ReWrite/Templates`). Each `.md` file in the folder is one template: YAML frontmatter holds `id`, `name`, `insertMode`, `newFileFolder`, `newFileNameTemplate`, the optional boolean flags `disableSharedCore`, `enableContextHint`, `diarize`, and `titleFromContent`, plus the tri-state `managed` ownership marker (see Managing individual defaults below); the file body is the LLM prompt. Files are sorted by basename in the modal/picker so users can prefix names (`01-...`, `02-...`) to control order.
[src/templates-folder.ts](src/templates-folder.ts) exports `loadTemplatesFromFolder(app, folderPath)`, `populateDefaultTemplates(app, folderPath)`, and `updateDefaultTemplates(app, folderPath)` (see Updating defaults below). The plugin keeps a cache on `plugin.templates: NoteTemplate[]`, refreshed in [src/main.ts](src/main.ts) on:
[src/templates-folder.ts](src/templates-folder.ts) exports `loadTemplatesFromFolder(app, folderPath)`, `populateDefaultTemplates(app, folderPath, disabledIds?)`, and `updateDefaultTemplates(app, folderPath, disabledIds?)` (see Updating defaults below; `disabledIds` = `new Set(settings.disabledDefaultTemplateIds)` at both call sites). The plugin keeps a cache on `plugin.templates: NoteTemplate[]`, refreshed in [src/main.ts](src/main.ts) on:
- `workspace.onLayoutReady` after `onload` (initial load, after the vault is ready)
- vault `create` / `modify` / `delete` events scoped to the templates folder via `isPathInTemplatesFolder`
- vault `rename` (checks both old and new path)
@ -213,6 +226,13 @@ The 10 defaults ([src/settings/default-templates.ts](src/settings/default-templa
`NoteTemplate.disableSharedCore?: boolean` (frontmatter `disableSharedCore: true`) opts a single template out of the shared-core prepend. `renderTemplateFile` ALWAYS emits the key so the knob is discoverable: empty (`disableSharedCore:`, parses to null = not disabled) by default, `disableSharedCore: true` when set. `parseTemplateFile` treats it as disabled only when the value is boolean `true` or the string `"true"` (case-insensitive) — the string form is tolerated because Obsidian's Properties UI may store an edited value as text; any other value (null/empty/false) means not disabled. The three opt-in boolean flags `enableContextHint` (see Context hint), `diarize` (see Speaker diarization), and `titleFromContent` (see Note title) follow the exact same render/parse convention (always-emitted empty key, boolean-or-`"true"` tolerance); their polarity is positive (set to turn ON), the reverse of `disableSharedCore`.
### Managing individual defaults (managed flag + disable list)
Two per-default controls, surfaced as the "Manage built-in templates" `<details>` checklist in the Templates settings section (`renderManageDefaults` in [src/settings/tab.ts](src/settings/tab.ts), expand state on `manageDefaultsExpanded`; the list is driven off `freshDefaultTemplates()` so it tracks the default set). The two controls are deliberately different affordances so their roles read without a hover: **Enabled** is a toggle switch with an inline caption that reads "Enabled" when on and "Disabled" when off (the whole tab re-renders on toggle, so the caption reflects the current state); **Tracked** is a real labelled checkbox (a manual `<input type="checkbox">` appended to the row's `controlEl`, with its caption span placed BEFORE the input so the label reads left-to-right; the caption reads "Tracked" when checked and "Untracked" when unchecked, mirroring the Enabled/Disabled switch; not a second `addToggle`, which would look identical to the Enabled switch). A legend above the list bolds both terms, and the `.rewrite-manage-*` CSS lays the row out:
- **Enabled / disabled (existence).** Disabling removes the on-disk file (matched by frontmatter `id` via `findTemplateFileById`, deleted with `app.fileManager.trashFile` behind a `ConfirmModal`) and records the id in `GlobalSettings.disabledDefaultTemplateIds`, which Populate and Update both receive as `disabledIds`: neither ever (re)creates a disabled default, including a future plugin version re-shipping it. Decision (documented): the ignore list is keyed by `id` only, NOT also by name — the `managed` flag already prevents a name-colliding user template from being touched, so name suppression would add ambiguity without protection. Re-enabling removes the id from the list and immediately recreates the file via `restoreDefaultTemplate` (same name/path-collision skip as Populate). The `ConfirmModal` gained an optional `onCancel` hook ([src/ui/confirm-modal.ts](src/ui/confirm-modal.ts), fired from `onClose` when not confirmed) so the settings tab can re-render and reset the optimistically-flipped toggle when the user backs out.
- **Tracked / untracked (updates), via `NoteTemplate.managed`.** `managed` is TRI-state, unlike the other flags: `true` (plugin-managed; Populate/Update stamp it on every file they create or reconcile), `false` (untracked: Update skips the file entirely, counted in `UpdateResult.untracked`), `undefined` (key absent/empty — treated as tracked when the id matches a built-in, preserving pre-flag behavior). `parseTemplateContent` uses `parseTriStateFlag` (boolean or string `"true"`/`"false"`); `renderTemplateFile` emits `managed: true` / `managed: false` / the empty `managed:` stub for undefined. Because Update's file-matching is now "id matches a built-in AND the file is not `managed: false`", and because only Populate/Update ever write `managed: true`, a user's own hand-authored template that coincidentally shares a name with a built-in is never clobbered. The untrack toggle edits just the frontmatter key via `app.fileManager.processFrontMatter` (preserves the body byte-for-byte) rather than re-rendering the whole file.
The Templates "Populate" button also seeds SharedCore.md when missing (non-destructive), because the shared core is load-bearing for the default templates' quality (it carries their guardrail + output discipline). Populating templates without it would yield prompts with no guardrail. (Populate no longer seeds an in-vault help file; the user-facing template guide lives in the wiki.) **Keep `wiki/Creating-Templates.md` in sync with the template structure.** Whenever you add or change a `NoteTemplate` field / frontmatter property, change the prompt-assembly order, alter what the shared core carries, or change the default-template set or count, update that wiki page in the SAME change: add the property to its frontmatter table AND give the behavior its own `##` section. The settings tab links users to it from the Templates section ("Creating templates"); it is the user-facing contract for the template format, so treat updating it as part of the task, not a follow-up.
### Updating defaults
@ -221,9 +241,9 @@ The Templates section ships three buttons sharing one `Setting` row: **Populate*
**Default history.** [src/settings/template-history.ts](src/settings/template-history.ts) holds `TEMPLATE_HISTORY: Record<string, TemplateVersionSnapshot[]>` — the plugin's in-code memory of what each default *used to be*, keyed by template id, each snapshot carrying `{ version, template }`. It is the base for the merge and the source for Load prior versions. **It starts EMPTY.** MAINTENANCE RULE (treat like the keep-the-guide-in-sync rule): whenever you change a default in [src/settings/default-templates.ts](src/settings/default-templates.ts), append the OUTGOING template to `TEMPLATE_HISTORY[id]` under the manifest version it shipped in. Without the snapshot, Update cannot tell an unedited old prompt from a user edit and falls back to a body conflict. `priorVersionsForId(id)` and `allPriorVersions()` return clones so callers can't mutate the registry.
**Per-field 3-way merge.** `mergeTemplate(onDisk, def, priors)` (pure, synchronous; `priors` = `priorVersionsForId(id)`) treats a field as *pristine* (user never touched it) when its on-disk value equals the current default OR any prior shipped default; a pristine value is brought forward to the current default, a genuine edit is kept. The **prompt body** is the only field whose kept edit becomes a report `body` conflict — an unedited old body is silently updated (recorded in `changes`). Scalars + flags adopt-or-keep silently (a real adoption is noted in `changes`). `noteProperties` union by name: missing defaults appended; an instruction matching a prior default is brought forward; a genuinely edited instruction is kept + `changedInstruction`; a property the default dropped is always kept + `removedProperty` (with `wasShippedDefault` set when the user's value matches a prior default, so the report can say it is safe to delete). Because `renderTemplateFile` always emits the four flag stubs and emits `noteProperties` only when non-empty, re-rendering a pristine current default is byte-identical to Populate's output, so an already-current folder is a clean no-op.
**Per-field 3-way merge.** `mergeTemplate(onDisk, def, priors)` (pure, synchronous; `priors` = `priorVersionsForId(id)`) treats a field as *pristine* (user never touched it) when its on-disk value equals the current default OR any prior shipped default; a pristine value is brought forward to the current default, a genuine edit is kept. The **prompt body** is the only field whose kept edit becomes a report `body` conflict — an unedited old body is silently updated (recorded in `changes`). Scalars + flags adopt-or-keep silently (a real adoption is noted in `changes`). `noteProperties` union by name: missing defaults appended; an instruction matching a prior default is brought forward; a genuinely edited instruction is kept + `changedInstruction`; a property the default dropped is always kept + `removedProperty` (with `wasShippedDefault` set when the user's value matches a prior default, so the report can say it is safe to delete). `mergeTemplate` stamps `managed: true` on its result (it only runs on tracked default-derived files, so this also back-fills the flag onto pre-flag files, at the cost of one rewrite). Because `renderTemplateFile` always emits the flag stubs and emits `noteProperties` only when non-empty, re-rendering a pristine current default is byte-identical to Populate's output, so an already-current folder is a clean no-op.
`updateDefaultTemplates` walks the folder, matches files to defaults by frontmatter `id` (via `readTemplateId`, also now backing `collectExistingIds`; non-default-derived files skipped), merges, and `vault.modify`s in place only when the render differs (CRLF-normalized compare; never renames, even with an ordering prefix). It then **recreates any default missing from disk entirely** (superset top-up, same name + path-collision skip as Populate) but does NOT seed SharedCore.md (Populate's job). Counters are mutually exclusive: `created` + `updated` + `unchanged` + `conflicts` + `parseFailed` = files seen. A default-derived file that fails to parse is left untouched and reported as `parseFailed`.
`updateDefaultTemplates` walks the folder, matches files to defaults by frontmatter `id` (via `readTemplateId`, also now backing `collectExistingIds`; non-default-derived files skipped, disabled ids skipped, `managed: false` files skipped as `untracked`), merges, and `vault.modify`s in place only when the render differs (CRLF-normalized compare; never renames, even with an ordering prefix). It then **recreates any default missing from disk entirely** (superset top-up, same name + path-collision skip as Populate, disabled ids excluded) but does NOT seed SharedCore.md (Populate's job). Counters are mutually exclusive: `created` + `updated` + `unchanged` + `conflicts` + `parseFailed` + `untracked` = files seen. A default-derived file that fails to parse is left untouched and reported as `parseFailed`; an untracked (`managed: false`) file is counted only in the `untracked` tally and gets no per-template entry in `Template update report.md` (freezing it is the user's explicit choice, not a conflict).
**Load prior versions** writes each `allPriorVersions()` snapshot as a standalone template with a distinct id (`<id>@<version>`) and versioned name (`<name> <version>`), so it appears in the picker for comparison, never collides with the live template's identity, and is left untouched by Update (its id is not a current default) and Populate. Non-destructive (skips by id/path); reports `available` so the button can say "no prior versions yet" when the registry is empty.
@ -276,6 +296,29 @@ Two halves, deliberately decoupled:
UI surfaces, both a collapsed `<details>` so the frictionless path is untouched: the main modal ([src/ui/modal.ts](src/ui/modal.ts) `renderContextSelector`, shown only when the active template has the flag; `contextHint` / `contextExpanded` instance state reset on template change, survives re-renders like `destinationExpanded`) and the reprocess-audio picker ([src/ui/template-picker.ts](src/ui/template-picker.ts), via `showContext` which [src/main.ts](src/main.ts) sets to `templates.some(t => t.enableContextHint)`; the typed value is forwarded to `runAudioFilePipeline` only when the picked template has the flag). Quick Record and the process-text command intentionally skip it.
## Real-time transcription
Opt-in live dictation, deliberately minimal: it streams the mic to the provider over a WebSocket and types finalized segments at the editor cursor as they arrive. No template, no LLM cleanup, no audio persistence, no main-modal entry — just the `rewrite-voice-notes:realtime-transcribe` command (see Commands). Lower-value than the rest of the pipeline (a raw transcript with no cleanup is what Obsidian's existing voice plugins already do), shipped because the primitives were cheap once the streaming adapters existed.
Provider support is gated by `transcriptionProviderSupportsRealtime(id)` ([src/realtime/index.ts](src/realtime/index.ts)): only `deepgram` and `assemblyai` expose a browser-reachable streaming WebSocket. Every other provider (OpenAI/Groq whisper-shape, Rev.ai async, whisper.cpp server) accepts only whole-file uploads, so the command errors with a "use AssemblyAI or Deepgram" Notice on them. **Voxtral realtime was tried and pulled** (see the Voxtral bullet below): its adapter is kept on disk but is deliberately NOT in the gate or the factory, so it is neither bundled nor selectable.
**Realtime is configured ENTIRELY independently of batch transcription** — its own provider, key, and model — so a profile can run e.g. Voxtral for batch, AssemblyAI for realtime, and Anthropic for cleanup. `EnvironmentProfile.realtimeProvider` (a `TranscriptionProviderID`; `'none'` = off, else a realtime-capable id) plus `realtimeConfig` (a `TranscriptionConfig` shape; only `apiKey`/`model` used). Reason: streaming providers/models/keys differ from batch. The key is persisted under secret id `profile-{kind}-realtime` (see Provider system / docs/SECRETS.md), hydrated/stripped/merged alongside the transcription and LLM slots; `realtimeProvider` is validated by `pickEnum` in `mergeProfile`. The settings tab renders an independent "Real-time transcription" section — a **Real-time provider** dropdown (None + realtime-capable only) that on change re-renders, plus key + model when a provider is picked (`renderRealtimeSection` in [src/settings/tab.ts](src/settings/tab.ts)). The model field is the same adaptive control as the batch fields (`populateModelField(..., 'realtime')`: dropdown + Refresh where the provider supports `listModels`, e.g. Deepgram; a plain text field otherwise, e.g. AssemblyAI), reading `profile.realtimeProvider` / `realtimeConfig` and sharing the transcription model cache. `startRealtimeTranscription` and `RealtimeController.begin()` gate on `realtimeProvider` (not `transcriptionProvider`) and `realtimeConfig.apiKey`.
- **Capture** ([src/realtime/pcm.ts](src/realtime/pcm.ts)): `PcmCapture` taps the mic with a `ScriptProcessorNode` (deprecated but universally available; `AudioWorklet` would need a separate module file a single-bundle plugin can't ship under the app CSP — the deprecation is `eslint-disable`d with that rationale inline), downsamples the context rate to 16 kHz via the pure `downsampleBuffer` (linear interpolation), quantizes to signed-16 PCM via the pure `floatTo16BitPcm`, and emits `ArrayBuffer` chunks. Both helpers and `REALTIME_SAMPLE_RATE` are exported and unit-tested. The node is routed through a zero-gain node to `destination` (a ScriptProcessor only runs while connected) so nothing is audible.
- **Session interface** ([src/realtime/index.ts](src/realtime/index.ts)): `RealtimeProvider.start(config, sampleRate, callbacks) -> RealtimeSession`, where `RealtimeSession` is `{ sendAudio(chunk), stop() }` and callbacks are `onFinal` / `onInterim` / `onError` / `onUnexpectedClose`. `waitForOpen` / `waitForClose` are shared WS plumbing.
- **Deepgram** ([src/realtime/deepgram.ts](src/realtime/deepgram.ts)): auth rides the `['token', <key>]` WebSocket subprotocol (browser WebSockets can't set headers), so the key never touches the URL. `is_final` marks a finalized segment; the profile's batch model (nova family) generally works live, empty falls to Deepgram's default. `stop()` sends `{ type: 'CloseStream' }` to flush.
- **AssemblyAI** ([src/realtime/assemblyai.ts](src/realtime/assemblyai.ts)): browser WS can't send the API key as a header and AssemblyAI has no subprotocol auth, so the real key first buys a short-lived (60 s) single-use token over a normal header-authed `jsonGet`, and only that ephemeral token rides the WS URL query. **This is the one sanctioned exception to the "auth never goes in the query" rule** (documented in the HTTP gotcha too): the token expires fast, can't mint more, and no error path echoes the URL. `format_turns: true` re-sends each turn once formatted; only the formatted end-of-turn is treated as final so a turn isn't inserted twice. `stop()` sends `{ type: 'Terminate' }`.
- **Voxtral** ([src/realtime/voxtral.ts](src/realtime/voxtral.ts)): **UNWIRED as of 1.2.0.** The adapter exists (protocol reverse-engineered from the mistralai Python SDK `src/mistralai/extra/realtime/`: `wss://api.mistral.ai/v1/audio/transcriptions/realtime?model=<model>`, `session.update` then base64 `input_audio.append` split at the 262144-decoded-byte cap, `flush`/`end` on stop, `transcription.text.delta`/`transcription.done` back) but live testing confirmed the AUTH CAVEAT: the browser WebSocket can only send the key via the `['token', <key>]` subprotocol (no `Authorization: Bearer` header from a WebView), and Mistral's realtime endpoint rejects that handshake, so Voxtral realtime is not WebView-reachable. The file is kept on disk, documented, as a starting point for a contributor who finds a working browser-auth path; it is NOT referenced by `createRealtimeProvider` or `transcriptionProviderSupportsRealtime`, so it is not bundled or selectable. To re-enable, wire it back into both. See `wiki/Voxtral-Realtime.md`.
- **Controller** ([src/ui/realtime.ts](src/ui/realtime.ts)): `RealtimeController` owns the capture + session + a floating status bar (reuses the `.rewrite-quick-floater` shell + `.rewrite-realtime-interim` line). `insertFinal` uses `editor.replaceSelection(text + ' ')` so segments chain like typing. `finish()` is a graceful stop (end capture, let trailing finals flush, dispose); `cancel()` is a hard teardown for errors/unload. One session at a time, owned by the plugin via `activeRealtime` + `realtimeStarting` (mirrors `activeQuickRecord`), cancelled in `onunload`. `startRealtimeTranscription` gates on unlocked secrets, a realtime-capable provider, a key, capture availability, and an open Markdown editor (the transcript needs a cursor).
## Auto-ingest folders
Semi-automatic batch ingestion for audio captured outside Obsidian and dropped into a vault folder. A user-run command (NOT a live watcher — a `vault.on('create')` watcher would fire for every existing file on vault load, a "startup storm"), so the user controls when a batch runs. Config is `GlobalSettings.ingestRules: { folderPath, templateId, enabled }[]`.
[src/ingest.ts](src/ingest.ts) `runIngestBatch(plugin)` gates on enabled rules existing, unlocked secrets, and a fully-configured voice profile (same checks as manual reprocess), then `collectWork` (async) resolves each enabled rule's folder + template. A rule is skipped (with a Notice) when its template is missing or is not `insertMode: 'newFile'``isIngestTemplate` (exported, tested) enforces newFile-only because unattended ingest has no active editor, so `cursor`/`append` would silently cascade to `newFile` anyway; requiring it makes the destination explicit. A rule is ALSO skipped up front when its folder is where recordings would be stored (see the same-folder guard below). `collectIngestFiles` (exported, tested) takes only direct `TFile` children filtered to `AUDIO_EXTENSIONS`, sorted by name (no recursion, so organized subfolders aren't swept up); a path seen by two rules is queued once. The batch runs **serialized** (one pipeline at a time) behind a sticky `Notice` with a Cancel button (`AbortController`), reporting a per-batch "N processed, M failed" summary.
Each file runs the pipeline with `source: { kind: 'audio', audio, sourcePath: file.path }` (skips `persist-audio`, reuses the path for the `![[embed]]` prepend, exactly like reprocess). **Move-on-success is the dedupe** (and the requested last step): after a fully successful run, `moveIngestedFile` moves the recording to the attachments location via `app.fileManager.renameFile` (which rewrites the just-created note's embed to the new path automatically), so a re-run never reprocesses it — no processed-path bookkeeping in `data.json`. A file that fails stays in the ingest folder and is retried next run (failures don't move). **Same-folder guard, two layers:** the move would be a pointless in-place de-collision rename (and the file would be re-ingested every run) whenever the ingest folder IS the attachments/recordings location. `collectWork` catches this UP FRONT, before creating any note: it resolves where recordings would land for the rule's template via the side-effect-free `resolveAttachmentFolder` ([src/audio-persist.ts](src/audio-persist.ts), the folder-only sibling of `resolveAttachmentPath` that does NOT create folders) and skips the rule with a Notice when that folder equals the rule folder (`normalizeIngestFolder`, exported/tested, normalizes root + trailing-slash variants before comparing). `moveIngestedFile` keeps the original per-file backstop (`ingestTargetIsSameFolder`) for the case only knowable at move time (empty attachments setting + note-relative Obsidian setting), throwing a reported error rather than churning the file. Ships on all platforms including mobile-only (a user-run file-move command, no cross-device double-processing hazard, so no `Platform.isDesktop` gate). Rule creation/editing is a popup ([src/ui/ingest-rule-modal.ts](src/ui/ingest-rule-modal.ts), template dropdown filtered to newFile templates); the settings "Auto-ingest folders" section lists rules with enable toggle / Edit / Delete plus an Add button. `resolveAttachmentPath` was extracted from `persistAudio` in [src/audio-persist.ts](src/audio-persist.ts) so the mover resolves the same destination a live recording would.
## Commands
Registered in [src/main.ts](src/main.ts):
@ -285,6 +328,8 @@ Registered in [src/main.ts](src/main.ts):
- **`rewrite-voice-notes:quick-record-fixed`** ("Quick record (set template)"): same flow, but records with the template chosen in `GlobalSettings.quickRecordTemplateId` (Settings dropdown beside Default template). If that id is unset or no longer resolves, it shows a "choose a Quick record template in settings" `Notice` and does not start (no `templates[0]` fallback, unlike the last-used command). Both commands share `toggleQuickRecord(opts?)` and the single `activeQuickRecord` ref, so either one stops an in-flight recording. The floating UI shows a stop-hotkey hint ("Press <combo> or click Stop") read live from the command's binding; see the Gotcha on `hotkeyManager`.
- **`rewrite-voice-notes:process-text`** ("Process text with template"): runs a template over the active editor's selection (or the whole note body if there's no selection). Opens a template quick-picker, then runs the pipeline in the background with progress shown via `Notice`. Gates on LLM-only configuration; opens the main modal's setup card when not configured. Bails with a Notice when no Markdown editor is active.
- **`rewrite-voice-notes:reprocess-audio`** ("Reprocess audio file with template"): reruns the pipeline over an audio file already in the vault. Opens an `AudioFilePickerModal` (`FuzzySuggestModal<TFile>` filtered to `AUDIO_EXTENSIONS` from [src/audio-persist.ts](src/audio-persist.ts)) then the template quick-picker, then calls `runAudioFilePipeline` in [src/ui/audio-source.ts](src/ui/audio-source.ts). The pipeline skips its `persist-audio` stage because the `audio` source variant carries a `sourcePath` (the existing vault path is reused for the `![[<path>]]\n\n` prepend). Gates on the full voice profile (`isProfileConfigured`).
- **`rewrite-voice-notes:process-ingest-folders`** ("Process auto-ingest folders"): runs the auto-ingest batch (`runIngestBatch` in [src/ingest.ts](src/ingest.ts)). See Auto-ingest folders above. Plain `callback` (available everywhere, including mobile-only vaults).
- **`rewrite-voice-notes:realtime-transcribe`** ("Real-time transcription (start/stop)"): toggles live dictation (`toggleRealtime` → `startRealtimeTranscription` in [src/ui/realtime.ts](src/ui/realtime.ts)). Second press (or the floating bar's Stop) ends the session. Routed through the single `activeRealtime` slot + `realtimeStarting` guard. See Real-time transcription above.
- **`rewrite-voice-notes:start-whisper-host`** / **`rewrite-voice-notes:stop-whisper-host`**: start or stop the local whisper.cpp server. Both use `checkCallback` so the palette only shows them on desktop, when the active profile's transcription provider is `whisper-local` (start) or when the host is currently `running` / `starting` (stop). Errors surface via `Notice`. Same code paths as the settings-tab Start/Stop button.
Plus an editor-menu item "ReWrite with template..." registered via `workspace.on('editor-menu', ...)` (and a second "Reprocess audio with template..." item that appears only when the cursor sits inside an `![[<audio>]]` embed, resolved via `app.metadataCache.getFirstLinkpathDest`), a `workspace.on('file-menu', ...)` handler that adds "Reprocess audio with template..." for audio files in the file explorer, an `addRibbonIcon('mic', 'ReWrite', ...)` that opens the modal, and a status-bar item ([src/ui/whisper-status-bar.ts](src/ui/whisper-status-bar.ts)) showing the live whisper-host status. The status bar polls `whisperHost.status()` every 1 s via `registerInterval`, click toggles start/stop, and the item is hidden via the `rewrite-hidden` CSS class when on mobile or when the active profile is not `whisper-local`.
@ -311,7 +356,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
- **`requestUrl` multipart bodies are hand-built.** `requestUrl` does not accept `FormData`. [src/http.ts](src/http.ts) exports `buildMultipart(parts)` which produces a `Uint8Array` with a random boundary; transcription adapters (Whisper, Rev.ai) call into it. If you add a multipart-LLM provider, reuse this rather than reaching for `FormData`.
- **`requestUrl` uses `throw: false` + status check.** All adapters surface non-2xx as `ProviderError` with `status` and `body`, so users see provider-attributed errors instead of opaque network failures.
- **Provider auth always goes in a header, never the URL query.** Every adapter passes its key via a header (`Authorization: Bearer`, `x-api-key`, `Token`, and `x-goog-api-key` for Gemini). Do not add a `?key=`/`?token=` query-auth provider: query strings leak into proxy/CDN logs, history, and `requestUrl`'s network-failure message. As a backstop, the network-failure `catch` in [src/http.ts](src/http.ts) `providerRequest` runs `redactQueryStrings` over the message (replaces any `?...` with `?<redacted>`) before building the `ProviderError`, so even a future query-auth slip cannot surface a secret in a Notice or log. The `body.slice(0, 200)` truncation in the `ProviderError` constructor is unrelated and left as-is (response bodies do not carry the request key).
- **Provider auth always goes in a header, never the URL query.** Every adapter passes its key via a header (`Authorization: Bearer`, `x-api-key`, `Token`, and `x-goog-api-key` for Gemini). Do not add a `?key=`/`?token=` query-auth provider: query strings leak into proxy/CDN logs, history, and `requestUrl`'s network-failure message. As a backstop, the network-failure `catch` in [src/http.ts](src/http.ts) `providerRequest` runs `redactQueryStrings` over the message (replaces any `?...` with `?<redacted>`) before building the `ProviderError`, so even a future query-auth slip cannot surface a secret in a Notice or log. The `body.slice(0, 200)` truncation in the `ProviderError` constructor is unrelated and left as-is (response bodies do not carry the request key). **One sanctioned exception:** the AssemblyAI realtime adapter ([src/realtime/assemblyai.ts](src/realtime/assemblyai.ts)) puts a token in the WebSocket URL query, because browser WebSockets cannot set an Authorization header. It is NOT the API key: the real key (sent as a header) first mints a short-lived, single-use 60 s token, and only that expiring token rides the query. Do not generalize this to the REST adapters, and do not put a real long-lived key in a query under any circumstance.
### Pipeline and provider system
@ -350,12 +395,29 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
- **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.
- **A screen wake lock is held for the duration of active recording.** Android (and iOS) suspend the Obsidian Capacitor WebView when the screen sleeps, which kills `MediaRecorder` capture mid-recording. [src/recorder.ts](src/recorder.ts) requests `navigator.wakeLock.request('screen')` in `start()` (and re-acquires in `resume()`), releases it in `pause()`, and tears it down via `stopWakeLock()` from the shared `releaseStream()` (so both `stop()` and `cancel()` cover it). The OS auto-releases a screen wake lock whenever the document becomes hidden, so a `visibilitychange` listener (registered in `startWakeLock`, removed in `stopWakeLock`) re-requests on the next `visible` transition while `state === 'recording'`. The acquire is best-effort and degrades silently: where the Wake Lock API is absent (older WebView, desktop builds, insecure context) or denied (`NotAllowedError`), recording proceeds without it. The API is reached through a narrow local `WakeLockLike` / `WakeLockSentinelLike` interface + `getWakeLock()` cast (it is not in every TS DOM lib version), mirroring the `hotkeyManager` pattern. Cost: the screen stays lit while recording (a partial CPU-only wake lock would need native code Obsidian doesn't expose to plugins). A `stop`/`cancel` that races ahead of the async `request('screen')` is handled by re-checking `state === 'recording'` before retaining the sentinel. The wake lock only prevents screen-sleep; it cannot stop an app switch from backgrounding (and suspending) the WebView, so both recording UIs additionally show a **mobile-only caution** for the duration of capture (`MOBILE_RECORD_WARNING_TEXT`, "Keep Obsidian in the foreground..."): `.rewrite-mobile-record-warning` in the modal's Record tab and `.rewrite-quick-mobile-warning` in the Quick Record floater, both shown only when `Platform.isMobile`, shown on record-start, hidden on stop / when the floater goes busy. Styled yellow (`--text-warning` outline + text, not the red `--background-modifier-error` of the silence warning) so it reads as an alert, not an error.
- **Live silence detection is a Web Audio tap on the recording stream.** [src/recorder.ts](src/recorder.ts) builds an `AnalyserNode` off the mic `MediaStream` in `start()` (no connection to `destination`, so no monitoring feedback) and samples peak amplitude every 100 ms via a `window.setInterval`. It exposes `getInputLevel()` (0..1 peak), `hasDetectedSound()`, and `getSilentMs()` (ms of continuous silence; returns 0 while paused / stopped or when the analyser could not be created, so the UI never warns when there is nothing to listen to). `resume()` resets the silence baseline so the paused gap is not counted. Both recording UIs poll `getSilentMs()` in their existing 250 ms timer loop and show a "No audio detected" warning past `SILENCE_WARNING_MS` (3 s): the modal's Record tab (`.rewrite-silence-warning`, [src/ui/modal.ts](src/ui/modal.ts)) and the Quick Record floater (`.rewrite-quick-silence-warning` below the controls row, [src/ui/quick-record.ts](src/ui/quick-record.ts) `setSilenceWarning`, hidden while busy). The monitor is torn down in `releaseStream()` (called by `stop()`/`cancel()`); any setup failure (no `AudioContext`, e.g. a stripped environment) degrades to "monitoring unavailable" and the recording proceeds without a warning. The threshold `SILENCE_LEVEL_THRESHOLD = 0.015` clears even quiet speech but flags a muted or dead mic.
### Real-time transcription
- **Realtime capture uses `ScriptProcessorNode`, not `AudioWorklet`.** The deprecation is deliberate and `eslint-disable`d with an inline rationale ([src/realtime/pcm.ts](src/realtime/pcm.ts)): an `AudioWorklet` processor must be loaded from a separate module file via `addModule(url)`, which a single-file bundled Obsidian plugin cannot ship and the app's CSP complicates via blob URLs. ScriptProcessor is supported in every WebView Obsidian runs in. If it is ever actually removed, that is the point to revisit — do not preemptively swap in a worklet.
- **Realtime auth cannot use a header (browser WebSocket limitation).** Deepgram takes the key via the `['token', <key>]` subprotocol (the key stays off the URL). AssemblyAI has no subprotocol auth, so it mints a short-lived single-use token over a normal header-authed request and puts only that token in the WS query — see the HTTP query-auth gotcha. Do not "simplify" either adapter to put the real key in the URL.
- **Realtime is one session at a time, plugin-owned.** `activeRealtime` + `realtimeStarting` mirror `activeQuickRecord` / `quickRecordStarting`; `onunload` calls `activeRealtime?.cancel()`. A realtime session and a Quick Record session are independent slots — they can technically both run — but each is individually single-instance. If you ever need them mutually exclusive, gate at `toggleRealtime` / `launchQuickRecord`.
- **Interim vs final segments.** Only `onFinal` text is inserted into the note; `onInterim` is display-only on the floating bar (superseded by later events). Treating an interim as final would double-insert or insert unpunctuated text. Both adapters map their provider's "formatted end of turn" / `is_final` to `onFinal`.
### Auto-ingest
- **Move-on-success IS the dedupe; there is no processed-path bookkeeping.** A successfully processed file leaves the ingest folder (moved to the attachments location via `app.fileManager.renameFile`, which also fixes the new note's `![[embed]]`), so a re-run can't see it again. A file that fails stays put and is retried next run. Do not add a "processed paths" list to `data.json` to compensate — the move is the whole mechanism, and it also gives the user the requested "recording ends up with the other recordings" behavior for free.
- **`renameFile` does NOT create the destination folder** (unlike `vault.create` / `createBinary`), so `resolveAttachmentPath` in [src/audio-persist.ts](src/audio-persist.ts) now `ensureParentFolder`s the resolved path before returning, and `moveIngestedFile` passes the just-created note's path as the `sourcePath` arg to `getAvailablePathForAttachment` so a note-relative attachment setting resolves correctly. Missing either was a real move-time failure mode. `resolveAttachmentPath` gained the optional `sourcePath` param for this; the recording flow passes none (harmless — no note yet).
- **A move failure after a successful pipeline is reported distinctly.** The note WAS created, so re-running would duplicate it. `ingestOne` wraps the `moveIngestedFile` call and rethrows a message that says the note was created and the user must move/delete the source themselves. The move-time same-folder backstop (destination folder equals the file's current folder — the ingest folder IS the attachments location) throws the same class of error rather than silently returning, so it surfaces in the batch's per-file error report instead of a lone Notice that could be missed.
- **The same-folder misconfiguration is now caught up front, not just at move time.** Pointing an ingest rule at the recordings/attachments folder used to only fail (or churn) after a note was already created. `collectWork` now resolves the would-be recordings folder for each rule via the side-effect-free `resolveAttachmentFolder` and skips the rule with a clear Notice BEFORE processing any file, so no stray note is created. The comparison goes through `normalizeIngestFolder` (root + trailing-slash tolerant, exported/tested). The move-time `ingestTargetIsSameFolder` guard remains for the dynamic case (empty ReWrite attachments setting + a note-relative Obsidian attachment setting) that can only be known once the note exists. Do NOT drop the up-front check in favor of the backstop: the whole point is to avoid creating a note you then cannot dedupe.
- **Per-file failures surface their actual reason.** `runIngestBatch` collects each failure's message and shows them in a second sticky Notice (`• <file>: <reason>`, first 5, 15 s) on top of the "N processed, M failed" summary, plus `console.error`. A batch tool that only logged to the console left the user with no idea why a file failed; keep the reasons user-visible.
- **Ingest is newFile-templates only, enforced in two places.** `isIngestTemplate` gates both the rule-editor dropdown ([src/ui/ingest-rule-modal.ts](src/ui/ingest-rule-modal.ts)) and `collectWork` ([src/ingest.ts](src/ingest.ts)). Unattended runs have no active editor, so `cursor`/`append` cascade to `newFile` anyway; requiring newFile makes the note's destination explicit and predictable. A rule whose template is deleted or changed to non-newFile is skipped with a Notice, not silently run.
### Quick Record UI
- **Quick Record uses a custom floating div, not a `Notice`.** Obsidian `Notice` does not support real interactive buttons. The floater is a `position: fixed` div on `document.body`, owned by `QuickRecordController`, with `cancel()` wired into `onunload`.
- **Quick Record now carries optional per-invocation params for the modal's background handoff.** `QuickRecordController` / `startQuickRecord` accept `destinationOverride` + `contextHint` (used only by the modal's "Record in background" path). Switching templates via the floater popover clears both, since they were configured against the original template. The two Quick Record commands pass neither, so their behavior is unchanged.
- **Quick Record floater holds its own popover.** The floater grew a third button between the timer and Stop that opens a popover-style template list ([src/ui/quick-record.ts](src/ui/quick-record.ts)). The popover is a child of the floater div, listens for outside-click via a capture-phase `document` listener and Escape via `document keydown`, and cleans up both listeners on dismiss. The popover dismisses on selection, Escape, outside click, and when `setBusy` runs (the pipeline is in flight). Selecting a template updates `controller.template` but does NOT update `lastUsedTemplateId`; that only happens after a successful completion, matching pre-popover behavior. The floater also shows an optional stop-hotkey hint span (`.rewrite-quick-stop-hint`) before the Stop button when `startQuickRecord` is passed a `commandId` whose binding resolves (see next Gotcha).
- **The stop-hotkey hint reads Obsidian's internal `app.hotkeyManager`.** `formatCommandHotkey(app, commandId)` in [src/ui/quick-record.ts](src/ui/quick-record.ts) prefers `getHotkeys(id)` (user binding) over `getDefaultHotkeys(id)` (plugin default), formats it platform-aware via `Platform.isMacOS`, and returns `null` when unbound (the floater then shows only the Stop button, no placeholder text). The manager is not in the public `obsidian` typings, so it is reached through a narrow local `HotkeyManager` interface + `as unknown as` cast rather than disabling a lint rule. The hint is computed fresh each time a recording starts (`commandId` is `${manifest.id}:quick-record` or `:quick-record-fixed`), so rebinding takes effect on the next recording. If a future feature needs the same lookup, reuse this helper.
- **Quick Record's own mic-stream start is guarded against double-firing, separately from `activeQuickRecord`.** `ReWritePlugin.toggleQuickRecord` ([src/main.ts](src/main.ts)) only assigns `this.activeQuickRecord` after `await startQuickRecord(...)` resolves (it awaits `getUserMedia`), so two rapid hotkey presses could both observe `activeQuickRecord === null` and each open a mic stream, orphaning the first. A synchronous `quickRecordStarting` boolean is set before the `await` and cleared in a `finally`; the second press then no-ops instead of racing.
@ -393,7 +455,7 @@ Build, then place/symlink `main.js`, `manifest.json`, and `styles.css` into `<Va
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 review`** / **`npm run review:docs`** ([local-review.mjs](local-review.mjs)): advisory, **always-exits-0** local review pass over the current diff (merge-base vs working tree by default; `--staged` / `--full` / `--base <ref>`), run against a locally-hosted llama.cpp model. Two deliberately-separate modes: `--code` (default; scope `src/`+`styles.css`+`test/`+`*.mjs`+`manifest.json`, bug-hunting prompt) and `--docs` (scope `*.md`, doc-consistency prompt) — split so each diff fits the model's context and each prompt stays specific. Untracked-and-not-ignored files in scope are folded in (plain `git diff` omits them, but a new source file must not be missed). Spawns/adopts the server (loopback-enforced, duplicating `whisper-host`'s `getHostArgs`/`isLoopbackHost`/`splitArgs` since that file can't load in plain Node), POSTs to `/v1/chat/completions`, prints findings and writes `docs/claude-scratch/local-review-<mode>-report.md`. Never chained into `npm run build`.
- **`npm run release:prep`** ([prepare-release-vault.mjs](prepare-release-vault.mjs)): **fails loudly** (exit 1); builds and copies the three release artifacts into `<releaseVault.vaultPath>/.obsidian/plugins/rewrite-voice-notes/`.
- **`release-checklist` skill** ([.claude/skills/release-checklist/](.claude/skills/release-checklist/)): runs `release:prep`, then walks `CHECKLIST.md` (the live feature-by-feature matrix that replaced the stale spec checklist) for a go/no-go. Keep `CHECKLIST.md` in sync with the feature set like the other in-sync docs.

View file

@ -10,6 +10,7 @@ You bring your own provider keys. Nothing is sent to a ReWrite server; the plugi
- Bring-your-own keys across 8 transcription and 5 LLM providers, cloud or fully local; nothing goes to a ReWrite server.
- 10 editable Markdown templates (general cleanup, todo, daily note, meeting, lecture, podcast, guides, book log, and more) plus a shared-core baseline you edit once.
- Speaker diarization, saved-audio embeds, spoken ad-hoc instructions, known-nouns preservation, and per-run destination overrides.
- Background recording from the main modal, real-time dictation at the cursor (Deepgram / AssemblyAI), and auto-ingest folders that batch-process audio dropped in from outside Obsidian.
- API keys encrypted at rest (OS secret storage, or an Argon2id/PBKDF2 passphrase) on desktop and mobile.
## Quick start

View file

@ -14,7 +14,9 @@
"//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
"maxDiffChars": 60000,
"//maxOutputTokens": "Cap on the model's findings length. Raise it if a review is cut off mid-sentence; make sure --ctx-size in extraArgs leaves room for the prompt plus this many output tokens.",
"maxOutputTokens": 8192
},
"releaseVault": {

View file

@ -18,7 +18,8 @@ Both scripts read one gitignored file at the repo root, `dev-tools.config.json`
"baseRef": "master", // diff is computed against this ref
"readyTimeoutMs": 60000,
"requestTimeoutMs": 300000,
"maxDiffChars": 60000
"maxDiffChars": 60000,
"maxOutputTokens": 8192 // cap on the model's findings; raise if a review is cut off
},
"releaseVault": {
"vaultPath": "" // required: a SCRATCH Obsidian vault, never a personal one
@ -28,15 +29,17 @@ Both scripts read one gitignored file at the repo root, `dev-tools.config.json`
## `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`.
A one-shot, **always-exits-0**, advisory pass over the current diff, run against a locally-hosted llama.cpp model 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.
**Two modes, run separately.** `npm run review` (default, `--code`) hunts for bugs in the plugin source; `npm run review:docs` (`--docs`) checks the Markdown docs for internal consistency. They are deliberately two runs, not one: different system prompt, and different file scope (`CODE_PATHS` = `src/`, `styles.css`, `test/`, `*.mjs`, `manifest.json`; `DOC_PATHS` = `*.md`). Splitting them keeps each diff small enough to fit the model's context (a whole-repo diff mixing code and docs both dilutes the review and overflows the window) and lets each prompt be specific. Each mode writes its own report: `docs/claude-scratch/local-review-code-report.md` / `-docs-report.md`.
**Diff scope.** `git merge-base <baseRef> HEAD` then `git diff <mergeBase> -- <pathspec>` against the working tree, i.e. everything staged + unstaged + committed-since-branch ("everything you're about to go test"), narrowed to the mode's pathspec. **Untracked-and-not-ignored files in scope are folded in too** (via `git ls-files --others --exclude-standard`, each appended as a `NEW FILE (untracked)` block): plain `git diff` omits untracked files, but a brand-new source file is exactly what a pre-test review must not miss. `--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.
**Prompts.** The **code** 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 **docs** system prompt instead looks for internal contradictions, staleness (a count/name/default a code change should have updated), broken cross-references / `[[wikilinks]]`, and missing mirrors across the in-sync docs (CLAUDE.md summary vs the deep `docs/` file; wiki vs README), and is told NOT to flag intentional overlap as duplication. 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.
**Output.** Findings print to stdout and are always also written to `docs/claude-scratch/local-review-<mode>-report.md` (per mode, overwritten each run; the folder is already gitignored). The report header carries the review mode, base ref, merge-base SHA, scope, untracked-file count, 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).
@ -58,3 +61,6 @@ The first project-level Claude Code skill in this repo. Two files, mirroring the
- **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.
- **Set `--ctx-size` in `extraArgs` for anything but a tiny diff.** llama-server's default context is 4096 tokens. A larger diff produces a prompt bigger than that, and the server silently truncates the prompt from the front (dropping the system prompt), so the model returns empty or nonsense findings. Symptom: "(no findings returned)" or a garbled reply on a diff you know is substantial. Fix: pass e.g. `--ctx-size 65536` and make sure it exceeds `maxDiffChars`-worth of tokens (~4 chars/token) plus `maxOutputTokens`. A capable long-context model helps too; a small quant reviewing a 40k-token diff is not the tool's sweet spot (it is meant for focused pre-test passes over small changes).
- **`maxOutputTokens` caps the findings length.** Default 8192. If a review is cut off mid-sentence, raise it, and confirm `--ctx-size` still leaves room for the prompt plus the larger output budget.
- **Reasoning ("thinking") models are the tool's biggest footgun; prefer a non-reasoning model.** A reasoning model streams its analysis into a separate `reasoning_content` field and leaves `message.content` empty until it *finishes* thinking. On a large diff it narrates the whole diff file-by-file and hits the output-token cap before ever emitting a final answer, so `content` comes back empty and the report reads "(no findings returned)" no matter how you tune context or the cap. Two defenses are built in: (1) the request sends `chat_template_kwargs: { enable_thinking: false }` + `reasoning_effort: 'none'` to disable the thinking channel where the chat template honors it (this is what makes such a model usable — it then answers directly in `content`); (2) if `content` is still empty, `postReview` falls back to `reasoning_content` so you at least see the analysis, with a note that it was cut off. The `[review] prompt_tokens=… completion_tokens=… finish_reason=…` line printed each run is the diagnostic: `finish_reason=length` with a large `completion_tokens` and an empty content is the tell that thinking ate the budget. If a model ignores the disable toggle, switch models.

View file

@ -2,11 +2,16 @@
> Extracted from CLAUDE.md. Subject to the same maintenance rule: when you change diarization behavior, update this file in the same change, and keep the one-line summary in [CLAUDE.md](../CLAUDE.md) accurate.
Opt-in per-profile flag `TranscriptionConfig.diarize?: boolean` ([src/types.ts](../src/types.ts)). When on, the capable adapter embeds `Speaker X:` labels into the returned transcript string (the v1 shape from the diarization item in the [ROADMAP.md](ROADMAP.md) archive; the `transcribe(): Promise<string>` interface is unchanged, and cleanup/insert treat the labels as ordinary text). Capability is centralized in `transcriptionProviderSupportsDiarization(id)` ([src/transcription/index.ts](../src/transcription/index.ts)), true only for `assemblyai` / `deepgram` / `revai`. The settings tab ([src/settings/tab.ts](../src/settings/tab.ts)) shows an "Identify speakers" toggle in the per-profile transcription block gated on that helper; the setup card is intentionally left without it (not a "fill in the basics" field).
Opt-in `Speaker X:` labels, chosen **per invocation** (there is no persisted profile setting). When on, the capable adapter embeds `Speaker X:` labels into the returned transcript string (the v1 shape from the diarization item in the [ROADMAP.md](ROADMAP.md) archive; the `transcribe(): Promise<string>` interface is unchanged, and cleanup/insert treat the labels as ordinary text). Capability is centralized in `transcriptionProviderSupportsDiarization(id)` ([src/transcription/index.ts](../src/transcription/index.ts)), true only for `assemblyai` / `deepgram` / `revai`. `TranscriptionConfig.diarize?: boolean` ([src/types.ts](../src/types.ts)) still carries the flag into the adapter, but it is set by the pipeline per run, not stored as a user preference.
## Per-template override
Why per-invocation: a profile-wide "always diarize" setting wrongly labels notes where speaker turns are meaningless (a daily-note braindump becomes `Speaker A: ...`). Harmless but unclean, so diarization is now a deliberate per-run choice.
A template can force diarization on for its own runs via `NoteTemplate.diarize?: boolean` (frontmatter `diarize: true`), independent of the profile toggle. [src/pipeline.ts](../src/pipeline.ts) `collectTranscript` merges `{ ...profile.transcriptionConfig, diarize: true }` for the transcribe call ONLY when `template.diarize` is set AND `transcriptionProviderSupportsDiarization(profile.transcriptionProvider)` is true; on a non-capable provider it leaves the config untouched (the flag is a documented no-op, not an error). The profile config is never mutated. The Meeting transcript default ships with this set. The override raises the effective setting only (a template never turns OFF a profile's diarization).
## The two inputs (template flag + modal toggle)
1. **Template flag** `NoteTemplate.diarize?: boolean` (frontmatter `diarize: true`): the template's default. The Meeting transcript default ships with it set.
2. **Modal toggle**: the main modal renders a per-run "Identify speakers" checkbox (`renderDiarizeToggle` in [src/ui/modal.ts](../src/ui/modal.ts)) whenever the active provider supports diarization. It defaults to the active template's flag, is reset when the template selector changes, and its value rides `PipelineParams.diarize` through every run path (Record tab, `startRecordingPipeline`, and the "Record in background" -> Quick Record handoff, cleared when the floater switches templates).
[src/pipeline.ts](../src/pipeline.ts) `collectTranscript` computes `effectiveDiarize = (template.diarize || params.diarize) && transcriptionProviderSupportsDiarization(profile.transcriptionProvider)` and builds `{ ...profile.transcriptionConfig, diarize: effectiveDiarize }` for the transcribe call, always setting the flag explicitly (a stale `diarize` in an older `data.json` cannot leak through). The profile config object is never mutated. On a non-capable provider the flag is a documented no-op, not an error. There is no settings-tab toggle; removing it was the point of this change.
## Per-adapter behavior

View file

@ -39,8 +39,8 @@ The tag name must equal `manifest.json`'s `version` exactly. `.npmrc` already pi
## Pre-flight checklist
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 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.)
2. `npm run lint` passes with zero warnings, and `npm test` passes. The local `eslint-plugin-obsidianmd` is looser than the official review bot, so also eyeball the conflict checklist below.
3. Manual feature pass via the **`release-checklist` skill** (`.claude/skills/release-checklist/`), which sequences the whole verification. Its Phase 1 runs the automated pre-checks (`build` / `lint` / `test` plus the advisory `npm run review` and `npm run review:docs` local reviews); then `npm run release:prep` builds and installs `main.js` / `manifest.json` / `styles.css` into your test vault's `.obsidian/plugins/rewrite-voice-notes/` (configure `releaseVault.vaultPath` in `dev-tools.config.json` first`release:prep` only overwrites those three files, so a vault with real data is safe); 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). Test the actual **versioned** artifact: bump first (`npm version <patch|minor|major> --no-git-tag-version`), then run the pass, then commit + tag.
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

@ -20,67 +20,11 @@ Keep an item in exactly one state. Moving an Unreleased item into a version head
## Planned
### 1. Plugin-managed local whisper.cpp server (desktop) — Phase B (auto-start lifecycle)
### Plugin-managed local whisper.cpp server (desktop) — Phase B remainder (supervision hardening)
Phase A shipped (see the Released archive). Remaining work:
The auto-start and idle-stop lifecycle toggles shipped (see Unreleased). The remaining Phase B work is deferred pending real-world reports:
- "Start automatically when Obsidian opens" toggle (default off).
- "Stop when idle for N minutes" toggle (default off; useful for the large-v3 user who doesn't want 1.5 GB resident all day).
- Process supervision hardening based on real-world Phase A usage (zombies, orphans, signal handling differences across platforms).
### 2. Real-time transcription mode (live STT, no cleanup)
Voxtral exposes a real-time STT model that doesn't accept whole-file uploads, and a few other providers have equivalents (Deepgram streaming, AssemblyAI realtime). Investigate adding an opt-in "Real-time" mode:
- Bound to a command/shortcut only (no main-modal entry, no template, no LLM cleanup pass).
- Inserts the live transcript at the cursor as it streams, on both mobile and desktop.
- 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)).
- Process supervision hardening based on real-world usage (zombies, orphans, signal-handling differences across platforms) beyond what the probe/adopt + PID-sidecar model already covers.
---
@ -88,16 +32,50 @@ For users who capture audio outside Obsidian (a dedicated voice-recorder app, a
Merged to `master`, not yet tagged. These become the next version's release notes.
### 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.
---
## Released
> Per-version attribution starts with the first release cut after this roadmap was adopted. The archive below collects work that shipped across **1.0.0 through 1.1.0** before per-version tracking began; it is grouped as one historical block rather than retro-fitted to individual tags. New releases get their own `### <version> — <date>` heading above this archive.
### 1.2.0 — 2026-07-08
### Real-time transcription mode (live dictation at the cursor), configured independently of batch
Opt-in live STT, command-only (`rewrite-voice-notes:realtime-transcribe`, "Real-time transcription (start/stop)"), no template / no LLM cleanup / no saved audio: it streams the mic to the provider over a WebSocket and types finalized segments at the editor cursor as they arrive, like dictation. Provider support is gated by `transcriptionProviderSupportsRealtime(id)` in [src/realtime/index.ts](../src/realtime/index.ts): only `deepgram` and `assemblyai` expose a browser-reachable streaming endpoint; the rest accept whole-file uploads only and the command errors with a steer to those two. New `src/realtime/` module: `pcm.ts` (`PcmCapture`, a `ScriptProcessorNode` mic tap, since `AudioWorklet` needs a separate module file a single-bundle plugin can't ship under the app CSP, plus the pure, unit-tested `downsampleBuffer` / `floatTo16BitPcm` to 16 kHz signed-16 PCM), `index.ts` (`RealtimeProvider` / `RealtimeSession` interfaces + `waitForOpen`/`waitForClose`), `deepgram.ts` (auth via the `['token', key]` WebSocket subprotocol so the key stays off the URL; `is_final` marks a final), and `assemblyai.ts` (Universal-Streaming v3; because a browser WS can't send an Authorization header, the real key mints a short-lived single-use 60 s token that alone rides the WS query, the one sanctioned exception to the "auth never in the query" rule; `format_turns` de-duplicates each turn). **Realtime is configured entirely independently of batch transcription**: its own `EnvironmentProfile.realtimeProvider` + `realtimeConfig` (key + model), persisted under secret id `profile-{kind}-realtime`, so a profile can run e.g. Voxtral for batch, AssemblyAI for realtime, and Anthropic for cleanup. The settings tab renders a self-contained "Real-time transcription" section (provider dropdown limited to None + realtime-capable, key, and the same adaptive model control as the batch fields via `populateModelField(..., 'realtime')`: dropdown + Refresh for Deepgram, text field for AssemblyAI). [src/ui/realtime.ts](../src/ui/realtime.ts) `RealtimeController` owns capture + session + a floating status bar (reuses `.rewrite-quick-floater`), inserts via `editor.replaceSelection`, and is a single plugin-owned session (`activeRealtime` + `realtimeStarting`, cancelled in `onunload`). Honest scope note preserved from the plan: lower-value than the cleanup pipeline (a raw transcript is what other voice plugins already do), shipped because the primitives were cheap once the streaming adapters existed. **Voxtral realtime was reverse-engineered and built but pulled**: Mistral's realtime endpoint rejects the only auth a browser WebSocket can send, so the adapter ([src/realtime/voxtral.ts](../src/realtime/voxtral.ts)) is kept on disk, unwired (not in the gate or factory, so not bundled or selectable), documented in `wiki/Voxtral-Realtime.md` for a contributor who finds a browser-auth path. Docs: CLAUDE.md (Real-time transcription section + gotchas), `wiki/Providers.md`, `wiki/Settings-Reference.md`, `wiki/Voxtral-Realtime.md`, `wiki/Commands-and-Menus.md`, README.
### Diarization moved from a settings toggle to per-invocation (template flag + per-run checkbox)
Speaker diarization is no longer a persisted per-profile setting (which wrongly diarized e.g. daily notes, producing stray `Speaker A` labels). It is now chosen per recording: the per-template `NoteTemplate.diarize` frontmatter flag (Meeting transcript ships it on) sets the default, and the main modal shows an **Identify speakers** checkbox (`renderDiarizeToggle` in [src/ui/modal.ts](../src/ui/modal.ts)) only on a capable provider (assemblyai/deepgram/revai), defaulting to the active template's flag and reset on template change. [src/pipeline.ts](../src/pipeline.ts) computes `effectiveDiarize = (template.diarize || params.diarize) && transcriptionProviderSupportsDiarization(id)` and always sets `transcriptionConfig.diarize` explicitly. `PipelineParams.diarize` is threaded through the Record tab, `startRecordingPipeline`, and the Record-in-background handoff. The settings-tab "Identify speakers" toggle is gone; `TranscriptionConfig.diarize` is now purely the internal pipeline->adapter transport. Docs: CLAUDE.md (Speaker diarization), docs/DIARIZATION.md, `wiki/Providers.md`, `wiki/Settings-Reference.md`.
### Mobile foreground caution while recording
Both recording UIs now show a mobile-only yellow caution (`MOBILE_RECORD_WARNING_TEXT`, "Keep Obsidian in the foreground...") for the duration of capture, because an app switch suspends the Capacitor WebView and kills `MediaRecorder` mid-recording (the screen wake lock only prevents screen-sleep, not backgrounding). Shown on record-start, hidden on stop/when busy: `.rewrite-mobile-record-warning` in the modal's Record tab and `.rewrite-quick-mobile-warning` in the Quick Record floater, both gated on `Platform.isMobile`. Styled yellow (`--text-warning`), distinct from the red silence warning, so it reads as an alert not an error. Docs: CLAUDE.md (Recording and capture gotcha), `wiki/Mobile.md`.
### Settings profile section: per-subsection headings + standardized field order
The profile section is now split into three headed subsections (**Transcription**, **Real-time transcription**, **Post-processing (LLM)**) that share one field order: provider, base URL (where applicable), API key, then model. Previously only the real-time block had a heading and the batch/LLM blocks put the model before the API key. Purely a settings-tab layout change ([src/settings/tab.ts](../src/settings/tab.ts) `renderProfile`). Docs: `wiki/Settings-Reference.md`.
### Record in background, plus non-blocking capture always
Two coupled changes to the main modal's Record tab. (1) **Processing already ran detached on Stop** (the 1.1.1 fix); this cycle confirms and preserves that: regardless of the new checkbox, pressing Stop closes the modal and runs the transcribe/cleanup/insert pipeline in the background so the vault is usable during processing. (2) **New "Record in background" checkbox** (desktop only, hidden on mobile where the WebView suspends backgrounded `MediaRecorder`), backed by the remembered `GlobalSettings.recordInBackground` flag (default off). When ticked, pressing Record does not capture in the modal at all: `startBackgroundHandoff` ([src/ui/modal.ts](../src/ui/modal.ts)) captures the modal's template + destination override + context hint into locals, closes the modal, and calls `plugin.startBackgroundRecording(opts)` ([src/main.ts](../src/main.ts)), which routes into the SAME `activeQuickRecord` slot + `quickRecordStarting` guard as the Quick Record commands (preserving the one-recording-at-a-time guarantee and the single `onunload` cleanup path). `QuickRecordController` / `startQuickRecord` gained optional `destinationOverride` + `contextHint` params, carried into `runPipeline`; switching templates via the floater popover clears them (they belonged to the original template). Docs: CLAUDE.md (Pipeline + Quick Record gotcha), `wiki/Commands-and-Menus.md`, `wiki/Settings-Reference.md`, README.
### Manage individual default templates: enable/disable + track/untrack via a `managed` flag
Replaced the all-or-nothing default set with two per-default controls, surfaced as a "Manage built-in templates" checklist in the Templates settings section (`renderManageDefaults` in [src/settings/tab.ts](../src/settings/tab.ts), driven off `freshDefaultTemplates()`). **Enable/disable** governs existence: disabling deletes the on-disk file (matched by frontmatter `id`, via `app.fileManager.trashFile` behind a `ConfirmModal`) and records the id in `GlobalSettings.disabledDefaultTemplateIds`, which both `populateDefaultTemplates` and `updateDefaultTemplates` now receive as a `disabledIds` set and never (re)create — including a future re-ship. Decision: the ignore list is keyed by `id` only, not also by name (the `managed` flag already protects a name-colliding user template). **Track/untrack** governs updates via the new tri-state `NoteTemplate.managed` field: `true` (Populate/Update stamp it on files they create/reconcile — Update may reconcile), `false` (untracked; Update skips it, counted in `UpdateResult.untracked`), `undefined` (absent/empty — treated as tracked when the id matches a built-in, preserving pre-flag behavior). `parseTriStateFlag` + `renderTemplateFile` handle all three states; the untrack toggle edits just the frontmatter key via `processFrontMatter`. Update's file-matching became "id matches a built-in AND not `managed: false`", and `mergeTemplate` stamps `managed: true` on its result, so a user's own hand-authored template that shares a name (or id) with a built-in is never clobbered. `ConfirmModal` gained an `onCancel` hook so backing out re-renders/reset the optimistically-flipped toggle. Docs: `wiki/Creating-Templates.md` (frontmatter table row + dedicated `managed` section), `wiki/Settings-Reference.md`, CLAUDE.md (Managing individual defaults + Updating defaults).
### Auto-ingest folders: batch-process dropped audio with a preassigned template
New `rewrite-voice-notes:process-ingest-folders` command ("Process auto-ingest folders", all platforms including mobile) plus a settings "Auto-ingest folders" section. Each rule (`GlobalSettings.ingestRules: { folderPath; templateId; enabled }[]`) pairs a vault folder with a newFile-mode template, edited in a popup ([src/ui/ingest-rule-modal.ts](../src/ui/ingest-rule-modal.ts), dropdown filtered to newFile templates). [src/ingest.ts](../src/ingest.ts) `runIngestBatch` scans each enabled rule's direct audio children (`collectIngestFiles`, exported/tested; no recursion), gates like manual reprocess (unlocked secrets + `isProfileConfigured`), and runs each file through the pipeline serialized behind a sticky Notice + Cancel. Each run reuses the reprocess path (`sourcePath` skips persist, keeps the `![[embed]]`); **move-on-success is the dedupe** — a fully-processed recording is moved to the attachments location via `app.fileManager.renameFile` (which fixes the note's embed link), so a re-run never reprocesses it and there is no processed-path bookkeeping in `data.json`; a failed file stays put and is retried next run. `isIngestTemplate` enforces newFile-only in both the rule editor and the runner (unattended runs have no active editor, so cursor/append would cascade to newFile anyway; requiring it makes the destination explicit). Same-folder guard, two layers: `collectWork` (now async) skips a rule UP FRONT (before creating any note) when its folder is also where recordings would be stored, resolved via the side-effect-free `resolveAttachmentFolder`; `moveIngestedFile` keeps the per-file `ingestTargetIsSameFolder` backstop for the case only knowable at move time. Failure reasons surface in a second sticky Notice (`• <file>: <reason>`, first 5). `resolveAttachmentPath` extracted from `persistAudio` so the mover resolves the same destination a live recording would. Docs: `wiki/Settings-Reference.md` (Auto-ingest section), `wiki/Commands-and-Menus.md`, CLAUDE.md (Auto-ingest folders + gotchas), README.
### Local whisper.cpp server — Phase B: auto-start and idle stop
Two opt-in lifecycle toggles for the desktop whisper.cpp host (both default off), completing the auto-start-lifecycle portion of the former Planned item #1. **Start automatically** (`localWhisper.autoStart`): after `onload`'s probe settles, [src/main.ts](../src/main.ts) starts the server when the active profile uses `whisper-local` and nothing was found to adopt (`shouldAutoStartWhisper`). **Stop when idle** (`localWhisper.idleStopMinutes`, 0 disables): a 60 s `registerInterval` calls `whisperHost.stopIfIdle()`, whose decision is the pure, unit-tested `shouldStopWhenIdle(...)` in [src/whisper-host.ts](../src/whisper-host.ts) — it only stops a ReWrite-owned (spawned/adopted, never external) server, never while a transcription is in flight, and only past the threshold. The `whisper-local` adapter brackets each transcription with `beginUse()`/`endUse()` (including the transcode) so a long job on a big model is never killed under the user and the idle clock restarts on completion; `start()` and `probe()`-adopt set the activity timestamp. Settings gained the two fields; the remaining Phase B supervision-hardening work stays in Planned. Docs: [docs/WHISPER_HOST.md](WHISPER_HOST.md) (Phase B section + config shape), `wiki/Settings-Reference.md`, `wiki/Self-Hosting-Whisper.md`, CLAUDE.md.
### 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.
### 1.1.1 — 2026-06-19
### Fix: recording the modal now closes on Stop and reports via notices, instead of holding the modal open

View file

@ -2,7 +2,7 @@
> Extracted from CLAUDE.md. Subject to the same maintenance rule: when you change whisper-host behavior, update this file in the same change, and keep the summary in [CLAUDE.md](../CLAUDE.md) accurate.
[src/whisper-host.ts](../src/whisper-host.ts) exposes the `WhisperHost` class. The plugin instantiates one in `onload` and stops it in `onunload`. Configuration lives at `GlobalSettings.localWhisper = { binaryPath, modelPath, port, extraArgs }` — all user-supplied. No automatic discovery, no PATH lookup at runtime, no auto-download. The one (user-initiated) convenience is the **Auto-detect** button beside the Binary path field in settings: `detectWhisperBinary()` in [src/settings/tab.ts](../src/settings/tab.ts) lazy-requires `fs`/`os`/`path` (same `window.require` + `Platform.isDesktop` guard as `whisper-host.ts`) and returns the first existing candidate from the build script's install locations (`~/.local/bin/whisper-server`, then `~/.local/share/whisper.cpp/build/bin/whisper-server`) followed by common system/Homebrew paths (`/usr/local/bin`, `/opt/homebrew/bin`, `/usr/bin`); `.exe` suffix on Windows. It only checks existence when clicked and never runs the binary. The `binaryPath` default stays `''` (the setup-card gating depends on it), so this is a fill-in helper, not a baked default.
[src/whisper-host.ts](../src/whisper-host.ts) exposes the `WhisperHost` class. The plugin instantiates one in `onload` and stops it in `onunload`. Configuration lives at `GlobalSettings.localWhisper = { binaryPath, modelPath, port, extraArgs, autoStart, idleStopMinutes }` — all user-supplied. No automatic discovery, no PATH lookup at runtime, no auto-download. The one (user-initiated) convenience is the **Auto-detect** button beside the Binary path field in settings: `detectWhisperBinary()` in [src/settings/tab.ts](../src/settings/tab.ts) lazy-requires `fs`/`os`/`path` (same `window.require` + `Platform.isDesktop` guard as `whisper-host.ts`) and returns the first existing candidate from the build script's install locations (`~/.local/bin/whisper-server`, then `~/.local/share/whisper.cpp/build/bin/whisper-server`) followed by common system/Homebrew paths (`/usr/local/bin`, `/opt/homebrew/bin`, `/usr/bin`); `.exe` suffix on Windows. It only checks existence when clicked and never runs the binary. The `binaryPath` default stays `''` (the setup-card gating depends on it), so this is a fill-in helper, not a baked default.
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.
@ -12,6 +12,15 @@ The companion CLI installer is [scripts/build-whisper-linux.sh](../scripts/build
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).
## Phase B lifecycle: auto-start and idle stop
Two opt-in knobs (both default off) automate the host's lifecycle:
- **Auto-start** (`localWhisper.autoStart`, "Start automatically" toggle). After `onload`'s `probe()` settles, [src/main.ts](../src/main.ts) checks `shouldAutoStartWhisper(status)`: the toggle is on, the active profile's transcription provider is `whisper-local`, and the probed status is `stopped` or `crashed`. An adopted or external server is never doubled up (the probe result gates it, and `start()` re-probes anyway). Success/failure surfaces via `Notice`; deliberately runs after the probe rather than in a raw `onload` hot path.
- **Idle stop** (`localWhisper.idleStopMinutes`, "Stop when idle" field; `0` disables). A 60 s `registerInterval` in [src/main.ts](../src/main.ts) calls `whisperHost.stopIfIdle(minutes)`. The decision is the pure, unit-tested `shouldStopWhenIdle(status, ownership, inFlightUses, lastActivityAt, idleStopMinutes, now)` in [src/whisper-host.ts](../src/whisper-host.ts): it only ever stops a `running` server whose ownership is `spawned` or `adopted` (NEVER `external` — not ours to stop), never while a transcription is in flight, and only after `idleStopMinutes` of no activity. Activity bookkeeping: `lastActivityAt` is set when `start()` reaches `running`, when `probe()` adopts, and by `beginUse()`/`endUse()`, the bracket the `whisper-local` adapter ([src/transcription/whisper-local.ts](../src/transcription/whisper-local.ts)) wraps around each transcription (including the transcode), so a long job on a large model is never killed mid-run and the idle clock restarts when it finishes.
Remaining Phase B scope (supervision hardening for zombie/orphan/signal edge cases beyond the probe/adopt model) stays open pending real-world reports; see the roadmap.
Mobile compatibility: `WhisperHost` and the `whisper-local` option are guarded everywhere by `Platform.isDesktop`. Node modules (`child_process`, `net`, `fs`) are lazy-required inside the `Platform.isDesktop` branch, mirroring the [src/secrets.ts](../src/secrets.ts) `getSafeStorage` pattern; on mobile the host is inert and the provider option is filtered out of dropdowns in [src/settings/tab.ts](../src/settings/tab.ts) and [src/ui/setup-card.ts](../src/ui/setup-card.ts).
## Gotchas

View file

@ -83,12 +83,16 @@ export function parseLocalReviewConfig(raw, exists = existsSync) {
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,
// Output-token cap for the model's findings. 4096 truncated mid-review on a large diff;
// keep this comfortably above the length of a thorough set of findings. Make sure the
// server's --ctx-size in extraArgs leaves room for prompt + this many output tokens.
maxOutputTokens: Number.isFinite(lr.maxOutputTokens) && lr.maxOutputTokens > 0 ? lr.maxOutputTokens : 8_192,
};
}
// --- CLI + diff scope -------------------------------------------------------------------
export function parseCliArgs(argv) {
const out = { base: null, staged: false, full: false };
const out = { base: null, staged: false, full: false, mode: 'code' };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--base') {
@ -100,20 +104,56 @@ export function parseCliArgs(argv) {
out.staged = true;
} else if (a === '--full') {
out.full = true;
} else if (a === '--docs') {
out.mode = 'docs';
} else if (a === '--code') {
out.mode = 'code';
} else if (a === '--mode') {
out.mode = argv[i + 1] === 'docs' ? 'docs' : 'code';
i++;
} else if (a.startsWith('--mode=')) {
out.mode = a.slice('--mode='.length) === 'docs' ? 'docs' : 'code';
}
}
return out;
}
// Two review jobs, deliberately separate (different prompt, different file scope, so each
// diff stays small enough to fit the model's context): `code` hunts for bugs in the plugin
// source, `docs` checks the Markdown docs for internal consistency. Git pathspecs: `src/`,
// `test/` are directory prefixes; `*.mjs` / `*.md` match at any depth; `styles.css` /
// `manifest.json` are root files.
export const CODE_PATHS = ['src/', 'styles.css', 'test/', '*.mjs', 'manifest.json'];
export const DOC_PATHS = ['*.md'];
export function pathsForMode(mode) {
return mode === 'docs' ? DOC_PATHS : CODE_PATHS;
}
export function modeLabel(mode) {
return mode === 'docs' ? 'documentation consistency (Markdown)' : 'code (src/, styles.css, test/, scripts)';
}
// 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 } = {}) {
export function buildDiffArgs(scope, mergeBase, { stat = false, paths = [] } = {}) {
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];
let range;
if (scope.staged) range = ['--staged'];
else if (scope.full) range = ['HEAD~1', 'HEAD'];
else range = [mergeBase];
const pathspec = paths.length > 0 ? ['--', ...paths] : [];
return [...base, ...range, ...pathspec];
}
// git diff excludes untracked files, but a new source file is exactly the kind of change a
// pre-test review must not miss. This lists untracked-and-not-ignored files inside the mode's
// pathspec; main() appends their contents to the diff so the model sees them.
export function untrackedArgs(paths) {
const pathspec = paths.length > 0 ? ['--', ...paths] : [];
return ['ls-files', '--others', '--exclude-standard', ...pathspec];
}
export function scopeLabel(scope) {
@ -142,10 +182,25 @@ export const REVIEW_SYSTEM_PROMPT = [
'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) {
export const DOC_REVIEW_SYSTEM_PROMPT = [
'You are a documentation reviewer for the ReWrite (Voice Notes) plugin for Obsidian. You are given a git diff of Markdown documentation (CLAUDE.md, docs/, the wiki/, README, the release checklist).',
'This repo keeps several docs deliberately in sync: CLAUDE.md carries a summary plus a pointer to a deeper docs/ file; the wiki restates the user-facing parts; the README quick-start overlaps the wiki. Your job is to catch where a change to one has NOT been mirrored in the others, and where a doc now contradicts itself or reality.',
'Report, most important first:',
'',
'1. Internal contradictions: two docs (or two parts of one doc) that now state different things (a count, a default, a command name, a settings key, a file path, a provider list).',
'2. Staleness: a number, name, or claim that a code/feature change should have updated but did not (e.g. a template count, a list of defaults, a provider that was added or removed, a renamed setting).',
'3. Broken or dangling references: a link, filename, section title, or `[[wikilink]]` that points at something renamed or removed; a "see X" pointer whose target no longer says what is claimed.',
'4. Missing mirror: a behavior described in one doc that the in-sync rule says should also appear elsewhere (CLAUDE.md summary vs the deep docs/ file; wiki vs README) but is absent.',
'',
'Be concrete: name both docs and quote the conflicting phrases. Do not review prose style, grammar, or wording preferences. Do not flag the same fact appearing in two places as a duplication problem: that overlap is intentional here. If the docs look consistent, say so plainly.',
].join('\n');
export function buildReviewMessages(diffText, mode = 'code') {
const system = mode === 'docs' ? DOC_REVIEW_SYSTEM_PROMPT : REVIEW_SYSTEM_PROMPT;
const label = mode === 'docs' ? 'documentation diff' : 'git diff';
return [
{ role: 'system', content: REVIEW_SYSTEM_PROMPT },
{ role: 'user', content: `Here is the git diff to review:\n\n\`\`\`diff\n${diffText}\n\`\`\`` },
{ role: 'system', content: system },
{ role: 'user', content: `Here is the ${label} to review:\n\n\`\`\`diff\n${diffText}\n\`\`\`` },
];
}
@ -158,15 +213,17 @@ export function truncateDiff(diff, maxChars) {
}
// --- Report -----------------------------------------------------------------------------
export function formatReport({ baseRef, mergeBase, timestamp, diffStat, findings, truncated, scope }) {
export function formatReport({ baseRef, mergeBase, timestamp, diffStat, findings, truncated, scope, mode = 'code', untrackedCount = 0 }) {
const lines = [];
lines.push('# Local review report');
lines.push(`# Local review report (${mode})`);
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('> Advisory only. Generated by `npm run review` against a local llama.cpp model. This is a first-pass filter, not a substitute for `/code-review` or human judgment. It always exits 0.');
lines.push('');
lines.push(`- Review mode: ${modeLabel(mode)}`);
lines.push(`- Base ref: \`${baseRef}\``);
lines.push(`- Merge base: \`${mergeBase}\``);
lines.push(`- Scope: ${scopeLabel(scope)}`);
if (untrackedCount > 0) lines.push(`- Included ${untrackedCount} untracked file(s) not yet in git.`);
lines.push(`- Generated: ${timestamp}`);
if (truncated) lines.push('- Note: the diff was truncated before review (exceeded `localReview.maxDiffChars`).');
lines.push('');
@ -271,14 +328,25 @@ async function stopServer(handle) {
});
}
async function postReview(baseUrl, messages, requestTimeoutMs) {
async function postReview(baseUrl, messages, requestTimeoutMs, maxOutputTokens) {
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 }),
// Ask the server to disable the model's thinking channel when the chat template
// supports it (Gemma/Qwen-style "enable_thinking"). Reasoning models otherwise burn
// the whole token budget narrating the diff before emitting any findings; unknown
// kwargs are ignored by templates that don't reference them.
body: JSON.stringify({
messages,
temperature: 0.2,
stream: false,
max_tokens: maxOutputTokens,
chat_template_kwargs: { enable_thinking: false },
reasoning_effort: 'none',
}),
signal: controller.signal,
});
if (!res.ok) {
@ -286,7 +354,27 @@ async function postReview(baseUrl, messages, requestTimeoutMs) {
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)';
const choice = data?.choices?.[0];
const msg = choice?.message ?? {};
const content = (msg.content ?? '').trim();
// Reasoning models (e.g. the Gemma/Ornith "thinking" quants) stream their analysis into
// a separate reasoning_content field and only populate content once thinking closes. On a
// large diff they can hit the token cap mid-thought, leaving content empty. The reasoning
// IS the review, so fall back to it rather than reporting nothing.
const reasoning = (msg.reasoning_content ?? '').trim();
const finish = choice?.finish_reason ?? '(none)';
const usage = data?.usage ?? {};
if (usage.prompt_tokens) {
console.log(`[review] prompt_tokens=${usage.prompt_tokens} completion_tokens=${usage.completion_tokens ?? '?'} finish_reason=${finish}`);
}
if (content) return content;
if (reasoning) {
const note = finish === 'length'
? '> Note: the model returned only its reasoning channel and hit the output-token cap before a final summary. Raise localReview.maxOutputTokens (within the context budget) for a cleaner pass. The analysis below is complete up to the cutoff.\n\n'
: '> Note: the model emitted its analysis in the reasoning channel with no separate final summary.\n\n';
return note + reasoning;
}
return '(no content returned by the model)';
} finally {
clearTimeout(timer);
}
@ -317,21 +405,34 @@ async function main() {
const cli = parseCliArgs(process.argv.slice(2));
const baseRef = cli.base ?? config.baseRef;
const paths = pathsForMode(cli.mode);
let mergeBase;
let diff;
let diffStat;
let untrackedCount = 0;
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 });
diff = execFileSync('git', buildDiffArgs(cli, mergeBase, { paths }), { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
diffStat = execFileSync('git', buildDiffArgs(cli, mergeBase, { stat: true, paths }), { cwd: repoRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
// git diff omits untracked files; a new source file is exactly what a pre-test review
// must not miss. Append each untracked-and-not-ignored file in scope as a new-file block.
const untracked = execFileSync('git', untrackedArgs(paths), { cwd: repoRoot, encoding: 'utf8' })
.split('\n').map((s) => s.trim()).filter(Boolean);
untrackedCount = untracked.length;
for (const rel of untracked) {
let content;
try { content = readFileSync(join(repoRoot, rel), 'utf8'); } catch { content = '(could not read file)'; }
diff += `\n\n=== NEW FILE (untracked, not yet in git): ${rel} ===\n${content}\n`;
diffStat += `\n ${rel} | new file (untracked)`;
}
} 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.`);
console.log(`Nothing to review: no ${cli.mode} changes for scope "${scopeLabel(cli)}" against ${baseRef}. Skipping the model call.`);
return;
}
@ -352,8 +453,8 @@ async function main() {
}
}
console.log('Sending the diff to the model for review...');
const findings = await postReview(baseUrl, buildReviewMessages(diffText), config.requestTimeoutMs);
console.log(`Sending the ${cli.mode} diff to the model for review...`);
const findings = await postReview(baseUrl, buildReviewMessages(diffText, cli.mode), config.requestTimeoutMs, config.maxOutputTokens);
const report = formatReport({
baseRef,
@ -363,9 +464,11 @@ async function main() {
findings,
truncated,
scope: cli,
mode: cli.mode,
untrackedCount,
});
const reportPath = join(repoRoot, 'docs', 'claude-scratch', 'local-review-report.md');
const reportPath = join(repoRoot, 'docs', 'claude-scratch', `local-review-${cli.mode}-report.md`);
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(reportPath, report);

View file

@ -1,7 +1,7 @@
{
"id": "rewrite-voice-notes",
"name": "ReWrite (Voice Notes)",
"version": "1.1.1",
"version": "1.2.0",
"minAppVersion": "1.4.4",
"description": "Record or paste speech and have it transcribed and structured by AI.",
"author": "WiseGuru",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "rewrite-voice-notes",
"version": "1.1.1",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rewrite-voice-notes",
"version": "1.1.1",
"version": "1.2.0",
"license": "0BSD",
"dependencies": {
"@zxcvbn-ts/core": "^3.0.4",

View file

@ -1,6 +1,6 @@
{
"name": "rewrite-voice-notes",
"version": "1.1.1",
"version": "1.2.0",
"description": "Record or paste speech and have it transcribed and structured by AI.",
"main": "main.js",
"type": "module",
@ -11,6 +11,7 @@
"lint": "eslint .",
"test": "vitest run",
"review": "node local-review.mjs",
"review:docs": "node local-review.mjs --docs",
"release:prep": "node prepare-release-vault.mjs"
},
"keywords": [],

View file

@ -57,13 +57,28 @@ export function buildAudioFilename(now: Date, ext: string): string {
export async function persistAudio(app: App, blob: Blob, settings: GlobalSettings): Promise<string> {
const ext = mimeToExtension(blob.type);
const filename = buildAudioFilename(new Date(), ext);
const path = await resolveTargetPath(app, filename, settings.attachmentsFolderPath);
const path = await resolveAttachmentPath(app, filename, settings.attachmentsFolderPath);
const buffer = await blob.arrayBuffer();
await app.vault.createBinary(path, buffer);
return path;
}
async function resolveTargetPath(app: App, filename: string, configuredFolder: string): Promise<string> {
// Where a recording lands: the configured attachments folder (with manual
// de-collision) when set, otherwise Obsidian's own attachments setting via
// getAvailablePathForAttachment. Exported so the auto-ingest mover resolves the
// same destination a live recording would get. `sourcePath` (the note the
// attachment belongs to) lets a note-relative attachment setting resolve
// correctly; it is harmless for the recording flow (no note yet). The parent
// folder of the resolved path is always created, because a caller that MOVES a
// file into it via `fileManager.renameFile` (auto-ingest) fails when the folder
// is missing (unlike `vault.create` / `createBinary`, rename does not create
// intermediate folders).
export async function resolveAttachmentPath(
app: App,
filename: string,
configuredFolder: string,
sourcePath = '',
): Promise<string> {
const folder = configuredFolder.trim();
if (folder) {
await ensureFolder(app, folder);
@ -72,11 +87,41 @@ async function resolveTargetPath(app: App, filename: string, configuredFolder: s
}
const fm = (app as unknown as { fileManager?: AttachmentFileManager }).fileManager;
if (fm?.getAvailablePathForAttachment) {
return await fm.getAvailablePathForAttachment(filename);
const resolved = await fm.getAvailablePathForAttachment(filename, sourcePath);
await ensureParentFolder(app, resolved);
return resolved;
}
return await deCollide(app, normalizePath(filename));
}
// The folder a recording WOULD be stored in, resolved the same way as
// resolveAttachmentPath but WITHOUT creating anything (no ensureFolder). Auto-ingest
// uses this to detect, before processing a file, that a rule's folder is also the
// attachments destination (which would make the move a pointless in-place rename and
// re-ingest the file every run). `sourcePath` lets a note-relative attachment setting
// resolve representatively (pass a path in the note's target folder).
export async function resolveAttachmentFolder(
app: App,
configuredFolder: string,
sourcePath = '',
): Promise<string> {
const folder = configuredFolder.trim();
if (folder) return normalizePath(folder);
const fm = (app as unknown as { fileManager?: AttachmentFileManager }).fileManager;
if (fm?.getAvailablePathForAttachment) {
const resolved = normalizePath(await fm.getAvailablePathForAttachment('rewrite-probe.webm', sourcePath));
return resolved.includes('/') ? resolved.slice(0, resolved.lastIndexOf('/')) : '';
}
return '';
}
async function ensureParentFolder(app: App, filePath: string): Promise<void> {
const normalized = normalizePath(filePath);
const slash = normalized.lastIndexOf('/');
if (slash <= 0) return; // vault root: nothing to create
await ensureFolder(app, normalized.slice(0, slash));
}
async function ensureFolder(app: App, folder: string): Promise<void> {
const normalized = normalizePath(folder);
if (app.vault.getAbstractFileByPath(normalized)) return;

236
src/ingest.ts Normal file
View file

@ -0,0 +1,236 @@
import { Notice, TFile, TFolder, normalizePath } from 'obsidian';
import type ReWritePlugin from './main';
import { runPipeline } from './pipeline';
import { resolveActiveProfile } from './platform';
import { resolveAttachmentFolder, resolveAttachmentPath } from './audio-persist';
import { isAudioFile, readAudioFileAsBlob } from './ui/audio-source';
import { isProfileConfigured } from './ui/setup-card';
import { IngestRule, NoteTemplate } from './types';
// Notice.noticeEl cast, mirroring pipeline-progress.ts (messageEl/containerEl only
// exist since Obsidian 1.8.7, newer than minAppVersion).
interface NoticeElementLike {
noticeEl: HTMLElement;
}
export interface IngestBatchSummary {
processed: number;
failed: number;
skippedRules: number;
canceled: boolean;
}
// One unit of work: an audio file paired with the template of the rule whose
// folder it was found in.
interface IngestWorkItem {
file: TFile;
template: NoteTemplate;
}
// A rule is runnable only when its template still exists AND targets a new file.
// Unattended ingest has no active editor, so cursor/append would silently cascade
// into newFile anyway; requiring newFile makes the destination explicit. Exported
// for tests and reused by the rule-editor dropdown filter.
export function isIngestTemplate(template: NoteTemplate | undefined): template is NoteTemplate {
return !!template && template.insertMode === 'newFile';
}
// Direct children of the rule folder only (no recursion): predictable scope, and
// a user's organized subfolders are never swept up by accident.
export function collectIngestFiles(folder: TFolder): TFile[] {
const files: TFile[] = [];
for (const child of folder.children) {
if (isAudioFile(child)) files.push(child);
}
files.sort((a, b) => a.name.localeCompare(b.name));
return files;
}
// Scan every enabled ingest rule and run each found audio file through the
// pipeline with the rule's template, serialized one at a time. Move-on-success is
// the dedupe mechanism: a processed file is moved to the attachments location (via
// fileManager.renameFile, which rewrites the just-created note's embed link), so a
// re-run never reprocesses it; a failed file stays put for the next run.
export async function runIngestBatch(plugin: ReWritePlugin): Promise<IngestBatchSummary | null> {
const settings = plugin.settings;
const { profile } = resolveActiveProfile(settings);
const rules = settings.ingestRules.filter((r) => r.enabled);
if (rules.length === 0) {
new Notice('ReWrite: no enabled auto-ingest folders. Add one in settings.');
return null;
}
if (plugin.encryptionStatus.locked) {
new Notice('ReWrite: API keys are locked. Unlock to process ingest folders.');
plugin.promptUnlock();
return null;
}
if (profile.transcriptionProvider === 'none') {
new Notice('ReWrite: transcription is disabled for this profile. Pick a transcription provider in settings.');
return null;
}
if (!isProfileConfigured(profile)) {
new Notice('ReWrite: configure a transcription and LLM provider before processing ingest folders.');
return null;
}
const { items, skippedRules } = await collectWork(plugin, rules);
if (items.length === 0) {
new Notice(skippedRules > 0
? `ReWrite: no audio files to ingest (${skippedRules} rule${skippedRules === 1 ? '' : 's'} skipped; check folders and templates).`
: 'ReWrite: no audio files found in the ingest folders.');
return { processed: 0, failed: 0, skippedRules, canceled: false };
}
const controller = new AbortController();
const progress = new Notice(`ReWrite: ingesting ${items.length} file${items.length === 1 ? '' : 's'}...`, 0);
const cancelBtn = (progress as unknown as NoticeElementLike).noticeEl
.createEl('button', { text: 'Cancel', cls: 'rewrite-notice-cancel' });
cancelBtn.addEventListener('click', () => controller.abort());
let processed = 0;
let canceled = false;
// Keep the actual reason each file failed so the user (and logs) can see WHY,
// rather than only a bare "N failed" count.
const failures: Array<{ name: string; message: string }> = [];
try {
for (let i = 0; i < items.length; i++) {
if (controller.signal.aborted) {
canceled = true;
break;
}
const item = items[i];
if (!item) continue;
progress.setMessage(`ReWrite: ingesting ${i + 1}/${items.length}${item.file.name}`);
try {
await ingestOne(plugin, item, controller.signal);
processed++;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
canceled = true;
break;
}
const message = e instanceof Error ? e.message : String(e);
failures.push({ name: item.file.name, message });
console.error(`ReWrite: ingest failed for ${item.file.path}`, e);
}
}
} finally {
progress.hide();
}
const failed = failures.length;
const failNote = failed > 0 ? `, ${failed} failed (left in place for the next run)` : '';
const cancelNote = canceled ? ' Canceled; remaining files stay put.' : '';
new Notice(`ReWrite: ingest done. ${processed} processed${failNote}.${cancelNote}`);
// Surface the concrete failure reasons in their own sticky notice so a failed
// batch is diagnosable without opening the developer console.
if (failed > 0) {
const lines = failures.slice(0, 5).map((f) => `${f.name}: ${f.message}`);
if (failed > 5) lines.push(`…and ${failed - 5} more (see the developer console).`);
new Notice(`ReWrite: ingest errors —\n${lines.join('\n')}`, 15_000);
}
return { processed, failed, skippedRules, canceled };
}
async function collectWork(plugin: ReWritePlugin, rules: IngestRule[]): Promise<{ items: IngestWorkItem[]; skippedRules: number }> {
const items: IngestWorkItem[] = [];
const seenPaths = new Set<string>();
let skippedRules = 0;
for (const rule of rules) {
const template = plugin.templates.find((t) => t.id === rule.templateId);
if (!isIngestTemplate(template)) {
skippedRules++;
new Notice(`ReWrite: ingest rule for "${rule.folderPath}" skipped. Its template is missing or does not create a new file.`);
continue;
}
const folder = plugin.app.vault.getAbstractFileByPath(normalizePath(rule.folderPath.trim()));
if (!(folder instanceof TFolder)) {
skippedRules++;
new Notice(`ReWrite: ingest folder "${rule.folderPath}" not found; rule skipped.`);
continue;
}
// Reject the misconfiguration where the rule folder is ALSO where recordings are
// stored, up front (before creating any note): the post-processing move would then
// be a pointless in-place rename and the file would be re-ingested every run. The
// destination is resolved representatively for this template's note folder; the
// move-time guard in moveIngestedFile stays as a per-file backstop.
const noteFolder = (template.newFileFolder ?? '').trim();
const probeNotePath = normalizePath(`${noteFolder ? `${noteFolder}/` : ''}rewrite-probe.md`);
const destFolder = await resolveAttachmentFolder(plugin.app, plugin.settings.attachmentsFolderPath, probeNotePath);
if (normalizeIngestFolder(destFolder) === normalizeIngestFolder(folder.path)) {
skippedRules++;
new Notice(`ReWrite: ingest rule for "${rule.folderPath}" skipped. This folder is also where recordings are stored, so processed files would stay put and be re-ingested every run. Point the rule at a different folder, or set a distinct Attachments folder in Recording settings.`);
continue;
}
for (const file of collectIngestFiles(folder)) {
// Two rules pointing at the same folder must not queue a file twice.
if (seenPaths.has(file.path)) continue;
seenPaths.add(file.path);
items.push({ file, template });
}
}
return { items, skippedRules };
}
async function ingestOne(plugin: ReWritePlugin, item: IngestWorkItem, signal: AbortSignal): Promise<void> {
const settings = plugin.settings;
const { profile } = resolveActiveProfile(settings);
const blob = await readAudioFileAsBlob(plugin.app, item.file);
// sourcePath skips the persist stage and reuses the existing file for the
// ![[embed]] prepend, exactly like the manual reprocess flow.
const result = await runPipeline({
app: plugin.app,
settings,
host: plugin,
profile,
template: item.template,
source: { kind: 'audio', audio: blob, sourcePath: item.file.path },
signal,
});
// Last step, only after the note was created: move the recording in with the
// other voice recordings. renameFile rewrites the just-created note's embed to
// the new path, so the link stays valid. A pipeline failure before this point
// leaves the file in the ingest folder to be retried next run. A failure of the
// move itself is reported distinctly, because the note DID get created, so the
// user must move/delete the source manually to avoid a duplicate next run.
try {
await moveIngestedFile(plugin, item.file, result.insert.path);
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
throw new Error(`note created, but the recording could not be moved out of the ingest folder (${reason}). Move or delete "${item.file.name}" yourself so the next run does not process it again.`);
}
}
async function moveIngestedFile(plugin: ReWritePlugin, file: TFile, notePath?: string): Promise<void> {
// Pass the just-created note as the source path so a note-relative attachment
// setting resolves near it; resolveAttachmentPath also creates the destination
// folder (renameFile, unlike vault.create, will not).
const target = normalizePath(await resolveAttachmentPath(plugin.app, file.name, plugin.settings.attachmentsFolderPath, notePath ?? ''));
// If the destination folder is the folder the file already sits in (the ingest
// folder IS the attachments location), moving is pointless — the de-collision
// would just rename it in place and the next run would reprocess it. Surface
// that misconfiguration instead of quietly churning files.
if (ingestTargetIsSameFolder(target, file.parent?.path ?? '')) {
throw new Error('its attachments location is the same folder it is already in; point the ingest rule at a different folder, or set a distinct Attachments folder in settings');
}
await plugin.app.fileManager.renameFile(file, target);
}
// Normalize a folder path for equality comparison: strip a trailing slash and treat the
// vault root ('/' from Obsidian, or '') as the same empty root. Pure + exported for tests.
export function normalizeIngestFolder(path: string): string {
const trimmed = (path ?? '').trim();
if (trimmed === '' || trimmed === '/') return '';
return trimmed.replace(/\/+$/, '');
}
// Whether the resolved attachment target lands in the same folder the file already sits in.
// When it does, moving is a no-op de-collision rename and the file would be reprocessed every
// run, so the caller treats it as an error. Compares folder portions, treating the vault root
// ('/' or '') as the same empty root. Pure + exported for tests (this is the comparison a
// local review flagged as worth pinning down).
export function ingestTargetIsSameFolder(targetPath: string, fileParentPath: string): boolean {
const targetParent = targetPath.includes('/') ? targetPath.slice(0, targetPath.lastIndexOf('/')) : '';
return normalizeIngestFolder(targetParent) === normalizeIngestFolder(fileParentPath);
}

View file

@ -4,12 +4,14 @@ import { ReWriteSettingTab } from './settings/tab';
import { ReWriteModal } from './ui/modal';
import { PassphraseModal } from './ui/passphrase-modal';
import { QuickRecordController, startQuickRecord } from './ui/quick-record';
import { RealtimeController, startRealtimeTranscription } from './ui/realtime';
import { resolveActiveTextSource, resolveTextFromEditor, runTextPipeline, TextResolution } from './ui/text-source';
import { TemplatePickerModal } from './ui/template-picker';
import { AudioFilePickerModal } from './ui/audio-file-picker';
import { collectAudioFiles, isAudioFile, runAudioFilePipeline } from './ui/audio-source';
import { runIngestBatch } from './ingest';
import { WhisperStatusBar } from './ui/whisper-status-bar';
import { GlobalSettings, KnownNoun, NoteTemplate, PipelineHost } from './types';
import { DestinationOverride, GlobalSettings, KnownNoun, NoteTemplate, PipelineHost } from './types';
import { WhisperHost } from './whisper-host';
import { bindWhisperHost } from './transcription/whisper-local';
import { resolveActiveProfile } from './platform';
@ -35,6 +37,11 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
// two rapid hotkey presses would both see it null and each open a mic stream, orphaning
// the first one.
private quickRecordStarting = false;
// One realtime dictation session at a time, same ownership model as
// activeQuickRecord (single slot + a synchronous starting guard, cleaned up in
// onunload).
private activeRealtime: RealtimeController | null = null;
private realtimeStarting = 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.
@ -64,8 +71,27 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
new Notice(`ReWrite: adopted whisper-server from previous session (pid ${snap.pid ?? '?'}).`);
} else if (snap.status === 'external') {
new Notice(`ReWrite: detected external whisper-server on ${snap.baseUrl}. Transcription will use it; ReWrite won't stop it.`);
} else if (this.shouldAutoStartWhisper(snap.status)) {
// Auto-start (Phase B): deferred until the probe settles so an
// adopted or external server is never doubled up. start() itself
// re-probes, so a race with a manual Start stays safe.
void this.whisperHost.start(this.settings.localWhisper).then(() => {
new Notice('ReWrite: started whisper-server automatically.');
}).catch((e) => {
console.error('ReWrite: whisper-host auto-start failed', e);
new Notice(`ReWrite: could not auto-start whisper-server. ${e instanceof Error ? e.message : String(e)}`);
});
}
}).catch((e) => { console.error('ReWrite: whisper-host probe failed', e); });
// Idle stop (Phase B): a cheap once-a-minute check; stopIfIdle() is a
// no-op unless the server is ReWrite-owned, idle past the configured
// threshold, and has no transcription in flight.
this.registerInterval(window.setInterval(() => {
void this.whisperHost.stopIfIdle(this.settings.localWhisper.idleStopMinutes).then((stopped) => {
if (stopped) new Notice(`ReWrite: stopped whisper-server after ${this.settings.localWhisper.idleStopMinutes} min idle.`);
}).catch((e) => { console.error('ReWrite: whisper-host idle stop failed', e); });
}, 60_000));
}
this.addSettingTab(new ReWriteSettingTab(this.app, this));
@ -113,6 +139,22 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
},
});
this.addCommand({
id: 'process-ingest-folders',
name: 'Process auto-ingest folders',
callback: () => {
void runIngestBatch(this);
},
});
this.addCommand({
id: 'realtime-transcribe',
name: 'Real-time transcription (start/stop)',
callback: () => {
void this.toggleRealtime();
},
});
this.addCommand({
id: 'start-whisper-host',
name: 'Start local whisper.cpp server',
@ -198,6 +240,8 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
onunload(): void {
this.activeQuickRecord?.cancel();
this.activeQuickRecord = null;
this.activeRealtime?.cancel();
this.activeRealtime = null;
void this.whisperHost?.stop();
for (const handle of Object.values(this.pendingRefresh)) {
if (handle !== undefined) window.clearTimeout(handle);
@ -329,6 +373,12 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
new ReWriteModal(this.app, this).open();
}
private shouldAutoStartWhisper(status: string): boolean {
if (!this.settings.localWhisper.autoStart) return false;
if (resolveActiveProfile(this.settings).profile.transcriptionProvider !== 'whisper-local') return false;
return status === 'stopped' || status === 'crashed';
}
private async startWhisperHost(): Promise<void> {
try {
await this.whisperHost.start(this.settings.localWhisper);
@ -352,7 +402,6 @@ 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) {
@ -362,11 +411,61 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
return;
}
}
await this.launchQuickRecord({ template, commandId });
}
// Entry point for the main modal's "Record in background" handoff: starts a
// Quick Record capture carrying the modal's per-invocation params (template,
// destination override, context hint). Routed through the SAME activeQuickRecord
// slot and quickRecordStarting guard as the two commands, so the one-recording-
// at-a-time guarantee holds no matter which surface starts a capture, and
// onunload's activeQuickRecord?.cancel() covers this path too.
async startBackgroundRecording(opts: {
template: NoteTemplate;
destinationOverride?: DestinationOverride;
contextHint?: string;
diarize?: boolean;
}): Promise<void> {
if (this.activeQuickRecord || this.quickRecordStarting) {
new Notice('ReWrite: a recording is already in progress.');
return;
}
await this.launchQuickRecord(opts);
}
isRecordingActive(): boolean {
return this.activeQuickRecord !== null || this.quickRecordStarting;
}
private async toggleRealtime(): Promise<void> {
if (this.activeRealtime) {
await this.activeRealtime.finish();
return;
}
if (this.realtimeStarting) return;
this.realtimeStarting = true;
try {
this.activeRealtime = await startRealtimeTranscription(this, () => {
this.activeRealtime = null;
});
} finally {
this.realtimeStarting = false;
}
}
private async launchQuickRecord(opts: {
template?: NoteTemplate;
commandId?: string;
destinationOverride?: DestinationOverride;
contextHint?: string;
diarize?: boolean;
}): Promise<void> {
if (this.quickRecordStarting) return;
this.quickRecordStarting = true;
try {
this.activeQuickRecord = await startQuickRecord(this, () => {
this.activeQuickRecord = null;
}, { template, commandId });
}, opts);
} finally {
this.quickRecordStarting = false;
}

View file

@ -33,6 +33,10 @@ export interface PipelineParams {
// surfaced for templates with `enableContextHint`. Injected as a `## Context`
// system-prompt block when non-empty; the pipeline does not check the flag.
contextHint?: string;
// Per-invocation speaker-diarization toggle (from the modal). OR-ed with the template's
// `diarize` flag and gated by provider capability. There is no persisted profile setting
// for diarization anymore; it is chosen per run (template default + this toggle).
diarize?: boolean;
onStage?: (stage: PipelineStage) => void;
signal?: AbortSignal;
}
@ -104,13 +108,13 @@ async function collectTranscript(params: PipelineParams): Promise<string> {
validateRecording(source.audio.size, source.durationMs, params.profile.transcriptionProvider);
params.onStage?.('transcribe');
const provider = createTranscriptionProvider(params.profile.transcriptionProvider);
// A template can force diarization on (e.g. the Meeting transcript
// default). Only merge it when the provider can actually diarize;
// otherwise leave the profile config untouched (no-op on the rest).
const config = params.template.diarize
&& transcriptionProviderSupportsDiarization(params.profile.transcriptionProvider)
? { ...params.profile.transcriptionConfig, diarize: true }
: params.profile.transcriptionConfig;
// Diarization is chosen per invocation: the template's `diarize` flag (e.g. the
// Meeting transcript default) OR the modal's per-run toggle, gated by provider
// capability. There is no persisted profile setting; diarize is always set
// explicitly here so a stale value from an older data.json can't leak through.
const effectiveDiarize = (params.template.diarize || params.diarize === true)
&& transcriptionProviderSupportsDiarization(params.profile.transcriptionProvider);
const config = { ...params.profile.transcriptionConfig, diarize: effectiveDiarize };
return provider.transcribe(source.audio, config, params.signal, source.durationMs);
}
}

104
src/realtime/assemblyai.ts Normal file
View file

@ -0,0 +1,104 @@
import { TranscriptionConfig } from '../types';
import { jsonGet } from '../http';
import { RealtimeProvider, RealtimeSession, RealtimeSessionCallbacks, waitForClose, waitForOpen } from './index';
interface TokenResponse {
token?: string;
}
interface StreamingMessage {
type?: string;
transcript?: string;
end_of_turn?: boolean;
turn_is_formatted?: boolean;
error?: string;
}
const TOKEN_URL = 'https://streaming.assemblyai.com/v3/token?expires_in_seconds=60';
const WS_BASE = 'wss://streaming.assemblyai.com/v3/ws';
// AssemblyAI Universal-Streaming (v3) over WebSocket. Browser WebSockets cannot
// set an Authorization header, so per AssemblyAI's documented browser flow the
// real API key first buys a SHORT-LIVED single-use token over a normal
// header-authenticated request, and only that ephemeral token rides the WS URL
// query. This is the one sanctioned exception to the "auth never goes in the
// query" rule (see the HTTP gotchas in CLAUDE.md): the value expires in 60 s
// and cannot mint further tokens, and no error path echoes the URL.
export function createAssemblyAIRealtime(): RealtimeProvider {
return {
id: 'assemblyai',
async start(
config: TranscriptionConfig,
sampleRate: number,
callbacks: RealtimeSessionCallbacks,
): Promise<RealtimeSession> {
if (!config.apiKey) throw new Error('assemblyai: API key is not configured');
const tokenRes = await jsonGet<TokenResponse>(
'assemblyai',
TOKEN_URL,
{ Authorization: config.apiKey },
);
if (!tokenRes.token) {
throw new Error('assemblyai: could not get a realtime session token. Realtime streaming may require a funded account.');
}
const params = new URLSearchParams({
sample_rate: String(sampleRate),
encoding: 'pcm_s16le',
// The server re-sends each finished turn once more with punctuation
// and casing applied; only that formatted event is treated as final.
format_turns: 'true',
token: tokenRes.token,
});
const ws = new WebSocket(`${WS_BASE}?${params.toString()}`);
ws.binaryType = 'arraybuffer';
let stopping = false;
ws.addEventListener('message', (ev: MessageEvent) => {
if (typeof ev.data !== 'string') return;
let msg: StreamingMessage;
try {
msg = JSON.parse(ev.data) as StreamingMessage;
} catch {
return;
}
if (msg.type === 'Turn') {
const transcript = (msg.transcript ?? '').trim();
if (!transcript) return;
// With format_turns on, an unformatted end-of-turn precedes the
// formatted one; treating only the formatted event as final keeps
// each turn from being inserted twice.
if (msg.end_of_turn && msg.turn_is_formatted) callbacks.onFinal(transcript);
else callbacks.onInterim(transcript);
return;
}
if (msg.type === 'Error' && !stopping) {
callbacks.onError(new Error(`assemblyai: ${msg.error ?? 'realtime session error'}`));
}
});
ws.addEventListener('error', () => {
if (!stopping) callbacks.onError(new Error('assemblyai: realtime connection error.'));
});
ws.addEventListener('close', () => {
if (!stopping) callbacks.onUnexpectedClose();
});
await waitForOpen(ws, 'assemblyai');
return {
sendAudio(chunk: ArrayBuffer): void {
if (ws.readyState === WebSocket.OPEN) ws.send(chunk);
},
async stop(): Promise<void> {
stopping = true;
// Terminate flushes the last turn (delivered through the message
// handler while we wait), then the server closes the socket.
try {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'Terminate' }));
} catch { /* best effort */ }
await waitForClose(ws);
},
};
},
};
}

81
src/realtime/deepgram.ts Normal file
View file

@ -0,0 +1,81 @@
import { TranscriptionConfig } from '../types';
import { RealtimeProvider, RealtimeSession, RealtimeSessionCallbacks, waitForClose, waitForOpen } from './index';
interface DeepgramLiveMessage {
type?: string;
is_final?: boolean;
channel?: {
alternatives?: Array<{ transcript?: string }>;
};
}
// Deepgram live STT over WebSocket. Auth rides the ['token', <key>] WebSocket
// subprotocol — the browser-supported equivalent of the Authorization header
// (browser WebSockets cannot set custom headers), so the key never appears in
// the URL. Results arrive as JSON messages; is_final marks a finalized segment.
export function createDeepgramRealtime(): RealtimeProvider {
return {
id: 'deepgram',
async start(
config: TranscriptionConfig,
sampleRate: number,
callbacks: RealtimeSessionCallbacks,
): Promise<RealtimeSession> {
if (!config.apiKey) throw new Error('deepgram: API key is not configured');
const params = new URLSearchParams({
encoding: 'linear16',
sample_rate: String(sampleRate),
channels: '1',
interim_results: 'true',
smart_format: 'true',
});
// The profile's batch model generally works for live too (nova family);
// when empty, Deepgram's server-side default applies.
if (config.model) params.set('model', config.model);
if (config.language) params.set('language', config.language);
const ws = new WebSocket(`wss://api.deepgram.com/v1/listen?${params.toString()}`, ['token', config.apiKey]);
ws.binaryType = 'arraybuffer';
let stopping = false;
ws.addEventListener('message', (ev: MessageEvent) => {
if (typeof ev.data !== 'string') return;
let msg: DeepgramLiveMessage;
try {
msg = JSON.parse(ev.data) as DeepgramLiveMessage;
} catch {
return;
}
if (msg.type !== 'Results') return;
const transcript = msg.channel?.alternatives?.[0]?.transcript ?? '';
if (!transcript.trim()) return;
if (msg.is_final) callbacks.onFinal(transcript.trim());
else callbacks.onInterim(transcript.trim());
});
ws.addEventListener('error', () => {
if (!stopping) callbacks.onError(new Error('deepgram: realtime connection error.'));
});
ws.addEventListener('close', () => {
if (!stopping) callbacks.onUnexpectedClose();
});
await waitForOpen(ws, 'deepgram');
return {
sendAudio(chunk: ArrayBuffer): void {
if (ws.readyState === WebSocket.OPEN) ws.send(chunk);
},
async stop(): Promise<void> {
stopping = true;
// CloseStream tells Deepgram to flush pending finals, then close;
// trailing Results still dispatch through the message handler while
// we wait for the close.
try {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'CloseStream' }));
} catch { /* best effort */ }
await waitForClose(ws);
},
};
},
};
}

96
src/realtime/index.ts Normal file
View file

@ -0,0 +1,96 @@
import { TranscriptionConfig, TranscriptionProviderID } from '../types';
import { createDeepgramRealtime } from './deepgram';
import { createAssemblyAIRealtime } from './assemblyai';
// Callbacks a realtime session fires as the stream progresses. onFinal receives
// finalized segments (safe to insert into the note); onInterim receives the
// rolling in-progress hypothesis (display-only, superseded by later events).
export interface RealtimeSessionCallbacks {
onFinal(text: string): void;
onInterim(text: string): void;
onError(error: Error): void;
// The server closed the socket without stop() being called.
onUnexpectedClose(): void;
}
export interface RealtimeSession {
// Push one chunk of 16-bit mono PCM at the session sample rate.
sendAudio(chunk: ArrayBuffer): void;
// Graceful shutdown: tell the server the stream ended, let trailing finals
// flush through onFinal, then close. Resolves once the socket is closed.
stop(): Promise<void>;
}
export interface RealtimeProvider {
readonly id: TranscriptionProviderID;
start(
config: TranscriptionConfig,
sampleRate: number,
callbacks: RealtimeSessionCallbacks,
): Promise<RealtimeSession>;
}
// Providers with a browser-usable streaming endpoint. Deepgram authenticates a
// WebSocket via the ['token', key] subprotocol; AssemblyAI via a short-lived
// temporary token. The rest (OpenAI/Groq whisper-shape, Rev.ai async, whisper.cpp
// server) expose no realtime endpoint reachable from a WebView, so realtime mode is
// unavailable on them.
//
// Voxtral (Mistral) is deliberately NOT listed: its realtime WebSocket rejects the
// only auth a browser WebSocket can send (see src/realtime/voxtral.ts and the
// Voxtral wiki page), so it is not WebView-reachable. The adapter is kept on disk,
// unwired, for contributors who find a working browser-auth path.
export function transcriptionProviderSupportsRealtime(id: TranscriptionProviderID): boolean {
return id === 'deepgram' || id === 'assemblyai';
}
export function createRealtimeProvider(id: TranscriptionProviderID): RealtimeProvider {
switch (id) {
case 'deepgram':
return createDeepgramRealtime();
case 'assemblyai':
return createAssemblyAIRealtime();
default:
throw new Error(`Realtime transcription is not supported for ${id}. Use AssemblyAI or Deepgram.`);
}
}
// Shared WebSocket open/close plumbing for the two adapters.
export function waitForOpen(ws: WebSocket, provider: string, timeoutMs = 10_000): Promise<void> {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
cleanup();
try { ws.close(); } catch { /* best effort */ }
reject(new Error(`${provider}: realtime connection timed out.`));
}, timeoutMs);
const onOpen = (): void => {
cleanup();
resolve();
};
const onErr = (): void => {
cleanup();
reject(new Error(`${provider}: realtime connection failed. Check your API key and network.`));
};
const cleanup = (): void => {
window.clearTimeout(timer);
ws.removeEventListener('open', onOpen);
ws.removeEventListener('error', onErr);
};
ws.addEventListener('open', onOpen);
ws.addEventListener('error', onErr);
});
}
export function waitForClose(ws: WebSocket, timeoutMs = 5_000): Promise<void> {
if (ws.readyState === WebSocket.CLOSED) return Promise.resolve();
return new Promise((resolve) => {
const timer = window.setTimeout(() => {
try { ws.close(); } catch { /* best effort */ }
resolve();
}, timeoutMs);
ws.addEventListener('close', () => {
window.clearTimeout(timer);
resolve();
}, { once: true });
});
}

137
src/realtime/pcm.ts Normal file
View file

@ -0,0 +1,137 @@
// Live raw-PCM microphone capture for the realtime transcription mode.
// MediaRecorder produces containerized chunks (webm/mp4) that streaming STT
// endpoints cannot consume mid-stream, so this taps the mic with a
// ScriptProcessorNode (deprecated but universally available in every WebView
// Obsidian runs in, and needs no external worklet module, which the app CSP
// would complicate), downsamples to the session rate, and emits 16-bit PCM.
export const REALTIME_SAMPLE_RATE = 16_000;
// Linear-interpolation resample. Pure; exported for tests. Returns the input
// unchanged when the rates already match.
export function downsampleBuffer(input: Float32Array, inRate: number, outRate: number): Float32Array {
if (inRate === outRate) return input;
if (inRate < outRate) {
throw new Error(`downsampleBuffer: cannot upsample ${inRate} -> ${outRate}`);
}
const ratio = inRate / outRate;
const outLength = Math.floor(input.length / ratio);
const out = new Float32Array(outLength);
for (let i = 0; i < outLength; i++) {
const pos = i * ratio;
const left = Math.floor(pos);
const right = Math.min(left + 1, input.length - 1);
const frac = pos - left;
out[i] = (input[left] ?? 0) * (1 - frac) + (input[right] ?? 0) * frac;
}
return out;
}
// Clamp and quantize float samples (-1..1) to signed 16-bit little-endian PCM.
// Pure; exported for tests.
export function floatTo16BitPcm(input: Float32Array): Int16Array {
const out = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
const s = Math.max(-1, Math.min(1, input[i] ?? 0));
out[i] = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff);
}
return out;
}
export function isPcmCaptureAvailable(): boolean {
const hasCtx = typeof window !== 'undefined'
&& (typeof window.AudioContext !== 'undefined'
|| typeof (window as unknown as { webkitAudioContext?: unknown }).webkitAudioContext !== 'undefined');
return hasCtx && typeof navigator !== 'undefined' && !!navigator.mediaDevices;
}
export class PcmCapture {
private stream: MediaStream | null = null;
private context: AudioContext | null = null;
private source: MediaStreamAudioSourceNode | null = null;
// ScriptProcessorNode is deprecated in favor of AudioWorklet, but a worklet
// must be loaded as a separate module (addModule(url)), which a single-file
// bundled Obsidian plugin cannot ship and the app CSP complicates via blob
// URLs. ScriptProcessor remains supported in every WebView Obsidian runs in;
// revisit if it is ever actually removed.
// eslint-disable-next-line @typescript-eslint/no-deprecated
private processor: ScriptProcessorNode | null = null;
private silentGain: GainNode | null = null;
// Opens the mic and begins emitting 16 kHz mono Int16 PCM chunks. The chunk
// size follows the processor buffer (4096 frames at the context rate, so
// roughly 85 ms at 48 kHz — within every provider's accepted frame range).
async start(onChunk: (chunk: ArrayBuffer) => void): Promise<void> {
if (this.context) throw new Error('PcmCapture already started.');
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`Microphone access denied: ${msg}`);
}
const Ctx = window.AudioContext
?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctx) {
for (const track of stream.getTracks()) track.stop();
throw new Error('Web Audio is unavailable; realtime capture cannot run here.');
}
const context = new Ctx();
// The instance fields are assigned only once every node is wired up (all-or-nothing),
// so if any setup call below throws, `this.context` is still null and stop() cannot
// reach the just-created context. Close it (and release the mic) here so a partial
// start never leaks an AudioContext or a live mic track.
try {
const source = context.createMediaStreamSource(stream);
// See the deprecation note on the `processor` field for why ScriptProcessor
// is used instead of AudioWorklet.
// eslint-disable-next-line @typescript-eslint/no-deprecated
const processor = context.createScriptProcessor(4096, 1, 1);
// eslint-disable-next-line @typescript-eslint/no-deprecated
processor.onaudioprocess = (ev) => {
// eslint-disable-next-line @typescript-eslint/no-deprecated
const data = ev.inputBuffer.getChannelData(0);
const down = downsampleBuffer(data, context.sampleRate, REALTIME_SAMPLE_RATE);
const pcm = floatTo16BitPcm(down);
onChunk(pcm.buffer);
};
// A ScriptProcessorNode only runs while connected to the destination. Route
// it through a zero-gain node so nothing is audible (the output buffer is
// silence anyway; the gain is belt-and-braces against feedback).
const gain = context.createGain();
gain.gain.value = 0;
source.connect(processor);
processor.connect(gain);
gain.connect(context.destination);
this.stream = stream;
this.context = context;
this.source = source;
this.processor = processor;
this.silentGain = gain;
} catch (e) {
void context.close().catch(() => { /* best effort */ });
for (const track of stream.getTracks()) track.stop();
throw e;
}
}
stop(): void {
// eslint-disable-next-line @typescript-eslint/no-deprecated
if (this.processor) this.processor.onaudioprocess = null;
try { this.source?.disconnect(); } catch { /* best effort */ }
try { this.processor?.disconnect(); } catch { /* best effort */ }
try { this.silentGain?.disconnect(); } catch { /* best effort */ }
if (this.context) {
void this.context.close().catch(() => { /* best effort */ });
}
if (this.stream) {
for (const track of this.stream.getTracks()) track.stop();
}
this.stream = null;
this.context = null;
this.source = null;
this.processor = null;
this.silentGain = null;
}
}

133
src/realtime/voxtral.ts Normal file
View file

@ -0,0 +1,133 @@
import { TranscriptionConfig } from '../types';
import { RealtimeProvider, RealtimeSession, RealtimeSessionCallbacks, waitForClose, waitForOpen } from './index';
// STATUS (v1.2.0): UNWIRED. This adapter is intentionally not referenced by
// src/realtime/index.ts (not in the factory or the realtime-capable gate), so it is
// not bundled or user-selectable. Live testing confirmed the AUTH CAVEAT below: the
// realtime handshake fails with the only auth a browser WebSocket can send, so Voxtral
// realtime is not WebView-reachable. The file is kept as a documented starting point
// for a contributor who finds a working browser-auth path (see the Voxtral wiki page).
// To re-enable, add it back to createRealtimeProvider + transcriptionProviderSupportsRealtime.
//
// Mistral Voxtral realtime STT over WebSocket. Protocol reverse-engineered from the
// mistralai Python SDK (src/mistralai/extra/realtime/{transcription,connection}.py):
// URL: wss://api.mistral.ai/v1/audio/transcriptions/realtime?model=<model>
// send: {"type":"session.update","session":{"audio_format":{"encoding":"pcm_s16le","sample_rate":N}}}
// {"type":"input_audio.append","audio":"<base64 PCM16>"} (max 262144 decoded bytes/msg)
// {"type":"input_audio.flush"} then {"type":"input_audio.end"} to finish
// recv: transcription.text.delta {text} (interim, incremental)
// transcription.done {text} (final)
// error {error:{message}}
//
// AUTH CAVEAT: the SDK authenticates with an `Authorization: Bearer` HTTP header, which a
// browser/WebView WebSocket cannot set. Mistral does not document a browser-usable auth for
// this endpoint. As a best-effort attempt (to be validated against a live key), the key is
// passed via the Sec-WebSocket-Protocol subprotocol, the same browser-safe pattern the
// Deepgram adapter uses (key stays off the URL). If the server rejects the handshake, Voxtral
// realtime is not reachable from a WebView and this adapter should be removed.
const DEFAULT_REALTIME_MODEL = 'voxtral-mini-transcribe-realtime-2602';
const MAX_APPEND_BYTES = 262144;
interface VoxtralEvent {
type?: string;
text?: string;
error?: { message?: string } | string;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i] ?? 0);
}
return btoa(binary);
}
export function createVoxtralRealtime(): RealtimeProvider {
return {
id: 'mistral-voxtral',
async start(
config: TranscriptionConfig,
sampleRate: number,
callbacks: RealtimeSessionCallbacks,
): Promise<RealtimeSession> {
if (!config.apiKey) throw new Error('mistral-voxtral: realtime API key is not configured');
const model = config.model || DEFAULT_REALTIME_MODEL;
const url = `wss://api.mistral.ai/v1/audio/transcriptions/realtime?model=${encodeURIComponent(model)}`;
const ws = new WebSocket(url, ['token', config.apiKey]);
ws.binaryType = 'arraybuffer';
let stopping = false;
// Voxtral streams incremental text.delta events; accumulate them for the rolling
// interim display and clear on each terminal transcription.done.
let interim = '';
ws.addEventListener('message', (ev: MessageEvent) => {
if (typeof ev.data !== 'string') return;
let msg: VoxtralEvent;
try {
msg = JSON.parse(ev.data) as VoxtralEvent;
} catch {
return;
}
switch (msg.type) {
case 'transcription.text.delta':
if (typeof msg.text === 'string') {
interim += msg.text;
if (interim.trim()) callbacks.onInterim(interim.trim());
}
break;
case 'transcription.done': {
const finalText = typeof msg.text === 'string' && msg.text.trim() ? msg.text.trim() : interim.trim();
interim = '';
if (finalText) callbacks.onFinal(finalText);
break;
}
case 'error': {
const em = typeof msg.error === 'string' ? msg.error : msg.error?.message;
if (!stopping) callbacks.onError(new Error(`mistral-voxtral: ${em ?? 'realtime error'}`));
break;
}
}
});
ws.addEventListener('error', () => {
if (!stopping) callbacks.onError(new Error('mistral-voxtral: realtime connection error (if this is an auth failure, Voxtral realtime may not support browser WebSocket auth).'));
});
ws.addEventListener('close', () => {
if (!stopping) callbacks.onUnexpectedClose();
});
await waitForOpen(ws, 'mistral-voxtral');
// Set the audio format before any audio is sent.
ws.send(JSON.stringify({
type: 'session.update',
session: { audio_format: { encoding: 'pcm_s16le', sample_rate: sampleRate } },
}));
return {
sendAudio(chunk: ArrayBuffer): void {
if (ws.readyState !== WebSocket.OPEN) return;
const bytes = new Uint8Array(chunk);
// Split anything over the per-message decoded-byte cap (capture chunks are
// far smaller, so this rarely triggers).
for (let off = 0; off < bytes.length; off += MAX_APPEND_BYTES) {
const slice = bytes.subarray(off, Math.min(off + MAX_APPEND_BYTES, bytes.length));
ws.send(JSON.stringify({ type: 'input_audio.append', audio: bytesToBase64(slice) }));
}
},
async stop(): Promise<void> {
stopping = true;
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input_audio.flush' }));
ws.send(JSON.stringify({ type: 'input_audio.end' }));
}
} catch { /* best effort */ }
// Trailing transcription.done still dispatches through the message handler
// while we wait for the close (or the waitForClose timeout force-closes).
await waitForClose(ws);
},
};
},
};
}

View file

@ -4,6 +4,7 @@ import {
ActiveProfileOverride,
EnvironmentProfile,
GlobalSettings,
IngestRule,
LLMConfig,
LLMProviderID,
LocalWhisperSettings,
@ -54,6 +55,8 @@ const DESKTOP_DEFAULT_PROFILE: EnvironmentProfile = {
transcriptionConfig: { ...EMPTY_TRANSCRIPTION_CONFIG },
llmProvider: 'anthropic',
llmConfig: { ...EMPTY_LLM_CONFIG },
realtimeProvider: 'none',
realtimeConfig: { ...EMPTY_TRANSCRIPTION_CONFIG },
};
const MOBILE_DEFAULT_PROFILE: EnvironmentProfile = {
@ -62,6 +65,8 @@ const MOBILE_DEFAULT_PROFILE: EnvironmentProfile = {
transcriptionConfig: { ...EMPTY_TRANSCRIPTION_CONFIG },
llmProvider: 'anthropic',
llmConfig: { ...EMPTY_LLM_CONFIG },
realtimeProvider: 'none',
realtimeConfig: { ...EMPTY_TRANSCRIPTION_CONFIG },
};
const DEFAULT_LOCAL_WHISPER: LocalWhisperSettings = {
@ -69,6 +74,8 @@ const DEFAULT_LOCAL_WHISPER: LocalWhisperSettings = {
modelPath: '',
port: 8080,
extraArgs: '',
autoStart: false,
idleStopMinutes: 0,
};
export const DEFAULT_SETTINGS: GlobalSettings = {
@ -89,6 +96,9 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
knownNounsPath: 'ReWrite/KnownNouns.md',
modelCache: { transcription: {}, llm: {} },
localWhisper: DEFAULT_LOCAL_WHISPER,
recordInBackground: false,
disabledDefaultTemplateIds: [],
ingestRules: [],
};
const PROFILE_KINDS: ActiveProfileKind[] = ['desktop', 'mobile'];
@ -103,6 +113,10 @@ function profileLLMKeyId(kind: ActiveProfileKind): string {
return `profile-${kind}-llm`;
}
function profileRealtimeKeyId(kind: ActiveProfileKind): string {
return `profile-${kind}-realtime`;
}
export async function loadSettings(plugin: Plugin): Promise<GlobalSettings> {
const stored = (await plugin.loadData()) as Partial<GlobalSettings> | null;
const merged = mergeSettings(DEFAULT_SETTINGS, stored ?? {});
@ -124,6 +138,8 @@ export async function hydrateSecrets(plugin: Plugin, settings: GlobalSettings):
profile.transcriptionConfig.apiKey = trKey ?? '';
const llmKey = all[profileLLMKeyId(kind)];
profile.llmConfig.apiKey = llmKey ?? '';
const rtKey = all[profileRealtimeKeyId(kind)];
profile.realtimeConfig.apiKey = rtKey ?? '';
}
}
@ -133,6 +149,7 @@ async function persistSecrets(plugin: Plugin, settings: GlobalSettings): Promise
const profile = profileFor(settings, kind);
updates[profileTranscriptionKeyId(kind)] = profile.transcriptionConfig.apiKey;
updates[profileLLMKeyId(kind)] = profile.llmConfig.apiKey;
updates[profileRealtimeKeyId(kind)] = profile.realtimeConfig.apiKey;
}
await saveManyKeys(plugin, updates);
}
@ -150,6 +167,7 @@ function stripProfileKeys(profile: EnvironmentProfile): EnvironmentProfile {
...profile,
transcriptionConfig: { ...profile.transcriptionConfig, apiKey: '' },
llmConfig: { ...profile.llmConfig, apiKey: '' },
realtimeConfig: { ...profile.realtimeConfig, apiKey: '' },
};
}
@ -180,9 +198,32 @@ export function mergeSettings(
},
},
localWhisper: { ...base.localWhisper, ...(isPlainObject(partial.localWhisper) ? partial.localWhisper : {}) },
disabledDefaultTemplateIds: sanitizeDisabledIds(partial.disabledDefaultTemplateIds, base.disabledDefaultTemplateIds),
ingestRules: sanitizeIngestRules(partial.ingestRules, base.ingestRules),
};
}
// Arrays from a corrupt/hand-edited data.json need explicit shape checks (a spread
// can't validate element shape the way pickEnum does for scalars). Non-arrays fall
// back to base; malformed elements are dropped. Exported for tests.
export function sanitizeDisabledIds(value: unknown, fallback: string[]): string[] {
if (!Array.isArray(value)) return fallback;
return value.filter((v): v is string => typeof v === 'string' && v.trim().length > 0);
}
export function sanitizeIngestRules(value: unknown, fallback: IngestRule[]): IngestRule[] {
if (!Array.isArray(value)) return fallback;
const rules: IngestRule[] = [];
for (const raw of value) {
if (!isPlainObject(raw)) continue;
const folderPath = typeof raw.folderPath === 'string' ? raw.folderPath : '';
const templateId = typeof raw.templateId === 'string' ? raw.templateId : '';
if (!folderPath.trim() || !templateId.trim()) continue;
rules.push({ folderPath, templateId, enabled: raw.enabled === true });
}
return rules;
}
function mergeProfile(
base: EnvironmentProfile,
partial: Partial<EnvironmentProfile> | undefined,
@ -193,6 +234,7 @@ function mergeProfile(
...partial,
transcriptionProvider: pickEnum(TRANSCRIPTION_PROVIDER_IDS, partial.transcriptionProvider, base.transcriptionProvider),
llmProvider: pickEnum(LLM_PROVIDER_IDS, partial.llmProvider, base.llmProvider),
realtimeProvider: pickEnum(TRANSCRIPTION_PROVIDER_IDS, partial.realtimeProvider, base.realtimeProvider),
transcriptionConfig: {
...base.transcriptionConfig,
...(isPlainObject(partial.transcriptionConfig) ? partial.transcriptionConfig : {}),
@ -201,5 +243,9 @@ function mergeProfile(
...base.llmConfig,
...(isPlainObject(partial.llmConfig) ? partial.llmConfig : {}),
},
realtimeConfig: {
...base.realtimeConfig,
...(isPlainObject(partial.realtimeConfig) ? partial.realtimeConfig : {}),
},
};
}

View file

@ -12,10 +12,18 @@ import {
TranscriptionProviderID,
} from '../types';
import { detectActiveProfileKind } from '../platform';
import { createTranscriptionProvider, transcriptionProviderSupportsDiarization } from '../transcription';
import { createTranscriptionProvider } from '../transcription';
import { transcriptionProviderSupportsRealtime } from '../realtime';
import { createLLMProvider } from '../llm';
import { formatWhisperStatus } from '../whisper-host';
import { loadPriorTemplateVersions, populateDefaultTemplates, updateDefaultTemplates } from '../templates-folder';
import {
findTemplateFileById,
loadPriorTemplateVersions,
populateDefaultTemplates,
restoreDefaultTemplate,
updateDefaultTemplates,
} from '../templates-folder';
import { freshDefaultTemplates } from './default-templates';
import { populateDefaultSharedCore } from '../shared-core';
import { populateDefaultAssistantPrompt } from '../assistant-prompt';
import { populateDefaultKnownNouns } from '../known-nouns';
@ -34,6 +42,7 @@ import {
import { hydrateSecrets } from '.';
import { PassphraseModal } from '../ui/passphrase-modal';
import { ConfirmModal } from '../ui/confirm-modal';
import { IngestRuleModal } from '../ui/ingest-rule-modal';
// Sentinel value for the "Custom..." entry in a model dropdown; never written to config.model.
const CUSTOM_MODEL_OPTION = '__rewrite_custom__';
@ -150,6 +159,10 @@ export class ReWriteSettingTab extends PluginSettingTab {
// while its async re-encryption + re-render is still in flight.
private modeChangeInFlight = false;
// Expand state of the "Manage built-in templates" disclosure, surviving the
// full-container redraws its own toggles trigger (mirrors inactiveProfileExpanded).
private manageDefaultsExpanded = false;
constructor(app: App, private readonly plugin: ReWritePlugin) {
super(app, plugin);
}
@ -174,6 +187,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
this.renderTemplates(containerEl);
this.renderSharedCore(containerEl);
this.renderRecording(containerEl);
this.renderAutoIngest(containerEl);
this.renderAdHocInstructions(containerEl);
this.renderKnownNouns(containerEl);
}
@ -592,6 +606,11 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
});
// The three provider subsections (batch transcription, real-time, LLM) each carry a
// heading and share one field order: provider, base URL (where applicable), API key,
// then model. Keeping the arrangement identical lets the profile scan predictably.
new Setting(body).setName('Transcription').setHeading();
new Setting(body)
.setName('Transcription provider')
.addDropdown((dd) => {
@ -608,8 +627,6 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
if (profile.transcriptionProvider !== 'none') {
this.renderTranscriptionModelField(body, profile);
if (profile.transcriptionProvider === 'openai-compatible') {
new Setting(body)
.setName('Transcription base URL')
@ -639,20 +656,16 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
}
if (transcriptionProviderSupportsDiarization(profile.transcriptionProvider)) {
new Setting(body)
.setName('Identify speakers')
.setDesc('Tag each voice in the transcript with a speaker label and keep those labels through cleanup. Optional, and only this provider supports it. Quality varies: the speaker count is a guess and labels can drift mid-conversation.')
.addToggle((t) => {
t.setValue(profile.transcriptionConfig.diarize ?? false);
t.onChange(async (v) => {
profile.transcriptionConfig.diarize = v;
await this.commit();
});
});
}
this.renderTranscriptionModelField(body, profile);
}
// Real-time (streaming) transcription is configured entirely on its own: its own
// provider, key, and model, independent of the batch transcription provider above.
// So a profile can use one service for batch and a different one for live dictation.
this.renderRealtimeSection(body, profile);
new Setting(body).setName('Post-processing (LLM)').setHeading();
new Setting(body)
.setName('LLM provider')
.addDropdown((dd) => {
@ -666,8 +679,6 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
if (profile.llmProvider !== 'none') {
this.renderLLMModelField(body, profile);
if (profile.llmProvider === 'openai-compatible') {
new Setting(body)
.setName('LLM base URL')
@ -695,6 +706,8 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
});
this.renderLLMModelField(body, profile);
this.renderNoteLength(body, profile);
}
@ -736,6 +749,55 @@ export class ReWriteSettingTab extends PluginSettingTab {
setting.settingEl.addClass('rewrite-note-length');
}
// Real-time (streaming) transcription, configured independently of batch transcription:
// its own provider dropdown (None + realtime-capable providers only), key, and model.
private renderRealtimeSection(parent: HTMLElement, profile: EnvironmentProfile): void {
new Setting(parent).setName('Real-time transcription').setHeading();
new Setting(parent)
.setName('Real-time provider')
.setDesc('Provider for live dictation, independent of the batch transcription provider above. Only providers with a streaming endpoint are listed.')
.addDropdown((dd) => {
for (const opt of TRANSCRIPTION_OPTIONS) {
if (opt.desktopOnly && !Platform.isDesktop) continue;
// 'none' (off) plus only the realtime-capable providers.
if (opt.id !== 'none' && !transcriptionProviderSupportsRealtime(opt.id)) continue;
dd.addOption(opt.id, opt.label);
}
dd.setValue(transcriptionProviderSupportsRealtime(profile.realtimeProvider) ? profile.realtimeProvider : 'none');
dd.onChange(async (v) => {
profile.realtimeProvider = v as TranscriptionProviderID;
await this.commit();
this.display();
});
});
if (!transcriptionProviderSupportsRealtime(profile.realtimeProvider)) return;
new Setting(parent)
.setName('Real-time API key')
.setDesc('Key for the real-time provider. Stored encrypted.')
.addText((t) => {
t.inputEl.type = 'password';
this.applyApiKeyFieldState(t.inputEl);
t.setPlaceholder(this.apiKeyPlaceholder());
t.setValue(profile.realtimeConfig.apiKey);
t.onChange(async (v) => {
if (this.plugin.encryptionStatus.locked) return;
profile.realtimeConfig.apiKey = v;
await this.commit();
});
});
// Adaptive model control (dropdown + Refresh where the provider lists models, else a
// text field), sharing populateModelField with the batch fields. The streaming model
// is often different from the batch model, so it has its own value in realtimeConfig.
parent.createEl('p', {
text: 'The streaming model, often different from the batch model. Leave blank for the provider default.',
cls: 'rewrite-section-desc',
});
const modelWrapper = parent.createDiv({ cls: 'rewrite-model-field' });
this.populateModelField(modelWrapper, profile, 'realtime');
}
private renderProfileAdvanced(parent: HTMLElement, profile: EnvironmentProfile): void {
if (profile.transcriptionProvider === 'none' && profile.llmProvider === 'none') return;
@ -794,22 +856,31 @@ export class ReWriteSettingTab extends PluginSettingTab {
private populateModelField(
wrapper: HTMLElement,
profile: EnvironmentProfile,
side: 'transcription' | 'llm',
side: 'transcription' | 'llm' | 'realtime',
forceText = false,
): void {
wrapper.empty();
const isTranscription = side === 'transcription';
const provider = isTranscription
? createTranscriptionProvider(profile.transcriptionProvider)
: createLLMProvider(profile.llmProvider);
const config = isTranscription ? profile.transcriptionConfig : profile.llmConfig;
const cached = (isTranscription
? this.plugin.settings.modelCache.transcription[profile.transcriptionProvider]
: this.plugin.settings.modelCache.llm[profile.llmProvider])?.ids ?? [];
const hint = isTranscription
? transcriptionModelHint(profile.transcriptionProvider)
: llmModelHint(profile.llmProvider);
// 'transcription' and 'realtime' are both backed by a transcription provider; they
// differ only in which provider id / config slot / model cache they read (realtime
// shares the transcription cache since it is the same provider's catalogue). 'llm'
// uses the LLM provider + cache.
const isLLM = side === 'llm';
const transcriptionId = side === 'realtime' ? profile.realtimeProvider : profile.transcriptionProvider;
const provider = isLLM
? createLLMProvider(profile.llmProvider)
: createTranscriptionProvider(transcriptionId);
const config: TranscriptionConfig | LLMConfig = side === 'transcription'
? profile.transcriptionConfig
: side === 'realtime'
? profile.realtimeConfig
: profile.llmConfig;
const cached = (isLLM
? this.plugin.settings.modelCache.llm[profile.llmProvider]
: this.plugin.settings.modelCache.transcription[transcriptionId])?.ids ?? [];
const hint = isLLM
? llmModelHint(profile.llmProvider)
: transcriptionModelHint(transcriptionId);
const current = config.model;
const supportsList = typeof provider.listModels === 'function';
const showDropdown = supportsList && cached.length > 0 && !forceText;
@ -822,19 +893,21 @@ export class ReWriteSettingTab extends PluginSettingTab {
? 'empty-cache'
: 'dropdown';
const docsUrl = isTranscription
? transcriptionModelDocsUrl(profile.transcriptionProvider)
: null;
const docsUrl = isLLM ? null : transcriptionModelDocsUrl(transcriptionId);
const setting = new Setting(wrapper)
.setName(isTranscription ? 'Transcription model' : 'LLM model');
const label = side === 'transcription'
? 'Transcription model'
: side === 'realtime'
? 'Real-time model'
: 'LLM model';
const setting = new Setting(wrapper).setName(label);
applyModelFieldDesc(setting, hint, mode, docsUrl);
const refresh = async (): Promise<void> => {
if (isTranscription) {
await this.refreshTranscriptionModels(profile.transcriptionProvider, profile.transcriptionConfig);
} else {
if (isLLM) {
await this.refreshLLMModels(profile.llmProvider, profile.llmConfig);
} else {
await this.refreshTranscriptionModels(transcriptionId, config as TranscriptionConfig);
}
this.populateModelField(wrapper, profile, side, false);
};
@ -989,6 +1062,31 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
});
new Setting(parent)
.setName('Start automatically')
.setDesc('Start the server when Obsidian opens, if this device\'s profile uses local whisper.cpp. An already-running server from a previous session is adopted instead of doubled up.')
.addToggle((t) => {
t.setValue(cfg.autoStart);
t.onChange(async (v) => {
cfg.autoStart = v;
await this.commit();
});
});
new Setting(parent)
.setName('Stop when idle')
.setDesc('Minutes without a transcription before the server is stopped, freeing the model\'s memory. 0 keeps it running. Only servers started or adopted by ReWrite are stopped, and never mid-transcription.')
.addText((t) => {
t.inputEl.type = 'number';
t.setValue(String(cfg.idleStopMinutes));
t.setPlaceholder('0');
t.onChange(async (v) => {
const n = Number.parseInt(v, 10);
cfg.idleStopMinutes = Number.isFinite(n) && n > 0 ? n : 0;
await this.commit();
});
});
const host = this.plugin.whisperHost;
const snap = host.snapshot();
@ -1067,7 +1165,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
.addButton((b) => {
b.setButtonText('Populate').setCta().onClick(() => void this.runGuardedButton(b, async () => {
try {
const result = await populateDefaultTemplates(this.app, s.templatesFolderPath);
const result = await populateDefaultTemplates(this.app, s.templatesFolderPath, new Set(s.disabledDefaultTemplateIds));
await this.plugin.refreshTemplates();
// The shared core is load-bearing for the default templates' quality
// (it carries the guardrail + output discipline), so seed it alongside.
@ -1085,7 +1183,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
.addButton((b) => {
b.setButtonText('Update').onClick(() => void this.runGuardedButton(b, async () => {
try {
const result = await updateDefaultTemplates(this.app, s.templatesFolderPath);
const result = await updateDefaultTemplates(this.app, s.templatesFolderPath, new Set(s.disabledDefaultTemplateIds));
await this.plugin.refreshTemplates();
const reviewNote = result.conflicts > 0
? ` ${result.conflicts} need review.`
@ -1093,8 +1191,11 @@ export class ReWriteSettingTab extends PluginSettingTab {
const failNote = result.parseFailed > 0
? ` ${result.parseFailed} unparseable, skipped.`
: '';
const untrackedNote = result.untracked > 0
? ` ${result.untracked} untracked, left alone.`
: '';
const reportNote = result.reportPath ? ` See ${result.reportPath}.` : '';
new Notice(`ReWrite: updated templates in ${result.folder}. ${result.updated} updated, ${result.created} created, ${result.unchanged} unchanged.${reviewNote}${failNote}${reportNote}`);
new Notice(`ReWrite: updated templates in ${result.folder}. ${result.updated} updated, ${result.created} created, ${result.unchanged} unchanged.${reviewNote}${failNote}${untrackedNote}${reportNote}`);
this.display();
} catch (e) {
console.error('ReWrite: update templates failed', e);
@ -1120,6 +1221,8 @@ export class ReWriteSettingTab extends PluginSettingTab {
}));
});
this.renderManageDefaults(parent);
const loaded = this.plugin.templates;
const listDesc = loaded.length === 0
? 'No templates loaded. Set a folder path and click Populate, or add your own Markdown files there.'
@ -1183,6 +1286,142 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
}
// Per-default checklist. Each row has two clearly-labelled, visually-distinct
// controls: an "Enabled" switch (off = delete the file and add the id to
// disabledDefaultTemplateIds so Populate/Update never re-add it) and a "Tracked"
// checkbox (off = write `managed: false` so Update leaves the file alone). The
// switch-vs-checkbox split, plus inline labels, makes which control does what
// legible without hovering. Driven off freshDefaultTemplates() so the list stays
// in sync as defaults come and go; on-disk state comes from the loaded cache.
private renderManageDefaults(parent: HTMLElement): void {
const s = this.plugin.settings;
const details = parent.createEl('details', { cls: 'rewrite-manage-defaults' });
details.open = this.manageDefaultsExpanded;
details.addEventListener('toggle', () => {
this.manageDefaultsExpanded = details.open;
});
details.createEl('summary', { text: 'Manage built-in templates' });
const intro = details.createEl('p', { cls: 'rewrite-section-desc' });
intro.createSpan({ cls: 'rewrite-manage-legend-term', text: 'Enabled' });
intro.appendText(' keeps the template in your folder; turning it off deletes the file and stops the populate and update buttons from bringing it back. ');
intro.createSpan({ cls: 'rewrite-manage-legend-term', text: 'Tracked' });
intro.appendText(' lets the update button keep the template current; unchecking it freezes your copy so updates never change it again.');
const disabled = new Set(s.disabledDefaultTemplateIds);
for (const def of freshDefaultTemplates()) {
const isDisabled = disabled.has(def.id);
const onDisk = this.plugin.templates.find((t) => t.id === def.id);
const tracked = onDisk ? onDisk.managed !== false : true;
const state = isDisabled
? 'Off. The file was removed; Populate and Update skip it.'
: !onDisk
? 'Not in your folder yet. Populate or Update will add it.'
: tracked
? 'In your folder. Update keeps it current.'
: 'In your folder, frozen. Update leaves it alone.';
const row = new Setting(details).setName(def.name).setDesc(state);
row.settingEl.addClass('rewrite-manage-row');
// Tracked control first, so the row reads [Tracked ☑] [Enabled ⊙] left to
// right. The "Tracked" caption precedes the checkbox inside the label. Only
// meaningful for an enabled, on-disk template.
if (!isDisabled && onDisk) {
const trackField = row.controlEl.createEl('label', { cls: 'rewrite-manage-check' });
// Caption reflects the current state (the tab re-renders on toggle), matching
// the Enabled/Disabled switch caption beside it.
trackField.createSpan({ text: tracked ? 'Tracked' : 'Untracked' });
const cb = trackField.createEl('input', { type: 'checkbox' });
cb.checked = tracked;
cb.setAttribute('aria-label', 'Tracked: keep this template current on update');
cb.addEventListener('change', () => {
void this.setDefaultTemplateTracked(def.id, cb.checked);
});
}
// Enabled/Disabled caption + the switch, added last so the switch sits at the far
// right. The caption reflects the current state (the whole tab re-renders on toggle).
row.controlEl.createSpan({
cls: 'rewrite-manage-switch-label',
text: isDisabled ? 'Disabled' : 'Enabled',
});
row.addToggle((t) => {
t.setValue(!isDisabled);
t.setTooltip(isDisabled ? 'Turn on to re-create the file' : 'Turn off to remove the file');
t.onChange((v) => {
if (!v) {
new ConfirmModal({
app: this.app,
title: 'Disable built-in template',
body: `This removes "${def.name}" from your templates folder and stops Populate and Update from re-adding it, including after plugin updates. Any edits you made to the file are lost. You can turn it back on later to get a fresh copy.`,
confirmLabel: 'Disable and remove',
confirmCls: 'mod-warning',
onConfirm: async () => {
await this.disableDefaultTemplate(def.id);
},
onCancel: () => this.display(),
}).open();
return;
}
void this.enableDefaultTemplate(def.id);
});
});
}
}
private async disableDefaultTemplate(id: string): Promise<void> {
const s = this.plugin.settings;
if (!s.disabledDefaultTemplateIds.includes(id)) {
s.disabledDefaultTemplateIds.push(id);
}
await this.commit();
try {
const file = await findTemplateFileById(this.app, s.templatesFolderPath, id);
if (file) await this.app.fileManager.trashFile(file);
} catch (e) {
console.error('ReWrite: could not remove disabled template file', e);
new Notice(`ReWrite: template disabled, but its file could not be removed. ${e instanceof Error ? e.message : String(e)}`);
}
await this.plugin.refreshTemplates();
this.display();
}
private async enableDefaultTemplate(id: string): Promise<void> {
const s = this.plugin.settings;
s.disabledDefaultTemplateIds = s.disabledDefaultTemplateIds.filter((v) => v !== id);
await this.commit();
try {
await restoreDefaultTemplate(this.app, s.templatesFolderPath, id);
} catch (e) {
console.error('ReWrite: could not re-create enabled template', e);
new Notice(`ReWrite: template enabled, but its file could not be re-created. ${e instanceof Error ? e.message : String(e)}`);
}
await this.plugin.refreshTemplates();
this.display();
}
private async setDefaultTemplateTracked(id: string, tracked: boolean): Promise<void> {
const s = this.plugin.settings;
try {
const file = await findTemplateFileById(this.app, s.templatesFolderPath, id);
if (!file) {
new Notice('ReWrite: template file not found.');
this.display();
return;
}
// Edit just the frontmatter key in place (preserves the body and any
// user formatting) rather than re-rendering the whole file.
await this.app.fileManager.processFrontMatter(file, (fm) => {
Object.assign(fm, { managed: tracked });
});
} catch (e) {
console.error('ReWrite: could not update managed flag', e);
new Notice(`ReWrite: could not update the template. ${e instanceof Error ? e.message : String(e)}`);
}
await this.plugin.refreshTemplates();
this.display();
}
private renderRecording(parent: HTMLElement): void {
this.sectionHeading(parent, 'Recording', 'mic');
new Setting(parent)
@ -1210,6 +1449,74 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
}
private renderAutoIngest(parent: HTMLElement): void {
this.sectionHeading(parent, 'Auto-ingest folders', 'folder-input');
parent.createEl('p', {
text: 'Drop audio files from outside Obsidian into a vault folder, then run the process auto-ingest folders command from the command palette. Each file becomes a note using the folder\'s template, and the recording is moved in with your other saved recordings. Files that fail stay in the folder and are retried the next time you run the command.',
cls: 'rewrite-section-desc',
});
const s = this.plugin.settings;
s.ingestRules.forEach((rule, index) => {
const template = this.plugin.templates.find((t) => t.id === rule.templateId);
const problem = !template
? ' Warning: template is missing; this rule will be skipped.'
: template.insertMode !== 'newFile'
? ' Warning: template does not create a new file; this rule will be skipped.'
: '';
new Setting(parent)
.setName(rule.folderPath)
.setDesc(`Template: ${template?.name ?? rule.templateId}.${problem}`)
.addToggle((t) => {
t.setValue(rule.enabled);
t.setTooltip('Enabled');
t.onChange(async (v) => {
rule.enabled = v;
await this.commit();
});
})
.addButton((b) => {
b.setButtonText('Edit').onClick(() => {
new IngestRuleModal({
app: this.app,
templates: this.plugin.templates,
rule,
onSubmit: async (updated) => {
s.ingestRules[index] = updated;
await this.commit();
this.display();
},
}).open();
});
})
.addExtraButton((b) => {
b.setIcon('trash-2').setTooltip('Delete rule').onClick(() => {
void (async () => {
s.ingestRules.splice(index, 1);
await this.commit();
this.display();
})();
});
});
});
new Setting(parent)
.setName(s.ingestRules.length === 0 ? 'No ingest folders yet' : 'Add another folder')
.addButton((b) => {
b.setButtonText('Add ingest folder').setCta().onClick(() => {
new IngestRuleModal({
app: this.app,
templates: this.plugin.templates,
onSubmit: async (rule) => {
s.ingestRules.push(rule);
await this.commit();
this.display();
},
}).open();
});
});
}
private renderAdHocInstructions(parent: HTMLElement): void {
this.sectionHeading(parent, 'Ad-hoc instructions', 'message-square');
parent.createEl('p', {

View file

@ -46,7 +46,16 @@ export interface PopulateResult {
folder: string;
}
export async function populateDefaultTemplates(app: App, folderPath: string): Promise<PopulateResult> {
// `disabledIds` (GlobalSettings.disabledDefaultTemplateIds) suppresses defaults
// the user disabled: they are neither created here nor counted as skipped-by-
// collision. Files Populate creates are stamped `managed: true` so Update can
// tell plugin-managed files from a user's own template that happens to share a
// name (or id) with a built-in.
export async function populateDefaultTemplates(
app: App,
folderPath: string,
disabledIds: ReadonlySet<string> = new Set(),
): Promise<PopulateResult> {
const normalized = normalizeFolderPath(folderPath);
if (!normalized) throw new Error('Templates folder path is empty.');
@ -56,6 +65,7 @@ export async function populateDefaultTemplates(app: App, folderPath: string): Pr
let created = 0;
let skipped = 0;
for (const template of freshDefaultTemplates()) {
if (disabledIds.has(template.id)) continue;
if (existingIds.has(template.id)) {
skipped++;
continue;
@ -66,12 +76,45 @@ export async function populateDefaultTemplates(app: App, folderPath: string): Pr
skipped++;
continue;
}
await app.vault.create(path, renderTemplateFile(template));
await app.vault.create(path, renderTemplateFile({ ...template, managed: true }));
created++;
}
return { created, skipped, folder: normalized };
}
// Locate the on-disk template file whose frontmatter id matches. Used by the
// per-default manage UI (disable = delete the file; untrack = flip its managed
// flag) so identity stays id-based and rename-proof.
export async function findTemplateFileById(app: App, folderPath: string, id: string): Promise<TFile | null> {
const normalized = normalizeFolderPath(folderPath);
if (!normalized) return null;
const folder = app.vault.getAbstractFileByPath(normalized);
if (!(folder instanceof TFolder)) return null;
for (const child of folder.children) {
if (!(child instanceof TFile) || child.extension !== 'md') continue;
if ((await readTemplateId(app, child)) === id) return child;
}
return null;
}
// Recreate a single built-in default (used when re-enabling a disabled one).
// Returns the created path, or null when the default is unknown, its id is
// already on disk, or the target filename collides.
export async function restoreDefaultTemplate(app: App, folderPath: string, id: string): Promise<string | null> {
const normalized = normalizeFolderPath(folderPath);
if (!normalized) throw new Error('Templates folder path is empty.');
const def = freshDefaultTemplates().find((t) => t.id === id);
if (!def) return null;
const folder = await ensureFolder(app, normalized);
const existingIds = await collectExistingIds(app, folder);
if (existingIds.has(id)) return null;
const filename = `${sanitizeFilename(def.name)}.md`;
const path = normalizePath(`${normalized}/${filename}`);
if (app.vault.getAbstractFileByPath(path)) return null;
await app.vault.create(path, renderTemplateFile({ ...def, managed: true }));
return path;
}
// One thing the Update button could not safely auto-merge, surfaced in the report.
export interface TemplateUpdateConflict {
// 'body' — on-disk prompt differs from the current default prompt.
@ -104,6 +147,9 @@ export interface UpdateResult {
unchanged: number;
conflicts: number;
parseFailed: number;
// Default-derived files with `managed: false`: the user froze them, so the
// walk leaves them untouched (not even reported as conflicts).
untracked: number;
entries: TemplateUpdateEntry[];
reportPath: string | null;
}
@ -207,6 +253,10 @@ export function mergeTemplate(onDisk: NoteTemplate, def: NoteTemplate, priors: N
diarize,
titleFromContent,
noteProperties: mergedProps,
// mergeTemplate only runs on tracked default-derived files (Update skips
// `managed: false` before merging), so the reconciled file is stamped as
// plugin-managed — this also back-fills the flag onto pre-flag files.
managed: true,
};
return { merged, conflicts, changes };
}
@ -215,7 +265,11 @@ export function mergeTemplate(onDisk: NoteTemplate, def: NoteTemplate, priors: N
// with the current built-in defaults: fill in new fields and missing properties,
// recreate any defaults deleted entirely, and never overwrite user edits. Anything
// that cannot be safely auto-merged is written to a human-facing report file.
export async function updateDefaultTemplates(app: App, folderPath: string): Promise<UpdateResult> {
export async function updateDefaultTemplates(
app: App,
folderPath: string,
disabledIds: ReadonlySet<string> = new Set(),
): Promise<UpdateResult> {
const normalized = normalizeFolderPath(folderPath);
if (!normalized) throw new Error('Templates folder path is empty.');
@ -229,6 +283,7 @@ export async function updateDefaultTemplates(app: App, folderPath: string): Prom
let unchanged = 0;
let conflicts = 0;
let parseFailed = 0;
let untracked = 0;
for (const child of folder.children) {
if (!(child instanceof TFile)) continue;
@ -240,6 +295,9 @@ export async function updateDefaultTemplates(app: App, folderPath: string): Prom
if (!id) continue;
const def = defaultsById.get(id);
if (!def) continue; // user's own template (id not in the default set)
// A disabled default that somehow still has a file on disk is the user's
// business now; don't reconcile or count it.
if (disabledIds.has(id)) continue;
seenIds.add(id);
let onDisk: NoteTemplate | null = null;
@ -257,6 +315,14 @@ export async function updateDefaultTemplates(app: App, folderPath: string): Prom
continue;
}
// Untracked (`managed: false`): the user adopted this default as their own;
// Update must never touch it again. An absent flag on a default-derived
// file (created before the flag existed) still counts as tracked.
if (onDisk.managed === false) {
untracked++;
continue;
}
const { merged, conflicts: cf, changes } = mergeTemplate(onDisk, def, priorVersionsForId(id));
const rendered = renderTemplateFile(merged);
// Normalize CRLF so we don't rewrite a file purely over line endings.
@ -278,19 +344,21 @@ export async function updateDefaultTemplates(app: App, folderPath: string): Prom
}
// Restore defaults missing from disk entirely (superset top-up), reusing the
// same name + path-collision skip as Populate.
// same name + path-collision skip as Populate. Disabled ids are never
// resurrected; that is the whole point of the disable switch.
for (const def of defaultsById.values()) {
if (seenIds.has(def.id)) continue;
if (disabledIds.has(def.id)) continue;
const filename = `${sanitizeFilename(def.name)}.md`;
const path = normalizePath(`${normalized}/${filename}`);
if (app.vault.getAbstractFileByPath(path)) continue;
await app.vault.create(path, renderTemplateFile(def));
await app.vault.create(path, renderTemplateFile({ ...def, managed: true }));
created++;
entries.push({ id: def.id, name: def.name, path, status: 'created', changes: [], conflicts: [] });
}
const result: UpdateResult = {
folder: normalized, created, updated, unchanged, conflicts, parseFailed, entries, reportPath: null,
folder: normalized, created, updated, unchanged, conflicts, parseFailed, untracked, entries, reportPath: null,
};
if (entries.some((e) => e.status !== 'unchanged')) {
result.reportPath = await writeTemplateUpdateReport(app, normalized, result);
@ -402,6 +470,13 @@ export function parseTemplateContent(file: TFile, content: string): NoteTemplate
const titleFromContent = rawTitleFromContent === true
|| (typeof rawTitleFromContent === 'string' && rawTitleFromContent.trim().toLowerCase() === 'true');
// `managed` is TRI-state, unlike the other flags: explicit true (plugin-managed),
// explicit false (untracked; Update must never touch the file), or undefined
// (key absent/empty, e.g. pre-flag files — treated as managed when the id
// matches a built-in, preserving old behavior). Same boolean-or-string
// tolerance for edits via Obsidian's Properties UI.
const managed = parseTriStateFlag(obj.managed);
// Authored as a YAML map (key = property name, value = instruction). Parsed
// into an ordered array (object key order is preserved). Non-map values and
// blank keys are skipped; a missing/non-string instruction becomes "".
@ -430,9 +505,21 @@ export function parseTemplateContent(file: TFile, content: string): NoteTemplate
diarize,
titleFromContent,
noteProperties,
managed,
};
}
function parseTriStateFlag(raw: unknown): boolean | undefined {
if (raw === true) return true;
if (raw === false) return false;
if (typeof raw === 'string') {
const v = raw.trim().toLowerCase();
if (v === 'true') return true;
if (v === 'false') return false;
}
return undefined;
}
async function collectExistingIds(app: App, folder: TFolder): Promise<Set<string>> {
const ids = new Set<string>();
for (const child of folder.children) {
@ -519,6 +606,13 @@ export function renderTemplateFile(template: NoteTemplate): string {
const titleLine = template.titleFromContent
? 'titleFromContent: true'
: 'titleFromContent:';
// managed is tri-state: explicit true/false round-trip, undefined renders as
// the discoverable empty stub (absent semantics).
const managedLine = template.managed === true
? 'managed: true'
: template.managed === false
? 'managed: false'
: 'managed:';
// noteProperties is a nested map, so unlike the booleans it is emitted only
// when the template actually declares properties (no always-empty stub).
const propsBlock = template.noteProperties && template.noteProperties.length > 0
@ -528,7 +622,7 @@ export function renderTemplateFile(template: NoteTemplate): string {
),
}).replace(/\n+$/, '')
: '';
return `---\n${fm}\n${disableLine}\n${contextLine}\n${diarizeLine}\n${titleLine}${propsBlock}\n---\n${template.prompt}\n`;
return `---\n${fm}\n${disableLine}\n${contextLine}\n${diarizeLine}\n${titleLine}\n${managedLine}${propsBlock}\n---\n${template.prompt}\n`;
}
// Guards against names that are unsafe/unusable as a file basename: purely dots (would

View file

@ -26,35 +26,52 @@ export function createWhisperLocalTranscription(): TranscriptionProvider {
if (!baseUrl) {
throw new ProviderError('whisper-local', 0, '', 'Local whisper.cpp server is not reachable. Start it from settings, or check whether the configured port is bound.');
}
let wavBuffer: ArrayBuffer;
// Bracket the whole request (including the transcode) so the idle-stop
// timer neither counts an in-flight transcription as idle time nor stops
// the server mid-job.
host.beginUse();
try {
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}`);
return await transcribeAgainstHost(baseUrl, audio, config, signal);
} finally {
host.endUse();
}
const parts: MultipartPart[] = [
{
type: 'file',
name: 'file',
filename: 'audio.wav',
contentType: 'audio/wav',
data: wavBuffer,
},
{ type: 'text', name: 'response_format', value: 'text' },
];
if (config.language) {
parts.push({ type: 'text', name: 'language', value: config.language });
}
const res = await multipartPost(
'whisper-local',
`${baseUrl}/inference`,
parts,
{},
signal,
);
return res.text.trim();
},
};
}
async function transcribeAgainstHost(
baseUrl: string,
audio: Blob,
config: TranscriptionConfig,
signal?: AbortSignal,
): Promise<string> {
let wavBuffer: ArrayBuffer;
try {
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}`);
}
const parts: MultipartPart[] = [
{
type: 'file',
name: 'file',
filename: 'audio.wav',
contentType: 'audio/wav',
data: wavBuffer,
},
{ type: 'text', name: 'response_format', value: 'text' },
];
if (config.language) {
parts.push({ type: 'text', name: 'language', value: config.language });
}
const res = await multipartPost(
'whisper-local',
`${baseUrl}/inference`,
parts,
{},
signal,
);
return res.text.trim();
}

View file

@ -22,9 +22,11 @@ export interface TranscriptionConfig {
baseUrl: string;
model: string;
language: string;
// Opt-in speaker diarization. Only honored by providers that support it
// (assemblyai, deepgram, revai); ignored by the rest. When on, the capable
// adapter embeds `Speaker X:` labels into the returned transcript string.
// Internal pipeline->adapter transport for speaker diarization. Set per
// invocation by the pipeline (from the template's `diarize` flag OR the modal's
// per-run toggle), NOT a persisted user setting. Only honored by capable
// providers (assemblyai, deepgram, revai); when on, the adapter embeds
// `Speaker X:` labels into the returned transcript string.
diarize?: boolean;
}
@ -52,10 +54,12 @@ export interface NoteTemplate {
// absent/false means the field is hidden. NOTE the polarity is the reverse
// of disableSharedCore (positive opt-in, not negative opt-out).
enableContextHint?: boolean;
// When true, forces speaker diarization on for this template's transcription,
// regardless of the profile's "Identify speakers" toggle. Only effective on
// When true, this template defaults speaker diarization ON (e.g. Meeting
// transcript). It seeds the modal's per-invocation "Identify speakers" toggle,
// which the user can still override for a single run. Only effective on
// diarization-capable providers (assemblyai/deepgram/revai); a no-op on the
// rest. Absent/false means the profile setting governs.
// rest. Absent/false means the toggle defaults off. There is no profile-wide
// diarization setting anymore.
diarize?: boolean;
// When true, the LLM generates this note's title (filename) from the content and
// any provided context, returned via a reserved `noteTitle` key in the same leading
@ -67,6 +71,14 @@ export interface NoteTemplate {
// instruction). Parsed into an ordered array (order drives both the prompt and
// the write order). Applied only for insertMode 'newFile'.
noteProperties?: NotePropertySpec[];
// Tri-state ownership marker for built-in-derived files. `true` (written by
// Populate/Update on the files they create or reconcile) means the file is
// plugin-managed and Update may reconcile it against the current built-in.
// `false` means the user untracked it: Update must never touch it again.
// `undefined` (key absent/empty, e.g. files created before this flag existed)
// is treated as managed when the id matches a built-in, preserving the old
// behavior. Ignored entirely for ids not in the default set.
managed?: boolean;
}
export interface NotePropertySpec {
@ -97,6 +109,16 @@ export interface EnvironmentProfile {
transcriptionConfig: TranscriptionConfig;
llmProvider: LLMProviderID;
llmConfig: LLMConfig;
// Real-time (streaming) transcription is configured entirely independently of batch
// transcription: its own provider, key, and model. A user can run e.g. Voxtral for batch,
// AssemblyAI for realtime, and Anthropic for cleanup. `realtimeProvider` is 'none' (off)
// or a realtime-capable provider (assemblyai/deepgram, per
// transcriptionProviderSupportsRealtime). `realtimeConfig` holds its key + model (reuses
// the TranscriptionConfig shape; `language`/`diarize` unused). Realtime models often
// differ from batch (e.g. a distinct streaming model id), which is why this is separate
// rather than reusing transcriptionProvider/transcriptionConfig.
realtimeProvider: TranscriptionProviderID;
realtimeConfig: TranscriptionConfig;
}
export type ActiveProfileOverride = 'auto' | 'desktop' | 'mobile';
@ -119,6 +141,22 @@ export interface LocalWhisperSettings {
modelPath: string;
port: number;
extraArgs: string;
// Phase B lifecycle knobs. autoStart spawns the server once the workspace is
// ready (desktop + whisper-local profile only). idleStopMinutes > 0 stops a
// ReWrite-owned (spawned/adopted, never external) server after that many
// minutes without a transcription; 0 disables idle stop.
autoStart: boolean;
idleStopMinutes: number;
}
// One auto-ingest rule: a vault folder scanned on demand by the
// process-ingest-folders command, each audio file run through the pipeline with
// the preassigned template (newFile-mode only), then moved to the attachments
// location on success (move-on-success is the dedupe; failures stay put).
export interface IngestRule {
folderPath: string;
templateId: string;
enabled: boolean;
}
export interface GlobalSettings {
@ -139,4 +177,13 @@ export interface GlobalSettings {
knownNounsPath: string;
modelCache: ModelCache;
localWhisper: LocalWhisperSettings;
// When true (desktop only), the main modal's Record button hands capture off to
// the Quick Record floating UI (carrying the modal's template, destination
// override, and context hint) so Obsidian stays usable while recording.
recordInBackground: boolean;
// Built-in default templates the user disabled: Populate and Update never
// (re)create these ids. Keyed by frontmatter id (canonical; survives renames).
disabledDefaultTemplateIds: string[];
// Auto-ingest folder rules for the process-ingest-folders command.
ingestRules: IngestRule[];
}

View file

@ -10,6 +10,10 @@ export interface ConfirmModalParams {
confirmCls?: string;
// Called when the user confirms. Throw to surface an error and keep the modal open.
onConfirm: () => Promise<void>;
// Called when the modal closes without a successful confirm (Cancel button,
// Escape, click-outside). Lets a caller reset UI state that was optimistically
// flipped before opening the confirmation (e.g. a toggle).
onCancel?: () => void;
}
// A small generic confirmation modal. window.confirm is banned by ESLint (no-alert), and the
@ -17,6 +21,7 @@ export interface ConfirmModalParams {
// consequential actions that just need an OK/Cancel use this. Reuses the rewrite-modal styling.
export class ConfirmModal extends Modal {
private busy = false;
private confirmed = false;
constructor(private readonly params: ConfirmModalParams) {
super(params.app);
@ -44,6 +49,9 @@ export class ConfirmModal extends Modal {
onClose(): void {
this.contentEl.empty();
// Escape / click-outside / the Cancel button all land here; only a
// successful confirm suppresses the cancel hook.
if (!this.confirmed) this.params.onCancel?.();
}
private async run(): Promise<void> {
@ -51,6 +59,7 @@ export class ConfirmModal extends Modal {
this.busy = true;
try {
await this.params.onConfirm();
this.confirmed = true;
this.close();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);

115
src/ui/ingest-rule-modal.ts Normal file
View file

@ -0,0 +1,115 @@
import { App, Modal, Notice, Setting, TFolder, normalizePath } from 'obsidian';
import { IngestRule, NoteTemplate } from '../types';
import { isIngestTemplate } from '../ingest';
export interface IngestRuleModalParams {
app: App;
// Loaded templates; the dropdown filters to newFile-mode ones.
templates: NoteTemplate[];
// Existing rule when editing, undefined when adding.
rule?: IngestRule;
onSubmit: (rule: IngestRule) => Promise<void>;
}
// Popup editor for one auto-ingest rule (folder + template + enabled), per the
// settings-UI-is-a-modal decision in the roadmap. The template dropdown only
// offers newFile templates: unattended ingest has no active editor, so cursor /
// append would cascade into newFile anyway — requiring newFile makes the
// destination explicit.
export class IngestRuleModal extends Modal {
private folderPath: string;
private templateId: string;
private enabled: boolean;
constructor(private readonly params: IngestRuleModalParams) {
super(params.app);
this.folderPath = params.rule?.folderPath ?? '';
this.templateId = params.rule?.templateId ?? '';
this.enabled = params.rule?.enabled ?? true;
}
onOpen(): void {
this.modalEl.addClass('rewrite-modal');
const { contentEl } = this;
contentEl.createEl('h2', { text: this.params.rule ? 'Edit ingest folder' : 'Add ingest folder' });
contentEl.createEl('p', {
text: 'When you run the process auto-ingest folders command, every audio file in this folder is turned into a note with the chosen template, then moved in with your other recordings. Files that fail stay put and are retried next run.',
cls: 'rewrite-section-desc',
});
new Setting(contentEl)
.setName('Folder')
.setDesc('Vault-relative path. Only files directly in this folder are processed, not subfolders.')
.addText((t) => {
t.setValue(this.folderPath);
t.setPlaceholder('Voice inbox');
t.onChange((v) => {
this.folderPath = v;
});
});
const eligible = this.params.templates.filter((t) => isIngestTemplate(t));
if (eligible.length === 0) {
contentEl.createEl('p', {
text: 'No templates create a new file. Ingest needs a template whose insert mode is "newFile"; add or edit one first.',
cls: 'rewrite-warning-text',
});
} else {
new Setting(contentEl)
.setName('Template')
.setDesc('Only templates that create a new file are offered; each recording becomes its own note.')
.addDropdown((dd) => {
dd.addOption('', '(pick a template)');
for (const tpl of eligible) dd.addOption(tpl.id, tpl.name);
dd.setValue(eligible.some((t) => t.id === this.templateId) ? this.templateId : '');
dd.onChange((v) => {
this.templateId = v;
});
});
}
new Setting(contentEl)
.setName('Enabled')
.setDesc('Disabled rules are kept but skipped by the command.')
.addToggle((t) => {
t.setValue(this.enabled);
t.onChange((v) => {
this.enabled = v;
});
});
const actions = contentEl.createDiv({ cls: 'rewrite-setup-actions' });
const save = actions.createEl('button', { text: 'Save', cls: 'mod-cta' });
save.addEventListener('click', () => {
void this.save();
});
const cancel = actions.createEl('button', { text: 'Cancel' });
cancel.addEventListener('click', () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
private async save(): Promise<void> {
const folder = this.folderPath.trim();
if (!folder) {
new Notice('ReWrite: set a folder path.');
return;
}
if (!(this.app.vault.getAbstractFileByPath(normalizePath(folder)) instanceof TFolder)) {
new Notice(`ReWrite: folder "${folder}" was not found in this vault.`);
return;
}
if (!this.templateId) {
new Notice('ReWrite: pick a template.');
return;
}
await this.params.onSubmit({
folderPath: folder,
templateId: this.templateId,
enabled: this.enabled,
});
this.close();
}
}

View file

@ -8,10 +8,15 @@ import { isProfileConfigured, isProfileConfiguredForText, renderSetupCard } from
import { resolveActiveTextSource } from './text-source';
import { formatDuration, runBackgroundPipeline, stageLabel } from './pipeline-progress';
import { pickDefaultTemplateId } from '../templates-folder';
import { transcriptionProviderSupportsDiarization } from '../transcription';
// Continuous silence (ms) before the Record UI warns about a muted / dead mic.
const SILENCE_WARNING_MS = 3000;
const SILENCE_WARNING_TEXT = 'No audio detected. Check that your microphone is on and not muted.';
// Shown on mobile for the duration of a recording: backgrounding the app suspends the
// Capacitor WebView, which stops MediaRecorder mid-capture (the screen wake lock prevents
// screen-sleep but cannot prevent an app switch).
const MOBILE_RECORD_WARNING_TEXT = 'Keep Obsidian in the foreground while recording. Switching to another app stops the capture and the recording may be lost.';
export class ReWriteModal extends Modal {
private templateId: string;
@ -31,6 +36,10 @@ export class ReWriteModal extends Modal {
private destinationExpanded = false;
private contextHint = '';
private contextExpanded = false;
// Per-invocation speaker-diarization choice. Defaults to the active template's `diarize`
// flag (reset on template change), and the user can toggle it for this run. There is no
// persisted profile setting anymore.
private diarize = false;
constructor(
app: App,
@ -39,6 +48,7 @@ export class ReWriteModal extends Modal {
) {
super(app);
this.templateId = initialTemplateId ?? pickDefaultTemplateId(this.plugin.settings, this.plugin.templates);
this.diarize = !!this.activeTemplate()?.diarize;
}
onOpen(): void {
@ -81,6 +91,7 @@ export class ReWriteModal extends Modal {
this.renderTemplateSelector(contentEl);
this.renderDestinationSelector(contentEl);
this.renderContextSelector(contentEl);
this.renderDiarizeToggle(contentEl);
this.renderTabBar(contentEl);
const tabBody = contentEl.createDiv({ cls: 'rewrite-tab-body' });
@ -177,6 +188,7 @@ export class ReWriteModal extends Modal {
this.destinationExpanded = false;
this.contextHint = '';
this.contextExpanded = false;
this.diarize = !!this.activeTemplate()?.diarize;
this.render();
});
}
@ -302,6 +314,30 @@ export class ReWriteModal extends Modal {
});
}
// Per-invocation "Identify speakers" toggle, shown only when the active profile's
// transcription provider supports diarization. Defaults to the template's `diarize`
// flag; the user can override it for this run. Only affects audio transcription.
private renderDiarizeToggle(parent: HTMLElement): void {
const { profile } = resolveActiveProfile(this.plugin.settings);
if (!transcriptionProviderSupportsDiarization(profile.transcriptionProvider)) return;
const row = parent.createDiv({ cls: 'rewrite-diarize-row' });
const label = row.createEl('label', { cls: 'rewrite-diarize-label' });
const checkbox = label.createEl('input', { type: 'checkbox' });
checkbox.checked = this.diarize;
checkbox.disabled = this.isLocked();
label.createSpan({
text: 'Identify speakers (label each voice; good for meetings, off for daily notes)',
});
checkbox.addEventListener('change', () => {
if (this.isLocked()) {
checkbox.checked = this.diarize;
return;
}
this.diarize = checkbox.checked;
});
}
private renderTabBar(parent: HTMLElement): void {
const tabs = parent.createDiv({ cls: 'rewrite-tabs' });
const record = tabs.createEl('button', { text: 'Record', cls: 'rewrite-tab' });
@ -339,6 +375,27 @@ export class ReWriteModal extends Modal {
return;
}
// Desktop-only opt-in (persisted on GlobalSettings.recordInBackground):
// pressing Record hands capture off to the Quick Record floating UI with
// this modal's template / destination override / context hint, closing the
// modal so Obsidian stays usable during capture. Hidden on mobile, where
// backgrounded capture is unreliable (the WebView suspends MediaRecorder).
if (Platform.isDesktop) {
const label = parent.createEl('label', { cls: 'rewrite-background-record' });
const checkbox = label.createEl('input', { type: 'checkbox' });
checkbox.checked = this.plugin.settings.recordInBackground;
checkbox.disabled = this.isLocked();
label.createSpan({
text: 'Record in background (close this window and keep recording in a floating bar)',
});
checkbox.addEventListener('change', () => {
this.plugin.settings.recordInBackground = checkbox.checked;
void this.plugin.saveSettings().catch((e) => {
console.error('ReWrite: failed to save record-in-background setting', e);
});
});
}
const button = parent.createEl('button', {
text: 'Record',
cls: 'mod-cta rewrite-record-button',
@ -352,6 +409,12 @@ export class ReWriteModal extends Modal {
text: SILENCE_WARNING_TEXT,
});
warning.hide();
// Mobile-only: warn not to leave the app while recording. Shown for the whole capture
// (not tied to silence), hidden when idle.
const mobileWarning = Platform.isMobile
? parent.createDiv({ cls: 'rewrite-mobile-record-warning', text: MOBILE_RECORD_WARNING_TEXT })
: null;
mobileWarning?.hide();
// 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,
@ -363,6 +426,10 @@ export class ReWriteModal extends Modal {
// moment recording starts (isLocked() is true precisely because isRecording is true).
if (this.running) return;
if (!this.isRecording) {
if (Platform.isDesktop && this.plugin.settings.recordInBackground) {
this.startBackgroundHandoff();
return;
}
try {
await this.beginCapture();
} catch (e) {
@ -372,6 +439,7 @@ export class ReWriteModal extends Modal {
this.isRecording = true;
button.setText('Stop');
dot.show();
mobileWarning?.show();
this.startTimerLoop(timer, warning);
} else {
button.disabled = true;
@ -381,6 +449,7 @@ export class ReWriteModal extends Modal {
button.setText('Record');
dot.hide();
warning.hide();
mobileWarning?.hide();
this.stopTimerLoop();
this.startRecordingPipeline(source);
} catch (e) {
@ -389,6 +458,7 @@ export class ReWriteModal extends Modal {
button.setText('Record');
dot.hide();
warning.hide();
mobileWarning?.hide();
this.stopTimerLoop();
} finally {
button.disabled = false;
@ -484,6 +554,24 @@ export class ReWriteModal extends Modal {
this.recorder = null;
}
// "Record in background" path: capture the modal's per-invocation params into
// locals, close the modal, and hand capture off to the Quick Record floating UI
// via the plugin's single-owner entry (same activeQuickRecord slot as the two
// commands, so only one recording can ever be live).
private startBackgroundHandoff(): void {
const template = this.activeTemplate();
if (!template) {
new Notice('Please pick a template.');
return;
}
const destinationOverride = this.destinationOverride ?? undefined;
const contextHint = this.contextHint.trim() || undefined;
const diarize = this.diarize;
const plugin = this.plugin;
this.close();
void plugin.startBackgroundRecording({ template, destinationOverride, contextHint, diarize });
}
// Recorded-audio path: close the modal immediately and run the pipeline detached, reporting
// progress and errors through a Notice (mirrors runAudioFilePipeline). The recording is
// persisted to the vault before transcription, so the saved file is the recovery path on
@ -513,6 +601,7 @@ export class ReWriteModal extends Modal {
source,
destinationOverride,
contextHint,
diarize: this.diarize,
},
{ startMessage: 'ReWrite: working...', templateId: template.id },
);
@ -540,6 +629,7 @@ export class ReWriteModal extends Modal {
source,
destinationOverride: this.destinationOverride ?? undefined,
contextHint: this.contextHint.trim() || undefined,
diarize: this.diarize,
onStage: (stage) => progress.setText(stageLabel(stage)),
});
this.plugin.settings.lastUsedTemplateId = template.id;

View file

@ -3,7 +3,7 @@ import type ReWritePlugin from '../main';
import { PipelineSource, runPipeline } from '../pipeline';
import { isMediaRecorderAvailable, resolveActiveProfile } from '../platform';
import { Recorder } from '../recorder';
import { NoteTemplate } from '../types';
import { DestinationOverride, NoteTemplate } from '../types';
import { isProfileConfigured } from './setup-card';
import { ReWriteModal } from './modal';
import { formatDuration, stageLabel } from './pipeline-progress';
@ -11,6 +11,9 @@ 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;
const SILENCE_WARNING_TEXT = 'No audio detected. Check that your microphone is on and not muted.';
// Mobile-only caution shown while recording: backgrounding the app suspends the Capacitor
// WebView and stops the capture (the wake lock prevents screen-sleep, not an app switch).
const MOBILE_RECORD_WARNING_TEXT = 'Keep Obsidian in the foreground. Switching to another app stops the recording.';
export class QuickRecordController {
private recorder: Recorder | null = null;
@ -22,14 +25,24 @@ export class QuickRecordController {
// 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;
// Per-invocation params handed off by the main modal's "Record in background"
// path. Both belong to the template they were set up against, so switching the
// template via the floater's popover clears them.
private destinationOverride: DestinationOverride | undefined;
private contextHint: string | undefined;
private diarize: boolean | undefined;
constructor(
private readonly plugin: ReWritePlugin,
template: NoteTemplate,
private readonly onDispose: () => void,
private readonly stopHotkey: string | null = null,
extras?: { destinationOverride?: DestinationOverride; contextHint?: string; diarize?: boolean },
) {
this.template = template;
this.destinationOverride = extras?.destinationOverride;
this.contextHint = extras?.contextHint;
this.diarize = extras?.diarize;
}
async begin(): Promise<void> {
@ -44,6 +57,14 @@ export class QuickRecordController {
getTemplates: () => this.plugin.templates,
getActiveTemplateId: () => this.template.id,
onPickTemplate: (t) => {
if (t.id !== this.template.id) {
// The handed-off destination override / context hint / diarize choice
// were set up against the original template; a different pick invalidates
// them (the new template's own `diarize` flag then governs).
this.destinationOverride = undefined;
this.contextHint = undefined;
this.diarize = undefined;
}
this.template = t;
this.floater?.setTemplateName(t.name);
},
@ -85,6 +106,9 @@ export class QuickRecordController {
profile,
template: this.template,
source,
destinationOverride: this.destinationOverride,
contextHint: this.contextHint,
diarize: this.diarize,
signal: this.controller.signal,
onStage: (stage) => this.floater?.setBusy(stageLabel(stage)),
});
@ -122,7 +146,15 @@ export class QuickRecordController {
export async function startQuickRecord(
plugin: ReWritePlugin,
onDispose: () => void,
opts?: { template?: NoteTemplate; commandId?: string },
opts?: {
template?: NoteTemplate;
commandId?: string;
// Set by the main modal's "Record in background" handoff; carried into the
// pipeline run exactly like the modal's own detached path would.
destinationOverride?: DestinationOverride;
contextHint?: string;
diarize?: boolean;
},
): Promise<QuickRecordController | null> {
const settings = plugin.settings;
const { profile } = resolveActiveProfile(settings);
@ -159,7 +191,11 @@ export async function startQuickRecord(
}
const stopHotkey = opts?.commandId ? formatCommandHotkey(plugin.app, opts.commandId) : null;
const controller = new QuickRecordController(plugin, template, onDispose, stopHotkey);
const controller = new QuickRecordController(plugin, template, onDispose, stopHotkey, {
destinationOverride: opts?.destinationOverride,
contextHint: opts?.contextHint,
diarize: opts?.diarize,
});
try {
await controller.begin();
} catch (e) {
@ -229,6 +265,7 @@ class QuickRecordFloater {
private readonly el: HTMLElement;
private readonly timerEl: HTMLElement;
private readonly warningEl: HTMLElement;
private readonly mobileWarningEl: HTMLElement | null;
private readonly templateBtn: HTMLButtonElement;
private readonly templateLabel: HTMLElement;
private popover: HTMLElement | null = null;
@ -291,6 +328,12 @@ class QuickRecordFloater {
text: SILENCE_WARNING_TEXT,
});
this.warningEl.hide();
// Mobile-only: shown for the whole capture (the floater exists only while recording /
// processing), hidden once the pipeline takes over in setBusy.
this.mobileWarningEl = Platform.isMobile
? this.el.createDiv({ cls: 'rewrite-quick-mobile-warning', text: MOBILE_RECORD_WARNING_TEXT })
: null;
}
setSilenceWarning(show: boolean): void {
@ -311,6 +354,7 @@ class QuickRecordFloater {
this.timerEl.setText(label);
this.templateBtn.disabled = true;
this.warningEl.hide();
this.mobileWarningEl?.hide();
this.closePopover();
}

214
src/ui/realtime.ts Normal file
View file

@ -0,0 +1,214 @@
import { Editor, MarkdownView, Notice } from 'obsidian';
import type ReWritePlugin from '../main';
import { resolveActiveProfile } from '../platform';
import {
createRealtimeProvider,
RealtimeSession,
transcriptionProviderSupportsRealtime,
} from '../realtime';
import { isPcmCaptureAvailable, PcmCapture, REALTIME_SAMPLE_RATE } from '../realtime/pcm';
// Realtime dictation: stream mic audio to the provider and type finalized
// segments at the editor cursor as they arrive. Deliberately minimal by design
// (roadmap item 2): command/shortcut only, no template, no LLM cleanup, no
// audio persistence — the transcript lands raw, like typing.
export class RealtimeController {
private capture: PcmCapture | null = null;
private session: RealtimeSession | null = null;
private floater: RealtimeFloater | null = null;
private settled = false;
constructor(
private readonly plugin: ReWritePlugin,
private readonly editor: Editor,
private readonly onDispose: () => void,
) {}
async begin(): Promise<void> {
const { profile } = resolveActiveProfile(this.plugin.settings);
const provider = createRealtimeProvider(profile.realtimeProvider);
const capture = new PcmCapture();
this.capture = capture;
try {
this.session = await provider.start(profile.realtimeConfig, REALTIME_SAMPLE_RATE, {
onFinal: (text) => this.insertFinal(text),
onInterim: (text) => this.floater?.setInterim(text),
onError: (error) => {
new Notice(`ReWrite realtime: ${error.message}`);
this.teardown();
},
onUnexpectedClose: () => {
if (this.settled) return;
new Notice('ReWrite realtime: the connection closed.');
this.teardown();
},
});
await capture.start((chunk) => this.session?.sendAudio(chunk));
this.floater = new RealtimeFloater({
onStop: () => {
void this.finish();
},
});
} catch (e) {
// Any startup failure (socket open, mic access, node setup): tear down whatever
// partially initialized so a half-open session, a live mic, or an AudioContext
// can't leak. capture.stop() is a safe no-op if start() never assigned anything.
capture.stop();
this.capture = null;
if (this.session) {
await this.session.stop().catch(() => { /* best effort */ });
this.session = null;
}
throw e;
}
}
// Graceful stop: end capture, let the provider flush trailing finals (still
// inserted through insertFinal while we wait), then dispose.
async finish(): Promise<void> {
if (this.settled) return;
this.settled = true;
this.floater?.setBusy('Finishing...');
this.capture?.stop();
this.capture = null;
try {
await this.session?.stop();
} catch (e) {
console.error('ReWrite: realtime stop failed', e);
}
this.session = null;
this.floater?.dispose();
this.floater = null;
this.onDispose();
}
// Hard teardown for errors and plugin unload: no flush, just release everything.
cancel(): void {
this.teardown();
}
private teardown(): void {
if (this.settled) return;
this.settled = true;
this.capture?.stop();
this.capture = null;
const session = this.session;
this.session = null;
if (session) void session.stop().catch(() => { /* best effort */ });
this.floater?.dispose();
this.floater = null;
this.onDispose();
}
private insertFinal(text: string): void {
this.floater?.setInterim('');
try {
// Behaves like typing: inserts at the cursor (or over a selection) and
// moves the cursor past the inserted text, so consecutive segments chain.
this.editor.replaceSelection(`${text} `);
} catch (e) {
console.error('ReWrite: realtime insert failed', e);
new Notice('ReWrite realtime: the note editor went away; stopping.');
this.teardown();
}
}
}
// One realtime session at a time, owned by the plugin (mirrors activeQuickRecord).
export async function startRealtimeTranscription(
plugin: ReWritePlugin,
onDispose: () => void,
): Promise<RealtimeController | null> {
const { profile } = resolveActiveProfile(plugin.settings);
if (plugin.encryptionStatus.locked) {
new Notice('ReWrite: API keys are locked. Unlock to use realtime transcription.');
plugin.promptUnlock();
return null;
}
if (!transcriptionProviderSupportsRealtime(profile.realtimeProvider)) {
new Notice('ReWrite: set the real-time provider to AssemblyAI or Deepgram in this profile\'s real-time transcription settings.');
return null;
}
if (!profile.realtimeConfig.apiKey) {
new Notice('ReWrite: set the real-time transcription API key in settings first.');
return null;
}
if (!isPcmCaptureAvailable()) {
new Notice('ReWrite: audio capture is not supported in this environment.');
return null;
}
const editor = plugin.app.workspace.getActiveViewOfType(MarkdownView)?.editor;
if (!editor) {
new Notice('ReWrite: open a Markdown note first; the live transcript is typed at the cursor.');
return null;
}
const controller = new RealtimeController(plugin, editor, onDispose);
try {
await controller.begin();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`ReWrite realtime could not start: ${msg}`);
onDispose();
return null;
}
return controller;
}
interface RealtimeFloaterOptions {
onStop: () => void;
}
// Floating status bar for a live session: pulsing dot, rolling interim text,
// and a Stop button. Mirrors the Quick Record floater's lifecycle (a
// document.body div owned by the controller, cleaned up via dispose()).
class RealtimeFloater {
private readonly el: HTMLElement;
private readonly interimEl: HTMLElement;
private readonly stopBtn: HTMLButtonElement;
private busy = false;
constructor(options: RealtimeFloaterOptions) {
this.el = activeDocument.body.createDiv({ cls: 'rewrite-quick-floater rewrite-realtime-floater' });
const row = this.el.createDiv({ cls: 'rewrite-quick-row' });
row.createSpan({ cls: 'rewrite-quick-dot' });
row.createSpan({ cls: 'rewrite-quick-label', text: 'Live transcription' });
this.stopBtn = row.createEl('button', {
text: 'Stop',
cls: 'mod-cta rewrite-quick-stop',
});
this.stopBtn.addEventListener('click', () => {
if (this.busy) return;
options.onStop();
});
this.interimEl = this.el.createDiv({ cls: 'rewrite-realtime-interim' });
this.interimEl.hide();
}
setInterim(text: string): void {
if (this.busy) return;
if (!text) {
this.interimEl.hide();
this.interimEl.setText('');
return;
}
this.interimEl.setText(text);
this.interimEl.show();
}
setBusy(label: string): void {
this.busy = true;
this.el.addClass('is-busy');
this.interimEl.setText(label);
this.interimEl.show();
this.stopBtn.disabled = true;
}
dispose(): void {
this.el.remove();
}
}

View file

@ -130,6 +130,27 @@ const READY_TIMEOUT_MS = 5_000;
const READY_POLL_MS = 250;
const STOP_KILL_GRACE_MS = 3_000;
// Pure idle-stop decision, extracted so it is unit-testable without a live
// process. Only a ReWrite-owned server (spawned or adopted) is ever idle-stopped:
// external servers were started by someone else and are never our call to stop.
// A transcription in flight (inFlightUses > 0) always blocks the stop, even past
// the deadline, so a long job on a big model is never killed under the user.
export function shouldStopWhenIdle(
status: WhisperStatus,
ownership: WhisperOwnership | null,
inFlightUses: number,
lastActivityAt: number | null,
idleStopMinutes: number,
now: number,
): boolean {
if (idleStopMinutes <= 0) return false;
if (status !== 'running') return false;
if (ownership !== 'spawned' && ownership !== 'adopted') return false;
if (inFlightUses > 0) return false;
if (lastActivityAt === null) return false;
return now - lastActivityAt >= idleStopMinutes * 60_000;
}
export class WhisperHost {
private statusValue: WhisperStatus = 'stopped';
private child: SpawnedChild | null = null;
@ -138,9 +159,43 @@ export class WhisperHost {
private ownershipValue: WhisperOwnership | null = null;
private logBuffer = '';
private stoppingDeliberately = false;
// Idle-stop bookkeeping: when the server last did something on our behalf
// (start, adopt, or a transcription finishing) and how many transcriptions are
// currently in flight against it.
private lastActivityAt: number | null = null;
private inFlightUses = 0;
constructor(private plugin: Plugin) {}
// Bracket a transcription request against the hosted server so the idle-stop
// timer neither counts an in-flight job as idle time nor stops mid-job.
beginUse(): void {
this.inFlightUses++;
this.lastActivityAt = Date.now();
}
endUse(): void {
this.inFlightUses = Math.max(0, this.inFlightUses - 1);
this.lastActivityAt = Date.now();
}
// Called on an interval from main.ts. Stops a ReWrite-owned server that has
// been idle past the configured threshold; a no-op in every other state.
async stopIfIdle(idleStopMinutes: number): Promise<boolean> {
if (!shouldStopWhenIdle(
this.statusValue,
this.ownershipValue,
this.inFlightUses,
this.lastActivityAt,
idleStopMinutes,
Date.now(),
)) {
return false;
}
await this.stop();
return true;
}
status(): WhisperStatus {
return this.statusValue;
}
@ -261,6 +316,7 @@ export class WhisperHost {
}
if (await isPortReachable(api.net, port)) {
this.statusValue = 'running';
this.lastActivityAt = Date.now();
if (child.pid !== undefined) {
await this.writePidFile({
pid: child.pid,
@ -390,6 +446,9 @@ export class WhisperHost {
this.ownershipValue = 'adopted';
this.currentPort = port;
this.currentPid = record.pid;
// Adoption counts as activity so the idle-stop clock starts now rather
// than never (an adopted server has no start() timestamp this session).
this.lastActivityAt = Date.now();
return this.snapshot();
}
// Bound by someone else. Clear stale sidecar if present.

View file

@ -56,6 +56,19 @@
border-radius: 4px;
}
.rewrite-modal .rewrite-diarize-row {
margin-bottom: 12px;
}
.rewrite-modal .rewrite-diarize-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: var(--font-ui-small);
color: var(--text-muted);
}
.rewrite-modal .rewrite-context-summary {
cursor: pointer;
user-select: none;
@ -131,6 +144,17 @@
font-size: var(--font-ui-small);
}
/* Caution (not error): yellow outline + text so an app-switch reads as an alert, not a problem. */
.rewrite-modal .rewrite-mobile-record-warning {
margin-top: 8px;
padding: 6px 10px;
border-radius: 4px;
background-color: var(--background-secondary);
border: 1px solid var(--text-warning);
color: var(--text-warning);
font-size: var(--font-ui-small);
}
.rewrite-modal .rewrite-paste {
width: 100%;
min-height: 160px;
@ -294,6 +318,18 @@
max-width: 28ch;
}
/* Caution (not error): yellow so leaving the app reads as an alert, not a problem. */
.rewrite-quick-floater .rewrite-quick-mobile-warning {
margin-top: 6px;
padding: 4px 8px;
border-radius: 4px;
background-color: var(--background-secondary);
border: 1px solid var(--text-warning);
color: var(--text-warning);
font-size: var(--font-ui-smaller);
max-width: 28ch;
}
.rewrite-quick-floater .rewrite-quick-dot {
width: 10px;
height: 10px;
@ -608,3 +644,75 @@
min-height: 80px;
}
/* "Record in background" checkbox on the main modal's Record tab (desktop only) */
.rewrite-modal .rewrite-background-record {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: var(--font-ui-small);
color: var(--text-muted);
cursor: pointer;
}
/* "Manage built-in templates" disclosure in the Templates settings section */
.rewrite-settings .rewrite-manage-defaults {
margin: 8px 0 12px 0;
}
.rewrite-settings .rewrite-manage-defaults > summary {
cursor: pointer;
user-select: none;
color: var(--text-muted);
font-size: var(--font-ui-small);
padding: 4px 0;
}
/* Bold the Enabled / Tracked terms in the legend so they tie to the row controls. */
.rewrite-settings .rewrite-manage-legend-term {
font-weight: var(--font-semibold);
color: var(--text-normal);
}
/* Row controls: keep the labelled Tracked checkbox and the Enabled switch on one
* line with comfortable spacing, wrapping only if the row is very narrow. */
.rewrite-settings .rewrite-manage-row .setting-item-control {
gap: 14px;
flex-wrap: wrap;
justify-content: flex-end;
}
.rewrite-settings .rewrite-manage-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--font-ui-small);
color: var(--text-normal);
cursor: pointer;
white-space: nowrap;
}
.rewrite-settings .rewrite-manage-check input {
margin: 0;
cursor: pointer;
}
/* "Enabled" caption sits just left of the switch so the switch is self-describing. */
.rewrite-settings .rewrite-manage-switch-label {
font-size: var(--font-ui-small);
color: var(--text-normal);
white-space: nowrap;
}
/* Realtime dictation floater: reuses the quick-floater shell; the interim line
* shows the rolling in-progress hypothesis under the controls row. */
.rewrite-realtime-floater .rewrite-realtime-interim {
max-width: 36ch;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}

116
test/ingest.test.ts Normal file
View file

@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import { collectIngestFiles, ingestTargetIsSameFolder, isIngestTemplate, normalizeIngestFolder } from '../src/ingest';
import { NoteTemplate } from '../src/types';
import { TFile, TFolder } from '../test/mocks/obsidian';
function template(overrides: Partial<NoteTemplate> = {}): NoteTemplate {
return {
id: 'tpl',
name: 'Template',
prompt: 'p',
insertMode: 'newFile',
newFileFolder: '',
newFileNameTemplate: '{{title}}',
...overrides,
};
}
function audioFile(name: string, ext: string): TFile {
const f = new TFile();
f.path = `Inbox/${name}.${ext}`;
f.name = `${name}.${ext}`;
f.basename = name;
f.extension = ext;
return f;
}
describe('isIngestTemplate', () => {
it('accepts a newFile template', () => {
expect(isIngestTemplate(template())).toBe(true);
});
// Unattended ingest has no active editor: cursor falls back to append, append
// to newFile, so only explicit newFile templates are eligible.
it('rejects cursor and append templates', () => {
expect(isIngestTemplate(template({ insertMode: 'cursor' }))).toBe(false);
expect(isIngestTemplate(template({ insertMode: 'append' }))).toBe(false);
});
it('rejects a missing template', () => {
expect(isIngestTemplate(undefined)).toBe(false);
});
});
describe('collectIngestFiles', () => {
it('collects only audio files, sorted by name', () => {
const folder = new TFolder();
const md = new TFile();
md.path = 'Inbox/note.md';
md.extension = 'md';
folder.children = [audioFile('b-rec', 'mp3'), md, audioFile('a-rec', 'm4a')];
const files = collectIngestFiles(folder as never);
expect(files.map((f) => f.basename)).toEqual(['a-rec', 'b-rec']);
});
// Direct children only: a subfolder the user organizes into must never be
// swept up (TFolder children that are folders are not TFile instances).
it('ignores subfolders', () => {
const folder = new TFolder();
const sub = new TFolder();
sub.children = [audioFile('nested', 'mp3')];
folder.children = [sub, audioFile('top', 'wav')];
const files = collectIngestFiles(folder as never);
expect(files.map((f) => f.basename)).toEqual(['top']);
});
it('returns empty for a folder with no audio', () => {
const folder = new TFolder();
folder.children = [];
expect(collectIngestFiles(folder as never)).toEqual([]);
});
});
describe('ingestTargetIsSameFolder', () => {
// The reprocess-loop guard: when the attachment target resolves into the folder the file
// is already in, the move is refused (a local review flagged this comparison).
it('is true when the target folder equals the file parent folder', () => {
expect(ingestTargetIsSameFolder('Inbox/rec.mp3', 'Inbox')).toBe(true);
expect(ingestTargetIsSameFolder('a/b/rec.mp3', 'a/b')).toBe(true);
});
it('is false when the target is a different folder', () => {
expect(ingestTargetIsSameFolder('Attachments/rec.mp3', 'Inbox')).toBe(false);
expect(ingestTargetIsSameFolder('Inbox/sub/rec.mp3', 'Inbox')).toBe(false);
});
it('treats vault root ("/" or "") consistently', () => {
// A root-level target and a root-level file are the same folder.
expect(ingestTargetIsSameFolder('rec.mp3', '')).toBe(true);
expect(ingestTargetIsSameFolder('rec.mp3', '/')).toBe(true);
// A root file but a subfolder target is a real move.
expect(ingestTargetIsSameFolder('Attachments/rec.mp3', '')).toBe(false);
expect(ingestTargetIsSameFolder('Attachments/rec.mp3', '/')).toBe(false);
});
});
describe('normalizeIngestFolder', () => {
// The up-front misconfiguration check (rule folder equals the recordings folder)
// compares folders through this normalizer, so a trailing slash or a root variant
// must not read as a different folder.
it('treats the vault root variants as one empty root', () => {
expect(normalizeIngestFolder('')).toBe('');
expect(normalizeIngestFolder('/')).toBe('');
expect(normalizeIngestFolder(' ')).toBe('');
});
it('trims whitespace and a trailing slash', () => {
expect(normalizeIngestFolder(' Attachments ')).toBe('Attachments');
expect(normalizeIngestFolder('Attachments/')).toBe('Attachments');
expect(normalizeIngestFolder('a/b/')).toBe('a/b');
});
it('leaves an already-clean folder unchanged, so equal folders compare equal', () => {
expect(normalizeIngestFolder('Voice Inbox')).toBe('Voice Inbox');
expect(normalizeIngestFolder('Voice Inbox/')).toBe(normalizeIngestFolder('Voice Inbox'));
});
});

View file

@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
CODE_PATHS,
DOC_PATHS,
buildDiffArgs,
buildReviewMessages,
formatReport,
@ -7,9 +9,11 @@ import {
isLoopbackHost,
parseCliArgs,
parseLocalReviewConfig,
pathsForMode,
scopeLabel,
splitArgs,
truncateDiff,
untrackedArgs,
} from '../local-review.mjs';
// The loopback guard is duplicated from src/whisper-host.ts on purpose (see the comment in
@ -66,21 +70,55 @@ describe('splitArgs', () => {
describe('parseCliArgs', () => {
it('defaults to no flags', () => {
expect(parseCliArgs([])).toEqual({ base: null, staged: false, full: false });
expect(parseCliArgs([])).toEqual({ base: null, staged: false, full: false, mode: 'code' });
});
it('reads --base with a following value', () => {
expect(parseCliArgs(['--base', 'main'])).toEqual({ base: 'main', staged: false, full: false });
expect(parseCliArgs(['--base', 'main'])).toEqual({ base: 'main', staged: false, full: false, mode: 'code' });
});
it('reads --base=value form', () => {
expect(parseCliArgs(['--base=develop'])).toEqual({ base: 'develop', staged: false, full: false });
expect(parseCliArgs(['--base=develop'])).toEqual({ base: 'develop', staged: false, full: false, mode: 'code' });
});
it('reads --staged and --full', () => {
expect(parseCliArgs(['--staged'])).toMatchObject({ staged: true });
expect(parseCliArgs(['--full'])).toMatchObject({ full: true });
});
it('defaults mode to code and reads --docs / --code / --mode', () => {
expect(parseCliArgs([]).mode).toBe('code');
expect(parseCliArgs(['--docs']).mode).toBe('docs');
expect(parseCliArgs(['--code']).mode).toBe('code');
expect(parseCliArgs(['--mode', 'docs']).mode).toBe('docs');
expect(parseCliArgs(['--mode=docs']).mode).toBe('docs');
// unknown mode value falls back to code
expect(parseCliArgs(['--mode', 'wat']).mode).toBe('code');
});
});
describe('pathsForMode', () => {
it('returns code paths by default and doc paths for docs', () => {
expect(pathsForMode('code')).toBe(CODE_PATHS);
expect(pathsForMode('docs')).toBe(DOC_PATHS);
expect(pathsForMode('anything-else')).toBe(CODE_PATHS);
});
it('code scope covers src and styles; doc scope is markdown', () => {
expect(CODE_PATHS).toContain('src/');
expect(CODE_PATHS).toContain('styles.css');
expect(DOC_PATHS).toEqual(['*.md']);
});
});
describe('untrackedArgs', () => {
it('lists untracked, non-ignored files within the pathspec', () => {
expect(untrackedArgs(['src/'])).toEqual(['ls-files', '--others', '--exclude-standard', '--', 'src/']);
});
it('omits the pathspec separator when no paths are given', () => {
expect(untrackedArgs([])).toEqual(['ls-files', '--others', '--exclude-standard']);
});
});
describe('buildDiffArgs', () => {
@ -99,6 +137,16 @@ describe('buildDiffArgs', () => {
it('adds --stat when requested', () => {
expect(buildDiffArgs({ staged: false, full: false }, 'abc123', { stat: true })).toEqual(['diff', '--stat', 'abc123']);
});
it('appends a pathspec after the range', () => {
expect(buildDiffArgs({ staged: false, full: false }, 'abc123', { paths: ['src/', 'styles.css'] }))
.toEqual(['diff', 'abc123', '--', 'src/', 'styles.css']);
});
it('combines stat and pathspec', () => {
expect(buildDiffArgs({ staged: false, full: false }, 'abc123', { stat: true, paths: ['*.md'] }))
.toEqual(['diff', '--stat', 'abc123', '--', '*.md']);
});
});
describe('scopeLabel', () => {
@ -161,6 +209,7 @@ describe('parseLocalReviewConfig', () => {
readyTimeoutMs: 60000,
requestTimeoutMs: 300000,
maxDiffChars: 60000,
maxOutputTokens: 8192,
});
});
@ -188,6 +237,14 @@ describe('buildReviewMessages', () => {
expect(msgs[1].role).toBe('user');
expect(msgs[1].content).toContain('diff body');
});
it('uses a different system prompt for docs mode than code mode', () => {
const code = buildReviewMessages('x', 'code')[0].content;
const docs = buildReviewMessages('x', 'docs')[0].content;
expect(code).not.toBe(docs);
expect(docs).toMatch(/consisten|contradict|stale/i);
expect(code).toMatch(/correctness|bug/i);
});
});
describe('formatReport', () => {
@ -208,6 +265,22 @@ describe('formatReport', () => {
expect(report).not.toContain('was truncated');
});
it('reflects the review mode and untracked-file count in the header', () => {
const report = formatReport({
baseRef: 'master',
mergeBase: 'deadbeef',
timestamp: 't',
diffStat: '',
findings: 'ok',
truncated: false,
scope: { staged: false, full: false },
mode: 'docs',
untrackedCount: 2,
});
expect(report).toContain('# Local review report (docs)');
expect(report).toMatch(/2 untracked file/);
});
it('notes truncation when set', () => {
const report = formatReport({
baseRef: 'master',

View file

@ -50,6 +50,7 @@ export class Notice {
export class TFile {
path = '';
name = '';
basename = '';
extension = '';
stat = { mtime: 0, ctime: 0, size: 0 };

60
test/realtime-pcm.test.ts Normal file
View file

@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { downsampleBuffer, floatTo16BitPcm, REALTIME_SAMPLE_RATE } from '../src/realtime/pcm';
describe('downsampleBuffer', () => {
it('returns the input unchanged when rates match', () => {
const input = new Float32Array([0.1, 0.2, 0.3]);
expect(downsampleBuffer(input, 16_000, 16_000)).toBe(input);
});
it('produces the expected length for a 48k -> 16k downsample', () => {
const input = new Float32Array(4800); // 100 ms at 48 kHz
const out = downsampleBuffer(input, 48_000, REALTIME_SAMPLE_RATE);
expect(out.length).toBe(1600); // 100 ms at 16 kHz
});
it('preserves a constant signal through resampling', () => {
const input = new Float32Array(480).fill(0.5);
const out = downsampleBuffer(input, 48_000, 16_000);
for (const sample of out) {
expect(sample).toBeCloseTo(0.5, 5);
}
});
it('interpolates between neighboring samples for non-integer positions', () => {
// 44.1k -> 16k has a non-integer ratio, forcing interpolation.
const input = new Float32Array(441);
for (let i = 0; i < input.length; i++) input[i] = i / input.length;
const out = downsampleBuffer(input, 44_100, 16_000);
expect(out.length).toBe(160);
// A linear ramp must stay monotonically non-decreasing after resampling.
for (let i = 1; i < out.length; i++) {
expect(out[i]).toBeGreaterThanOrEqual(out[i - 1] ?? 0);
}
});
it('refuses to upsample', () => {
expect(() => downsampleBuffer(new Float32Array(10), 8_000, 16_000)).toThrow();
});
});
describe('floatTo16BitPcm', () => {
it('maps the float range onto signed 16-bit', () => {
const out = floatTo16BitPcm(new Float32Array([0, 1, -1]));
expect(out[0]).toBe(0);
expect(out[1]).toBe(0x7fff);
expect(out[2]).toBe(-0x8000);
});
it('clamps out-of-range samples instead of wrapping', () => {
const out = floatTo16BitPcm(new Float32Array([2.5, -2.5]));
expect(out[0]).toBe(0x7fff);
expect(out[1]).toBe(-0x8000);
});
it('quantizes mid-range values proportionally', () => {
const out = floatTo16BitPcm(new Float32Array([0.5, -0.5]));
expect(out[0]).toBe(Math.round(0.5 * 0x7fff));
expect(out[1]).toBe(Math.round(-0.5 * 0x8000));
});
});

View file

@ -64,4 +64,65 @@ describe('mergeSettings', () => {
expect(merged.desktopProfile.transcriptionConfig.model).toBe('whisper-1');
expect(merged.desktopProfile.transcriptionConfig.baseUrl).toBe(DEFAULT_SETTINGS.desktopProfile.transcriptionConfig.baseUrl);
});
it('provides and merges the separate realtime provider + config slot', () => {
const base = mergeSettings(DEFAULT_SETTINGS, {});
expect(base.desktopProfile.realtimeProvider).toBe('none');
expect(base.desktopProfile.realtimeConfig).toEqual(DEFAULT_SETTINGS.desktopProfile.realtimeConfig);
// realtimeProvider is enum-validated: garbage falls back to the base value
const bad = mergeSettings(DEFAULT_SETTINGS, {
// @ts-expect-error deliberately invalid
desktopProfile: { ...DEFAULT_SETTINGS.desktopProfile, realtimeProvider: 'not-a-provider' },
});
expect(bad.desktopProfile.realtimeProvider).toBe('none');
// a stored partial (e.g. an older data.json with no realtimeConfig) still yields the slot
const merged = mergeSettings(DEFAULT_SETTINGS, {
desktopProfile: {
...DEFAULT_SETTINGS.desktopProfile,
realtimeConfig: { ...DEFAULT_SETTINGS.desktopProfile.realtimeConfig, model: 'voxtral-mini-transcribe-realtime-2602' },
},
});
expect(merged.desktopProfile.realtimeConfig.model).toBe('voxtral-mini-transcribe-realtime-2602');
});
it('keeps valid disabledDefaultTemplateIds and drops malformed entries', () => {
const merged = mergeSettings(DEFAULT_SETTINGS, {
// @ts-expect-error deliberately mixed garbage to simulate a corrupt data.json
disabledDefaultTemplateIds: ['tpl-default-podcast', 42, null, '', 'tpl-default-guides'],
});
expect(merged.disabledDefaultTemplateIds).toEqual(['tpl-default-podcast', 'tpl-default-guides']);
});
it('falls back to base disabledDefaultTemplateIds when the stored value is not an array', () => {
const merged = mergeSettings(DEFAULT_SETTINGS, {
// @ts-expect-error deliberately invalid
disabledDefaultTemplateIds: 'garbage',
});
expect(merged.disabledDefaultTemplateIds).toEqual([]);
});
it('sanitizes ingestRules: keeps well-formed rules, drops the rest, coerces enabled to boolean', () => {
const merged = mergeSettings(DEFAULT_SETTINGS, {
ingestRules: [
{ folderPath: 'Voice Inbox', templateId: 'tpl-default-guides', enabled: true },
// @ts-expect-error non-boolean enabled coerces to false
{ folderPath: 'Other', templateId: 'tpl', enabled: 'yes' },
// @ts-expect-error missing templateId is dropped
{ folderPath: 'NoTemplate' },
// @ts-expect-error non-object is dropped
'garbage',
],
});
expect(merged.ingestRules).toEqual([
{ folderPath: 'Voice Inbox', templateId: 'tpl-default-guides', enabled: true },
{ folderPath: 'Other', templateId: 'tpl', enabled: false },
]);
});
it('ships the new Phase B whisper defaults off', () => {
const merged = mergeSettings(DEFAULT_SETTINGS, {});
expect(merged.localWhisper.autoStart).toBe(false);
expect(merged.localWhisper.idleStopMinutes).toBe(0);
expect(merged.recordInBackground).toBe(false);
});
});

View file

@ -117,4 +117,39 @@ describe('mergeTemplate', () => {
expect(merged.noteProperties).toEqual([{ name: 'subject', instruction: 'fill this in' }]);
expect(changes.some((c) => c.includes('subject'))).toBe(true);
});
// Update only ever merges tracked default-derived files, so the reconciled
// output is stamped managed: true (back-filling pre-flag files too).
it('stamps managed: true on the merged result', () => {
const def = baseTemplate();
const onDisk = baseTemplate(); // no managed key, like a pre-flag file
const { merged } = mergeTemplate(onDisk, def, []);
expect(merged.managed).toBe(true);
});
});
describe('managed flag round trip', () => {
it('round-trips managed: true', () => {
const rendered = renderTemplateFile(baseTemplate({ managed: true }));
expect(parseTemplateContent(fakeFile('x'), rendered)?.managed).toBe(true);
});
it('round-trips managed: false (untracked)', () => {
const rendered = renderTemplateFile(baseTemplate({ managed: false }));
expect(parseTemplateContent(fakeFile('x'), rendered)?.managed).toBe(false);
});
// Tri-state: an absent/empty key must stay undefined, NOT collapse to false,
// because absent means "tracked when the id matches a built-in" (old files)
// while explicit false means "user untracked it; never touch".
it('parses an absent/empty managed key as undefined, not false', () => {
const rendered = renderTemplateFile(baseTemplate());
expect(rendered).toContain('managed:');
expect(parseTemplateContent(fakeFile('x'), rendered)?.managed).toBeUndefined();
});
it('tolerates the string forms written by the Properties UI', () => {
const content = '---\nid: t\nmanaged: "false"\n---\nBody.';
expect(parseTemplateContent(fakeFile('x'), content)?.managed).toBe(false);
});
});

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { getHostArgs, isLoopbackHost } from '../src/whisper-host';
import { getHostArgs, isLoopbackHost, shouldStopWhenIdle } from '../src/whisper-host';
describe('getHostArgs', () => {
it('returns an empty array when no --host is present', () => {
@ -45,3 +45,43 @@ describe('isLoopbackHost', () => {
expect(isLoopbackHost(host)).toBe(expected);
});
});
describe('shouldStopWhenIdle', () => {
const MIN = 60_000;
it('never stops when idle stop is disabled (0 minutes)', () => {
expect(shouldStopWhenIdle('running', 'spawned', 0, 0, 0, 10 * MIN)).toBe(false);
});
it('stops a spawned server idle past the threshold', () => {
expect(shouldStopWhenIdle('running', 'spawned', 0, 0, 5, 5 * MIN)).toBe(true);
});
it('stops an adopted server idle past the threshold', () => {
expect(shouldStopWhenIdle('running', 'adopted', 0, 0, 5, 6 * MIN)).toBe(true);
});
it('does not stop before the threshold elapses', () => {
expect(shouldStopWhenIdle('running', 'spawned', 0, 0, 5, 4 * MIN)).toBe(false);
});
// External servers were started by someone else; ReWrite never stops them.
it('never stops an external server', () => {
expect(shouldStopWhenIdle('external', 'external', 0, 0, 5, 60 * MIN)).toBe(false);
});
// A long transcription on a big model must not be killed under the user even
// when the last-activity timestamp is far in the past.
it('never stops while a transcription is in flight', () => {
expect(shouldStopWhenIdle('running', 'spawned', 1, 0, 5, 60 * MIN)).toBe(false);
});
it('does nothing when the server is not running', () => {
expect(shouldStopWhenIdle('stopped', null, 0, 0, 5, 60 * MIN)).toBe(false);
expect(shouldStopWhenIdle('starting', 'spawned', 0, 0, 5, 60 * MIN)).toBe(false);
});
it('does nothing without an activity timestamp', () => {
expect(shouldStopWhenIdle('running', 'spawned', 0, null, 5, 60 * MIN)).toBe(false);
});
});

View file

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

View file

@ -11,6 +11,8 @@ Every entry point ReWrite adds: command palette commands, the ribbon icon, right
| **Quick record (set template)** | Same one-shot capture, but always uses the template pinned in Settings, Templates, Quick record (set template). If none is pinned, it shows a notice and does nothing. |
| **Process text with template** | Runs a template over the current editor selection, or the whole note if nothing is selected. No audio. Progress shows via notices. |
| **Reprocess audio file with template** | Reruns the full pipeline over an audio file already in your vault. Opens an audio-file picker, then a template picker. |
| **Process auto-ingest folders** | Scans your configured ingest folders and processes every audio file found with the folder's preassigned template, one at a time, then moves each processed recording in with your other saved recordings. A sticky progress notice with Cancel tracks the batch. See [Settings reference](Settings-Reference) for setting up rules. |
| **Real-time transcription (start/stop)** | Live dictation: streams your mic to the provider and types the transcript at the cursor as you speak. No template, no LLM cleanup, no saved audio, just raw live text. Needs a **Real-time provider** (AssemblyAI or Deepgram) and its key set in the profile's Real-time transcription settings, configured independently of the batch transcription provider, plus an open Markdown note (see [Providers](Providers)). Run it again (or click Stop on the floating bar) to end. |
| **Start whisper host** / **Stop whisper host** | Starts or stops the local whisper.cpp server. Only visible on desktop when relevant (start: active profile uses `whisper-local`; stop: the host is running or starting). See [Self-hosting: whisper.cpp](Self-Hosting-Whisper). |
## Ribbon and status bar
@ -36,6 +38,8 @@ Opened by the ribbon icon or the Open command. It has:
When you **Record** and press Stop, the modal closes right away and the rest of the work (transcribe, clean up, insert) runs in the background with progress shown via notices, the same as reprocessing a saved file. If something fails, a notice tells you why; your recording was already saved to the vault, so you can reprocess it. The **Paste** and **From note** tabs instead keep the modal open while they run and offer a **Retry** button on error, since their input is not saved anywhere.
The Record tab also has a **Record in background** checkbox (desktop only; the choice is remembered). When checked, pressing Record closes the modal immediately and hands the capture to the Quick Record floating bar, carrying the template, destination override, and context hint you set in the modal, so Obsidian stays fully usable during the recording itself. Switching templates from the floating bar's popover drops the carried destination and context (they belonged to the original template). Only one recording can be live at a time, shared with the Quick Record commands. On mobile the checkbox is hidden: the system suspends background capture, so the modal keeps recording in the foreground there.
## Quick Record floating UI
Quick Record skips the modal entirely. When you start it, a small floating panel appears with:
@ -47,6 +51,10 @@ Quick Record skips the modal entirely. When you start it, a small floating panel
Both Quick Record commands share one in-flight recording, so either one stops a recording the other started. If the active profile is not configured, or audio capture is unavailable, Quick Record opens the main modal instead. If the pipeline errors after capture, the modal opens so you can retry; the saved audio file is your recovery path.
## Real-time transcription floating bar
The **Real-time transcription (start/stop)** command shows its own small floating bar: a pulsing dot, the rolling in-progress phrase, and a **Stop** button. Finalized phrases are typed at your cursor as you speak, like dictation; the in-progress phrase is only previewed on the bar, never inserted until the provider finalizes it. Stopping flushes the last phrase, then closes the connection. This mode is deliberately minimal (no template, no cleanup pass, no saved audio): it is for quick live dictation, not the full ReWrite pipeline.
## Long-form audio
Lectures, meetings, interviews, and podcasts use the same pipeline. Drop the file anywhere in your vault and **Reprocess** it with the **Lecture** or **Podcast** template. For multi-hour recordings, choose a provider with a high ceiling such as AssemblyAI or Rev.ai (OpenAI Whisper and Groq cap at 25 MB; Mistral Voxtral at 30 minutes), and turn on **Identify speakers** to keep speaker labels through cleanup. See per-provider limits in [Providers](Providers). Only process audio you have the right to use.

View file

@ -48,11 +48,12 @@ Files are sorted by filename in the modal and pickers, so prefix names with `01-
| `newFileNameTemplate` | string | Filename pattern for `newFile`, supports `{{date}}`, `{{time}}`, `{{title}}`. |
| `disableSharedCore` | boolean | Set `true` to skip the shared core for this template (opt-out). |
| `enableContextHint` | boolean | Set `true` to show the one-off Context field for this template (opt-in). |
| `diarize` | boolean | Set `true` to force speaker labels on (only effective on diarization-capable providers). |
| `diarize` | boolean | Set `true` to default the modal's "Identify speakers" checkbox ON for this template (only effective on diarization-capable providers; you can still untick it for a single run). |
| `titleFromContent` | boolean | Set `true` to have the LLM name the new file from the content. |
| `noteProperties` | map | Frontmatter properties the LLM fills from the content (key = property name, value = instruction). `newFile` only. |
| `managed` | boolean (three states) | Marks a file as plugin-managed. `true` (written by Populate/Update on built-in-derived files): the Update button may reconcile it. `false`: you have untracked it; Update never touches it. Empty/absent: treated as managed when the `id` matches a built-in (how files from before this flag behave). Ignored entirely on your own templates. |
Note the polarity difference: `disableSharedCore` is an opt-out (set it to turn the shared core OFF for this template), while `enableContextHint`, `diarize`, and `titleFromContent` are opt-ins (set them to turn a feature ON). Populate always writes the four boolean keys as empty stubs so they are discoverable; an empty value means "not set". Obsidian's Properties UI may store an edited boolean as text, so the parser accepts both `true` and the string `"true"`.
Note the polarity difference: `disableSharedCore` is an opt-out (set it to turn the shared core OFF for this template), while `enableContextHint`, `diarize`, and `titleFromContent` are opt-ins (set them to turn a feature ON). Populate always writes the boolean keys as empty stubs so they are discoverable; an empty value means "not set". Obsidian's Properties UI may store an edited boolean as text, so the parser accepts both `true` and the string `"true"` (and, for `managed`, `false`/`"false"`).
## Insert modes
@ -103,6 +104,19 @@ The Templates section has three buttons (see [Settings reference](Settings-Refer
- **Update**: reconciles your default-derived templates with the current built-ins using a per-field 3-way merge. Pristine fields (never edited, matching a current or previously shipped default) are brought forward; your edits are kept. Anything it cannot auto-merge (notably an edited prompt body that diverges from a changed default) is written to `Template update report.md` next to the templates folder for you to review. Re-serializing frontmatter drops any YAML comments you added; the prompt body is left untouched.
- **Load prior versions**: writes earlier shipped versions of the defaults as standalone, separately-named templates so you can compare prompt wording. They never collide with your live templates.
Update only reconciles files it recognizes as plugin-managed (see the next section), and skips any built-in you have disabled in **Manage built-in templates**.
## The managed flag: tracking, untracking, and disabling built-ins
Every file Populate or Update creates carries `managed: true` in its frontmatter. That flag is how Update tells a plugin-managed copy of a built-in apart from a template you wrote yourself; a template of your own is never overwritten by Update even if it happens to share a name (or an `id`) with a built-in, because it does not carry `managed: true`.
Two per-template controls live in settings under **Templates, Manage built-in templates** (an **Enabled** switch and a **Tracked** checkbox on each row):
- **Enabled switch**: turning a built-in off removes its file (behind a confirmation) and remembers the choice, so Populate and Update never resurrect it, including after a plugin update re-ships it. Identity is the frontmatter `id`, so this survives renaming the file. Turn it back on any time for a fresh copy.
- **Tracked checkbox**: unchecking it writes `managed: false` into the file. The template stays on disk and fully usable, but Update never changes it again; you have adopted it as your own. Re-check it to resume updates.
An empty `managed:` key (or none at all, as in files created before this flag existed) on a file whose `id` matches a built-in is treated as tracked, which matches the old behavior; only an explicit `managed: false` opts out.
## Cross-references
- [Providers](Providers) for diarization, context hints, and known nouns, which interact with templates.

View file

@ -14,6 +14,7 @@ ReWrite is an Obsidian plugin that captures speech (live recording or a pasted t
- [Commands and menus](Commands-and-Menus) - the command palette, ribbon, editor and file menus, and Quick Record.
- [Creating templates](Creating-Templates) - the template file format and a full guide to writing your own.
- [Providers](Providers) - choosing and configuring transcription and LLM providers, models, diarization, context hints, and known nouns.
- [Voxtral real-time (disabled)](Voxtral-Realtime) - the reverse-engineered Voxtral streaming protocol, why its browser-auth caveat keeps it disabled, and how to help.
### Self-hosting
- [Self-hosting: whisper.cpp](Self-Hosting-Whisper) - on-device transcription with the plugin-managed local whisper.cpp server.

View file

@ -6,6 +6,8 @@ Obsidian on iOS and Android runs in a constrained WebView, so a few things behav
Mobile WebViews suspend (and stop `MediaRecorder` capture) when the screen sleeps. To counter this, the plugin holds a screen wake lock for the duration of an active recording on both iOS and Android, so screen-off mid-recording is largely mitigated on supported OS versions. It is best-effort: on older WebViews, in an insecure context, or if the OS denies the request, it silently falls back, so keeping the screen on (or using the Paste tab with an OS-level dictation keyboard) is still the safe habit. The trade-off is that the screen stays lit while recording.
The wake lock only prevents the screen from sleeping; it cannot keep the recording alive if you **switch to another app**, which backgrounds and suspends Obsidian. Both recording UIs (the modal Record tab and the Quick Record floating bar) show a yellow caution while recording reminding you to keep Obsidian in the foreground. If you leave, the capture stops and the in-progress recording may be lost.
## Encryption on mobile
If your Obsidian version provides secret storage on mobile, keys use it just like on desktop. Otherwise the plugin prompts you to set a passphrase before any key can be saved, and keys are then encrypted with Argon2id/PBKDF2 AES-GCM. The `secrets.json.nosync` file (which holds encrypted keys only in passphrase mode) uses the `.nosync` suffix so iCloud Drive skips it; for other sync tools, see [Secrets and sync](Secrets-and-Sync).

View file

@ -6,17 +6,17 @@ For fully local setups, see [Self-hosting: whisper.cpp](Self-Hosting-Whisper) an
## Transcription providers
| Provider | API key | Model dropdown | Diarization | Notes |
| --- | --- | --- | --- | --- |
| OpenAI Whisper (`openai`) | Yes | Yes | No | 25 MB upload cap. |
| OpenAI-compatible (`openai-compatible`) | Yes | No (type the id) | No | For self-hosted Whisper-shape servers; see base URL below. |
| Groq (`groq`) | Yes | Yes | No | 25 MB upload cap. |
| AssemblyAI (`assemblyai`) | Yes | No (docs link) | Yes | Large ceiling (5 GB / 10 h). |
| Deepgram (`deepgram`) | Yes | No (docs link) | Yes | 2 GB cap. |
| Rev.ai (`revai`) | Yes | No (docs link) | Yes | 2 GB / 17 h. |
| Mistral Voxtral (`mistral-voxtral`) | Yes | Yes (filtered) | No | Always transcodes to WAV; 30-minute cap. |
| Local whisper.cpp (`whisper-local`) | No | No | No | Desktop only, on-device. See [whisper.cpp](Self-Hosting-Whisper). |
| None (`none`) | n/a | n/a | n/a | Disables recording for text-only use. |
| Provider | API key | Model dropdown | Diarization | Real-time | Notes |
| --- | --- | --- | --- | --- | --- |
| OpenAI Whisper (`openai`) | Yes | Yes | No | No | 25 MB upload cap. |
| OpenAI-compatible (`openai-compatible`) | Yes | No (type the id) | No | No | For self-hosted Whisper-shape servers; see base URL below. |
| Groq (`groq`) | Yes | Yes | No | No | 25 MB upload cap. |
| AssemblyAI (`assemblyai`) | Yes | No (docs link) | Yes | Yes | Large ceiling (5 GB / 10 h). |
| Deepgram (`deepgram`) | Yes | No (docs link) | Yes | Yes | 2 GB cap. |
| Rev.ai (`revai`) | Yes | No (docs link) | Yes | No | 2 GB / 17 h. |
| Mistral Voxtral (`mistral-voxtral`) | Yes | Yes (filtered) | No | No | Always transcodes to WAV; 30-minute cap. (Real-time was tried but is not reachable from a browser; see below.) |
| Local whisper.cpp (`whisper-local`) | No | No | No | No | Desktop only, on-device. See [whisper.cpp](Self-Hosting-Whisper). |
| None (`none`) | n/a | n/a | n/a | n/a | Disables recording for text-only use. |
## LLM providers
@ -70,13 +70,28 @@ The URLs above are international endpoints; China-region accounts can substitute
## Speaker diarization
Diarization adds `Speaker A:` / `Speaker B:` labels and is supported only on **AssemblyAI**, **Deepgram**, and **Rev.ai**. Two switches raise the effective setting:
Diarization adds `Speaker A:` / `Speaker B:` labels and is supported only on **AssemblyAI**, **Deepgram**, and **Rev.ai**. It is chosen **per recording**, not as a saved setting, so notes where speakers do not matter (a daily-note braindump) are not labelled by default:
- The per-profile **Identify speakers** toggle (Settings).
- A per-template `diarize: true` flag (the Meeting transcript default ships with it). The flag only ever raises the setting; it is a documented no-op on providers that cannot diarize.
- The main modal shows an **Identify speakers** checkbox for the current recording, but only when your transcription provider supports diarization. Tick it for a meeting; leave it off for a personal note.
- A template can set `diarize: true` in its frontmatter to make that checkbox **default on** (the Meeting transcript default ships this way). You can still untick it for a single run.
The flag is a documented no-op on providers that cannot diarize. (There is no longer a profile-wide "always diarize" setting.)
The labels survive cleanup because the shared core instructs the LLM to preserve them.
## Real-time transcription
The **Real-time transcription (start/stop)** command (see [Commands and menus](Commands-and-Menus)) streams your mic to the provider over a live connection and types the transcript at your cursor as you speak. **AssemblyAI** and **Deepgram** offer a streaming endpoint the plugin can reach; every other provider only accepts whole-file uploads.
Real-time is configured **entirely on its own** — its own provider, key, and model — in the profile's **Real-time transcription** section, independent of your batch transcription provider. So you can use, say, Voxtral for recorded notes, AssemblyAI for live dictation, and Anthropic for cleanup. Pick a **Real-time provider** (or None to disable) and set its key; the streaming model usually differs from the batch model.
Details worth knowing:
- **Deepgram** uses your configured real-time model when it supports live streaming (the nova family does); its model field is a dropdown you can Refresh, or leave it empty for Deepgram's default. **AssemblyAI** streaming ignores the model field and mints a short-lived (60-second) session token for each start; streaming may require a funded AssemblyAI account.
- **Voxtral real-time is not available.** We reverse-engineered the protocol and built an adapter, but Mistral's realtime endpoint rejects the only authentication a browser WebSocket can send, so it is not reachable from Obsidian and has been pulled from the build. Use AssemblyAI or Deepgram for live dictation. See [Voxtral real-time (beta)](Voxtral-Realtime) for the protocol, the auth caveat, and how to help if you know a working browser-auth path.
- There is no template, no LLM cleanup, and no saved audio in this mode: what you say is what lands, punctuation courtesy of the provider. For cleaned, structured notes, record normally instead.
- Works on desktop and mobile, but keep the app in the foreground: the connection and mic capture stop if the system suspends Obsidian.
## Context hint
A per-run free-text field for one-off background (speakers, setting, subject), for example "Lecture by Dr. Smith on thermodynamics". It is the situational counterpart to the persistent known-nouns list and pairs naturally with diarization (mapping `Speaker X:` labels to real names). It is shown only for templates with `enableContextHint: true`, in the main modal and the reprocess picker. The cleanup step treats it as reference, not instructions.

View file

@ -40,6 +40,8 @@ In ReWrite settings, scroll to **Local whisper.cpp server (desktop)** and fill i
Click **Start**. The status indicator moves Stopped, Starting, Running. **View log** shows whisper-server's output if startup fails. You can also start/stop from the command palette and the desktop status-bar item.
Two optional lifecycle settings automate this: **Start automatically** launches the server when Obsidian opens (when this device's profile uses local whisper.cpp; a server left running from a previous session is adopted, not doubled up), and **Stop when idle** shuts it down after the configured minutes without a transcription, freeing the model's memory. Idle stop never touches a server ReWrite did not start and never interrupts a transcription in progress.
### 5. Use it from a profile
Set the profile's **Transcription provider** to "Local whisper.cpp (desktop only)". The Transcription model field is decorative for this provider; whisper-server uses whichever model file is loaded at startup. No API key is needed. The plugin transcodes recordings to 16 kHz mono WAV before sending them to `/inference`.

View file

@ -20,18 +20,34 @@ Full detail, including which file holds what and how to keep keys off your sync,
## Profiles (Desktop and Mobile)
Each profile is rendered in its own framed section, with the device's active profile highlighted. The fields:
Each profile is rendered in its own framed section, with the device's active profile highlighted. It is split into three headed subsections (**Transcription**, **Real-time transcription**, **Post-processing (LLM)**) that all share the same field order: provider, base URL (where applicable), API key, then model. The fields:
- **Profile label**: a friendly name for the profile.
**Transcription** (batch, whole-file):
- **Transcription provider**: the service that turns audio into text. Choosing a provider reveals the fields it needs. `None` disables the recording paths for this profile (text-only use). See [Providers](Providers) for the full list.
- **Transcription base URL**: shown only for providers that need it (for example `openai-compatible`). See [Providers](Providers) for the base-URL conventions.
- **Transcription API key**: stored encrypted (see API key encryption above). Not shown for providers that need no key (such as local whisper.cpp).
- **Identify speakers**: turns on diarization (`Speaker A:` / `Speaker B:` labels) when the provider supports it (AssemblyAI, Deepgram, Rev.ai). A no-op on other providers. A template can also force this on. See [Providers](Providers).
- **LLM provider**: the model that cleans and structures the transcript. `None` skips cleanup and inserts the raw text.
- **Transcription model**: the adaptive model field described below.
- **Speaker diarization** is not a setting here: it is chosen per recording via an **Identify speakers** checkbox in the main modal (shown only for AssemblyAI / Deepgram / Rev.ai), defaulting to the template's `diarize` flag. See [Providers](Providers).
**Real-time transcription** (live dictation), configured independently of the batch provider so live dictation can use a different service. See [Providers](Providers):
- **Real-time provider**: None, or a streaming-capable provider (AssemblyAI / Deepgram). None disables it. (Voxtral real-time is not offered; see [Voxtral real-time (beta)](Voxtral-Realtime) for why.)
- **Real-time API key**: stored encrypted, separate from the batch key.
- **Real-time model**: the adaptive model field described below (a dropdown with Refresh for Deepgram, a text field for AssemblyAI). Often different from the batch model.
**Post-processing (LLM)**, the model that cleans and structures the transcript:
- **LLM provider**: `None` skips cleanup and inserts the raw text.
- **LLM base URL**: shown for `openai-compatible`. Note the base-URL asymmetry between transcription and LLM described in [Providers](Providers).
- **LLM API key**: stored encrypted.
- **LLM model**: the adaptive model field described below.
- **Maximum note length**: a friendly dropdown that frames the LLM output cap in minutes of speech (5 / 10 / 20 / 30 / 60). Internally this sets the LLM max-tokens value. A custom token value entered in Advanced shows here as a "Custom" option.
- **Transcription model / LLM model**: a single adaptive field. When the provider supports listing models and the cache is populated, it is a dropdown with a Refresh button and a "Custom..." escape hatch for typing an id by hand; otherwise it is a plain text field. Whichever is shown, the value is the model id sent to the provider. See [Providers](Providers) for which providers support the dropdown.
The **model** field in each section is a single adaptive control: when the provider supports listing models and the cache is populated, it is a dropdown with a Refresh button and a "Custom..." escape hatch for typing an id by hand; otherwise it is a plain text field. Whichever is shown, the value is the model id sent to the provider. See [Providers](Providers) for which providers support the dropdown.
- **Advanced** (collapsible):
- **Transcription language**: an optional language hint passed to the transcription provider.
- **LLM max tokens**: the raw output-token cap that "Maximum note length" frames in minutes. Editing it here updates the dropdown on the next render. The cap bounds output (note length), not input.
@ -40,6 +56,11 @@ Each profile is rendered in its own framed section, with the device's active pro
Shown on desktop only. Manages a whisper-server binary you supply for fully on-device transcription. Fields: **Binary path**, **Model path**, **Port** (default 8080), **Extra args**, and a live **Status** row with Start/Stop and a log viewer. The server is always bound to loopback (`127.0.0.1`); it refuses to start if Extra args contains a non-loopback `--host`. Full walkthrough on [Self-hosting: whisper.cpp](Self-Hosting-Whisper).
Two lifecycle options, both off by default:
- **Start automatically**: starts the server when Obsidian opens, if this device's profile uses local whisper.cpp. A server still running from a previous session is adopted rather than doubled up.
- **Stop when idle**: minutes without a transcription before the server is stopped, freeing the model's memory (useful if you load a large model like large-v3 and do not want 1.5 GB resident all day). `0` keeps it running. Only servers started or adopted by ReWrite are stopped, never one you started yourself, and never in the middle of a transcription.
## Templates
- **Templates folder**: where the template Markdown files live (default `ReWrite/Templates`). Changing it reloads templates from the new location.
@ -47,6 +68,7 @@ Shown on desktop only. Manages a whisper-server binary you supply for fully on-d
- **Populate**: creates any missing default templates plus `SharedCore.md`. Non-destructive; skips anything that already exists. (The template format is documented in [Creating templates](Creating-Templates), not a seeded vault file.)
- **Update**: reconciles your default-derived templates against the current built-ins with a per-field 3-way merge, and writes a `Template update report.md` for anything it cannot safely auto-merge. Recreates any default you deleted.
- **Load prior versions**: drops earlier shipped versions of the defaults into the folder as separate, selectable templates so you can compare wording.
- **Manage built-in templates** (collapsible): one row per built-in default with two labelled controls. The **Enabled** switch turns the default off: its file is removed (behind a confirmation) and neither Populate nor Update will ever re-add it, even after plugin updates; turn it back on to get a fresh copy. The **Tracked** checkbox governs updates: unchecking it writes `managed: false` into the file's frontmatter so Update never touches it again (you have adopted it as your own). See [Creating templates](Creating-Templates) for the `managed` flag.
- **Default template**: the template pre-selected when you open the modal.
- **Quick record (set template)**: the template used by the "Quick record (set template)" command (see [Commands and menus](Commands-and-Menus)).
- **On filename collision**: when a `newFile` template would overwrite an existing note, either `auto` (silently append `-1`, `-2`, ...) or `prompt` (ask you for a name).
@ -58,6 +80,17 @@ See [Creating templates](Creating-Templates) for the file format and how Populat
- **Audio format preference**: a hint for which container/codec MediaRecorder should prefer; the plugin falls back to a supported format if the preferred one is unavailable.
- **Attachments folder**: where recorded audio is saved. Leave empty to use Obsidian's own attachment location; set a folder to override it. Saved recordings are linked into the output with an `![[...]]` embed.
The main modal's Record tab also has a **Record in background** checkbox (desktop only, remembered across sessions): when checked, pressing Record closes the modal and hands the capture to the Quick Record floating bar, carrying the template, destination, and context you set up in the modal, so you can keep using Obsidian while recording. See [Commands and menus](Commands-and-Menus).
## Auto-ingest folders
Semi-automatic batch processing for audio captured outside Obsidian (a voice-recorder app, a call-recording export, a scripted dump) and dropped into a vault folder.
- Each **rule** pairs a vault folder with a template; rules are created and edited in a popup (**Add ingest folder**). Only templates that create a new file are offered, so every recording predictably becomes its own note where the template says. Rules can be toggled, edited, or deleted from the list.
- Nothing watches the folder. You run the **Process auto-ingest folders** command when you want a batch processed; that is the whole trigger.
- Files are processed one at a time. On success, the recording is **moved** to your attachments location (the note's audio link is updated automatically), which is also what prevents it from being processed twice. A file that fails stays in the folder and is retried the next time you run the command.
- Only files directly in the folder are scanned, not subfolders. Works on desktop and mobile.
## Ad-hoc instructions
Controls spoken "assistant" instructions extracted from a transcript (say the assistant name followed by a directive, and that directive is injected into the cleanup prompt).

78
wiki/Voxtral-Realtime.md Normal file
View file

@ -0,0 +1,78 @@
# Voxtral real-time transcription (tried, not shipped)
> **Status: disabled.** We reverse-engineered Mistral's **Voxtral** real-time protocol and built a working adapter, but live testing confirmed it cannot authenticate from Obsidian's WebView (see [The open problem: auth](#the-open-problem-browser-websocket-auth)). The endpoint rejects the only credential a browser `WebSocket` can present, so **Voxtral real-time is not selectable in ReWrite**. The adapter (`src/realtime/voxtral.ts`) is kept in the repo, unwired, so a contributor who finds a working browser-auth path can re-enable it by wiring it back into `createRealtimeProvider` and `transcriptionProviderSupportsRealtime`. For live dictation today, use **AssemblyAI** or **Deepgram** (see [Providers](Providers) and [Commands and menus](Commands-and-Menus)).
This page documents everything we learned, because the groundwork is real and only one piece is missing. If you know how Mistral expects a **browser** client to authenticate the realtime WebSocket, please open an issue or PR.
## What's built (but unwired)
- The full realtime **message protocol** is implemented and matches Mistral's SDK.
- Audio capture, chunking, interim/final handling, and session teardown are done.
- The independent **real-time key and model** plumbing works and is used by the shipped AssemblyAI/Deepgram paths; only the Voxtral provider itself is removed from the selectable list. Its realtime model would default to `voxtral-mini-transcribe-realtime-2602`.
## What blocked it
- **Authentication from a browser/Obsidian WebSocket.** Mistral's official SDK authenticates with an HTTP `Authorization: Bearer` header, which a browser `WebSocket` cannot set. Mistral documents no browser-usable alternative (query param, subprotocol, or a short-lived token endpoint). ReWrite tried the WebSocket subprotocol (the same trick Deepgram accepts); Mistral's server rejects the handshake, so the connection never opens. Until that is solved, the provider stays disabled.
## The protocol (reverse-engineered)
Reverse-engineered from the open-source `mistralai` Python SDK (`src/mistralai/extra/realtime/transcription.py` and `connection.py`). If Mistral publishes an official raw-WebSocket reference, prefer it over this.
**Endpoint**
```
wss://api.mistral.ai/v1/audio/transcriptions/realtime?model=voxtral-mini-transcribe-realtime-2602
```
**Audio format:** signed 16-bit little-endian PCM (`pcm_s16le`), 16 kHz, mono.
**Client → server** (JSON text frames):
| Message | Shape |
| --- | --- |
| Configure the session (send first) | `{"type":"session.update","session":{"audio_format":{"encoding":"pcm_s16le","sample_rate":16000}}}` |
| Send audio | `{"type":"input_audio.append","audio":"<base64 PCM16>"}` — max **262144 decoded bytes** per message |
| Flush pending audio | `{"type":"input_audio.flush"}` |
| End the stream | `{"type":"input_audio.end"}` |
**Server → client** (JSON):
| `type` | Meaning |
| --- | --- |
| `session.created` / `session.updated` | Handshake / config acknowledged |
| `transcription.text.delta` | `{text}` — incremental interim text (accumulate for display) |
| `transcription.done` | `{model, text}` — final transcript |
| `transcription.segment`, `transcription.language` | Segment / detected-language events |
| `error` | `{error:{message}}` |
**Notes**
- **Realtime is not compatible with diarization** (per Mistral). This fits ReWrite's model: diarization is a per-recording choice on batch transcription, and a realtime session never requests it.
- ReWrite accumulates `transcription.text.delta` events for the on-screen interim line and inserts on `transcription.done`.
## The open problem: browser WebSocket auth
This is the crux, and where community help is most valuable.
- **Why it's hard:** Browser (and Obsidian/Electron/Capacitor renderer) `WebSocket` objects cannot set arbitrary request headers, so the SDK's `Authorization: Bearer <key>` handshake is not reproducible from a plugin.
- **How other providers solve it:** Deepgram accepts the key via the `Sec-WebSocket-Protocol` subprotocol (`['token', <key>]`); AssemblyAI mints a short-lived single-use token over a normal HTTPS request and puts only that token in the WS URL query.
- **What ReWrite currently attempts:** the Deepgram-style subprotocol (`['token', <key>]`), as a best-effort guess. This is **unverified against a live Voxtral endpoint**. If it is rejected, the socket closes during the handshake and you'll see a connection error.
- **What would resolve it:** confirmation of any of — (a) a subprotocol Mistral accepts, (b) a query-param the endpoint honors (note: ReWrite's policy avoids putting long-lived keys in URLs, so a short-lived token would be preferred), or (c) a token-minting endpoint like AssemblyAI's. If you have a working browser/JS example, that answers it directly.
## How to try it (contributors only)
Because the handshake fails, **Voxtral is not in the Real-time provider dropdown** in a shipped build. To exercise the adapter you have to re-wire it in a dev build first:
1. In `src/realtime/index.ts`, add `mistral-voxtral` back to `transcriptionProviderSupportsRealtime` and add its `case` to `createRealtimeProvider` (importing `createVoxtralRealtime` from `./voxtral`).
2. Build and reload. In the profile's **Real-time transcription** section, **Mistral Voxtral** will now appear; select it, set the **Real-time API key** (your Mistral key) and optionally the **Real-time model** (defaults to `voxtral-mini-transcribe-realtime-2602`).
3. Open a Markdown note and run **Real-time transcription (start/stop)**, then speak. If the floating bar shows interim text and words land at your cursor, you found the missing auth piece: please open a PR. If the socket closes during the handshake, that is the failure we hit.
Meanwhile, use **AssemblyAI** or **Deepgram** for live dictation (both are verified). Voxtral still works great for **batch** transcription (record or reprocess a file), which is unaffected.
## For contributors
- The adapter is `src/realtime/voxtral.ts` (kept on disk, unwired); the auth attempt and the caveat are documented inline.
- The realtime interfaces (`RealtimeProvider` / `RealtimeSession`) and shared WS helpers are in `src/realtime/index.ts` (this is also where you re-enable the provider).
- The developer-facing summary lives in [CLAUDE.md](https://github.com/WiseGuru/ReWrite-Voice-Notes/blob/master/CLAUDE.md) under "Real-time transcription".
[Back to Home](Home)

View file

@ -10,6 +10,7 @@
- [Commands and menus](Commands-and-Menus)
- [Creating templates](Creating-Templates)
- [Providers](Providers)
- [Voxtral real-time (disabled)](Voxtral-Realtime)
**Self-hosting**
- [whisper.cpp](Self-Hosting-Whisper)