mirror of
https://github.com/wiseguru/ReWrite-Voice-Notes.git
synced 2026-07-22 07:49:19 +00:00
Claude optimization
This commit is contained in:
parent
8f9fb09be7
commit
8ec1c93cdf
4 changed files with 145 additions and 73 deletions
137
CLAUDE.md
137
CLAUDE.md
|
|
@ -14,6 +14,8 @@ When extending the plugin, follow the file layout the spec prescribes: provider
|
|||
|
||||
Update CLAUDE.md with every behavioral change. When modifying code that this document describes (pipeline stages, command IDs, settings keys, gotchas, conventions), update CLAUDE.md in the same change. If a behavioral change has no existing section, add one or drop a note under "Gotchas". Treat the doc update as part of the task, not a follow-up.
|
||||
|
||||
Some subsystems are documented in depth in linked `docs/` files (currently [docs/DIARIZATION.md](docs/DIARIZATION.md), [docs/SECRETS.md](docs/SECRETS.md), [docs/WHISPER_HOST.md](docs/WHISPER_HOST.md)) with only a summary + pointer left here. The same rule applies: when you change behavior covered by one of those files, update the linked doc in the same change AND keep the CLAUDE.md summary accurate. Each linked doc restates this at its top.
|
||||
|
||||
There is a second, user-facing doc to keep in sync: the **template guide** (`DEFAULT_TEMPLATE_GUIDE` in [src/template-guide.ts](src/template-guide.ts)), seeded into the vault by Populate. Any change to the template structure — a new/changed `NoteTemplate` field or frontmatter property, the prompt-assembly order, the default-template set or count, or what the shared core carries — must update the guide in the same change: add the property to its frontmatter table AND give the behavior its own `##` section. See the Templates section for the full rule.
|
||||
|
||||
## Commands
|
||||
|
|
@ -114,25 +116,15 @@ Providers may optionally implement `listModels(config, signal)` returning a stri
|
|||
|
||||
## Speaker diarization
|
||||
|
||||
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 FEATURES.md item 4; 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, 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.
|
||||
|
||||
**Per-template override.** 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).
|
||||
|
||||
Per-adapter behavior: [src/transcription/assemblyai.ts](src/transcription/assemblyai.ts) sets `speaker_labels: true` and formats the returned `utterances[]` (`Speaker A: ...`, native letter labels); [src/transcription/deepgram.ts](src/transcription/deepgram.ts) adds `diarize=true` and groups per-word `speaker` indices via `formatDiarizedWords` (0-based bumped to `Speaker 1`); [src/transcription/revai.ts](src/transcription/revai.ts) fetches the JSON transcript (`Accept: application/vnd.rev.transcript.v1.0+json`) instead of `text/plain` and rebuilds labels from `monologues[]` via `formatMonologues` (0-based bumped to `Speaker 1`). Each adapter falls back to its flat-text path when the labeled payload is missing, so toggling off is a clean no-op. Label survival through cleanup is handled by a clause in `DEFAULT_SHARED_CORE` ([src/shared-core.ts](src/shared-core.ts)) telling the LLM to preserve `Speaker X:` prefixes; the Podcast default template already tolerates labeled and unlabeled input.
|
||||
Full detail (per-adapter formatting, pipeline merge rules, label survival) lives in [docs/DIARIZATION.md](docs/DIARIZATION.md).
|
||||
|
||||
## Local whisper.cpp host (desktop)
|
||||
|
||||
[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.
|
||||
`WhisperHost` ([src/whisper-host.ts](src/whisper-host.ts), instantiated in `onload`, stopped in `onunload`) manages a user-supplied whisper-server child process. Config lives at `GlobalSettings.localWhisper = { binaryPath, modelPath, port, extraArgs }`: no auto-discovery, no auto-download, no baked defaults. Desktop-only (Node modules lazy-required behind `Platform.isDesktop`; the option is filtered out of mobile dropdowns). **Loopback binding is enforced** (a non-loopback `--host` throws before spawn) because whisper-server has no auth/TLS. The `whisper-local` transcription provider ([src/transcription/whisper-local.ts](src/transcription/whisper-local.ts)) is a thin shim POSTing transcoded 16 kHz mono WAV to `http://127.0.0.1:<port>/inference`; no API key.
|
||||
|
||||
The companion CLI installer is [scripts/build-whisper-linux.sh](scripts/build-whisper-linux.sh): it clones/builds whisper.cpp into `~/.local/share/whisper.cpp` (override `--source-dir`), symlinks the binary to `~/.local/bin/whisper-server` (override `--prefix`, skip with `--no-symlink`), and prints the final Binary path to paste into settings. Keep the Auto-detect candidate list in sync with this script's default paths.
|
||||
|
||||
`start()` validates the binary and model paths exist via `fs.existsSync`, probes the port via `net.createServer().listen(port)` to detect conflicts, spawns the user's whisper-server with `child_process.spawn`, captures stdout/stderr into a 1 MB ring buffer, then polls `net.createConnection` against the port every 250 ms for up to 5 s before declaring `'running'`. Any failure transitions status to `'crashed'` with the log tail surfaced in the error message. `stop()` sends SIGTERM, waits up to 3 s, then SIGKILL.
|
||||
|
||||
**Loopback binding is enforced.** whisper-server has no auth/TLS, so `start()` pins `--host 127.0.0.1` when the user did not supply a `--host` in `extraArgs` (so we don't depend on upstream's default binding), and throws before spawning if a user-supplied `--host` is non-loopback (`getHostArg` + `isLoopbackHost` in [src/whisper-host.ts](src/whisper-host.ts); loopback = `127.0.0.1` / `localhost` / `::1`). This prevents a stray `--host 0.0.0.0` from silently exposing an unauthenticated transcription server to the LAN. The README's "Network exposure" disclosure and the Extra args field description both state this.
|
||||
|
||||
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).
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
## Settings
|
||||
|
||||
|
|
@ -146,37 +138,14 @@ Saving flow strips secrets out of `data.json` and writes them to `secrets.json.n
|
|||
|
||||
## Secrets encryption
|
||||
|
||||
[src/secrets.ts](src/secrets.ts) supports two encryption modes for API keys, file-wide (not per-key). There is no unencrypted at-rest option.
|
||||
[src/secrets.ts](src/secrets.ts) encrypts API keys file-wide in one of two modes (no unencrypted at-rest option):
|
||||
|
||||
- **`secretStorage`** — Obsidian's first-party `app.secretStorage` (GA 1.11.4): a shared, cross-plugin, OS-encrypted store (Keychain/DPAPI/libsecret under the hood, the same backend Electron `safeStorage` used). **Default/preferred when available.** Values live in Obsidian's store, NOT in `secrets.json.nosync` (the on-disk envelope in this mode is just `{ version, mode: 'secretStorage', keys: {} }` to record the mode). Keys are namespaced with `manifest.id` (`rewrite-voice-notes:profile:desktop:transcription`) because the store is shared across plugins. The API is reached via a narrow `SecretStorageLike` interface + cast (the installed typings predate it) that normalizes `getSecret`/`get`, `setSecret`/`set`, `listSecrets`/`list`, and `deleteSecret`/`removeSecret`/`delete` (falling back to `setSecret(id, '')` when no delete exists); each method may be sync or async, so callers `await`. **Availability** is a round-trip self-test (`setSecret` a sentinel, `getSecret`, compare, clean up) cached for the session — a device with no working OS secret store (e.g. Linux without a keyring) reads unavailable and falls back to passphrase. `warmSecretStorage(plugin)` runs the probe once in `onload` BEFORE `getEncryptionStatus`, so the synchronous `defaultEnvelope()` can prefer `secretStorage` on first run. **Sync caveat:** because the store can sync across devices via Obsidian Sync, keys in this mode are not bound to one device (unlike the `.nosync` file); this is surfaced in the settings mode description.
|
||||
- **`passphrase`** — WebCrypto AES-GCM-256 with a key derived from a user-supplied passphrase. The KDF is **Argon2id** by default (via `hash-wasm`, `m = 32 MiB`, `t = 3`, `p = 1`, 32-byte output), with **PBKDF2-SHA256** (600,000 iterations) as the fallback when a device can't run Argon2id. The chosen algorithm + params live in the envelope `kdf` (see below). Each value is stored as `<iv-b64>.<ct-b64>` (12-byte random IV per value) in `secrets.json.nosync`. A `verifier` field stores an encryption of `VERIFIER_PLAINTEXT` so unlock can validate the passphrase without decrypting user keys. Works on every platform including mobile and Linux-without-keyring; the always-available fallback when `secretStorage` is not. **Entropy gate:** `changeEncryptionMode`/`changePassphrase` reject a passphrase scoring below `MIN_PASSPHRASE_SCORE` (3 of 4) via [src/passphrase-strength.ts](src/passphrase-strength.ts) (zxcvbn-ts wrapper); this is the hard, non-bypassable gate. The passphrase modal ([src/ui/passphrase-modal.ts](src/ui/passphrase-modal.ts)) surfaces a live strength meter and a **Generate** button (6-word EFF-wordlist diceware via [src/diceware.ts](src/diceware.ts) + [src/eff-large-wordlist.ts](src/eff-large-wordlist.ts)) on create/change flows (`enforceStrength: true`). **zxcvbn-ts is lazy-loaded:** its dictionaries are ~1.6 MB / ~140 ms to build, so [src/passphrase-strength.ts](src/passphrase-strength.ts) pulls the packages via dynamic `import()` (not a static top-level import), keeping them out of plugin-startup evaluation; `evaluatePassphrase`/`isPassphraseAcceptable` are therefore **async**, and the modal calls `warmPassphraseStrength()` on open so the first keystroke does not pay the build cost. The bytes stay in `main.js` (esbuild has no code-splitting here); only the parse/construct work is deferred.
|
||||
- **`secretStorage`** (default/preferred when available) — Obsidian's first-party `app.secretStorage`, an OS-encrypted shared store. Reached via a narrow cast + round-trip self-test, falls back to passphrase when unavailable (old Obsidian, Linux without a keyring).
|
||||
- **`passphrase`** (always-available fallback) — WebCrypto AES-GCM-256 keyed by a user passphrase, KDF Argon2id (PBKDF2 fallback), stored in `secrets.json.nosync`. Entropy-gated at `MIN_PASSPHRASE_SCORE` (3/4). Locks/unlocks; the derived key never touches disk.
|
||||
|
||||
History note: an earlier hand-rolled `safeStorage` mode (direct Electron keychain) was REMOVED and superseded by `secretStorage`, which uses the same OS backend but is Obsidian-managed and cross-platform. No users had it (unpublished), so it was dropped outright with no migration; a stale `safeStorage` envelope parses as invalid and resets to default.
|
||||
`ReWritePlugin.encryptionStatus` (`{ mode, locked, configured, secretStorageAvailable }`, refreshed via `refreshEncryptionStatus()`) is read synchronously by UI; pipeline entry points gate on `locked` and call `promptUnlock()`. `minAppVersion` is `1.4.4` (driven by `processFrontMatter`, not `secretStorage`, which is feature-detected).
|
||||
|
||||
`minAppVersion` is `1.4.4`, raised from `1.4.0` because `FileManager.processFrontMatter` (`@since 1.4.4`, used directly in [src/insert.ts](src/insert.ts) for note-property frontmatter) is NOT feature-detected. `secretStorage` (the other newer API) IS feature-detected at runtime, so it does not drive the minimum; older-than-secretStorage Obsidian still loads the plugin and simply lands on passphrase.
|
||||
|
||||
File envelope (`SECRETS_VERSION = 2`). For passphrase mode, the `kdf.algo` discriminant distinguishes PBKDF2 from Argon2id; a legacy envelope with no `algo` but an `iterations` field is read as `pbkdf2`:
|
||||
```json
|
||||
{ "version": 2, "mode": "passphrase",
|
||||
"kdf": { "algo": "argon2id", "salt": "<b64>", "memKiB": 32768, "timeCost": 3, "parallelism": 1 },
|
||||
"verifier": "<iv-b64>.<ct-b64>",
|
||||
"keys": { "profile:desktop:transcription": "<iv-b64>.<ct-b64>", ... } }
|
||||
```
|
||||
|
||||
The derived AES-GCM key for passphrase mode lives in module-level state (`unlockedKey`); it never touches disk. `lockSecrets()` forgets it. `unlockSecrets(plugin, passphrase)` derives a candidate key via `deriveKeyFromKdf` (dispatching on `kdf.algo`) and decrypts the `verifier` to check correctness before caching. An Argon2id allocation failure at unlock throws a clear "this device can't allocate enough memory" error (the envelope was created on a device with more RAM). **Opportunistic migration:** on a successful unlock of a `pbkdf2` envelope, if the device can run Argon2id the keys are silently re-encrypted to `argon2id` (best-effort; failures leave it on PBKDF2). This is a deliberate exception to the pre-release no-migration rule, kept so existing passphrase users aren't forced to re-enter keys. `secretStorage` mode never locks and has no KDF/verifier.
|
||||
|
||||
`ReWritePlugin.encryptionStatus` (a snapshot of `{ mode, locked, configured, secretStorageAvailable }`) is loaded on `onload` and refreshed via `plugin.refreshEncryptionStatus()` after every mode change / unlock. UI code reads this synchronously. `locked` and `configured` only vary in passphrase mode (`secretStorage` is always unlocked + configured); `configured` is `false` for a passphrase envelope with no `kdf`/`verifier` yet (first run on a device without secret storage); `secretStorageAvailable` is the cached probe result, used to offer the mode and to steer the user when it is missing.
|
||||
|
||||
When `mode === 'passphrase'` and not yet unlocked (`encryptionStatus.locked === true`, which includes the unconfigured first-run state):
|
||||
- `loadAllKeys` / `loadKey` return empty strings (no error).
|
||||
- `saveManyKeys` is a no-op (so calls to `saveSettings()` from unrelated UI changes do not clobber the on-disk encrypted values with empties).
|
||||
- `saveKey` throws.
|
||||
- All entry points ([src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), [src/ui/text-source.ts](src/ui/text-source.ts), [src/ui/audio-source.ts](src/ui/audio-source.ts)) check `plugin.encryptionStatus.locked` and call `plugin.promptUnlock()` instead of proceeding. `promptUnlock` branches on `configured`: a configured envelope opens the unlock modal; an unconfigured one opens a **create-passphrase** modal (`requireConfirm` + `enforceStrength`) that calls `changeEncryptionMode(this, 'passphrase', pass)`. The entry points need no change since unconfigured reports `locked === true`.
|
||||
- The settings tab disables the API key input fields and shows a banner: "Unlock" (configured+locked) or "Set passphrase" (unconfigured).
|
||||
|
||||
`changeEncryptionMode(plugin, newMode, newPassphrase?)` reads all keys to plaintext from the current mode (via `readAllPlain`, which lists `app.secretStorage` for `secretStorage` or decrypts the envelope for `passphrase`), switches, and re-stores them. Requires the current mode to be unlocked (if passphrase AND already configured). Switching TO `secretStorage` requires the probe to pass, then `setSecret`s each value and writes a minimal envelope. Switching TO `passphrase` requires `newPassphrase` (entropy-gated, new envelope built by `buildPassphraseKdfAndKey`) and, when coming FROM `secretStorage`, clears our namespaced entries from the shared store (`clearSecretStorage`) so keys don't linger/sync. `changePassphrase(plugin, newPassphrase)` is a thin wrapper that calls `changeEncryptionMode(plugin, 'passphrase', newPassphrase)`.
|
||||
|
||||
`resetSecrets(plugin, newPassphrase)` is the **forgot-passphrase recovery** path. Unlike `changePassphrase`, it does NOT require unlocking first: it drops `unlockedKey` / `cachedEnvelope` and writes a fresh, **empty** passphrase envelope under the new passphrase (the old keys are unrecoverable without the old passphrase, so they are discarded by design). The new passphrase must pass the same entropy gate. It is exposed in the settings tab **only in the locked state** (`renderEncryption`'s `status.locked` banner branch shows a `mod-warning` "Forgot passphrase? Reset" button beside Unlock; `openResetModal` runs the flow, then `hydrateSecrets` to empty the in-memory keys, `refreshEncryptionStatus`, and `notifySecretsUnlocked`). An unlocked user uses **Change passphrase** instead, so there is no reset row there. The reset modal is a `PassphraseModal` with `requirePhrase: 'DELETE APIS'` (a destructive-confirmation gate: `submit()` blocks until a plain-text field exactly matches the phrase) plus the usual `requireConfirm` + `enforceStrength`.
|
||||
Full detail (envelope schema, KDF params, lock/unlock flow, opportunistic PBKDF2→Argon2id migration, `changeEncryptionMode` / `changePassphrase` / `resetSecrets` recovery, locked-state behavior, and the secrets gotchas) lives in [docs/SECRETS.md](docs/SECRETS.md).
|
||||
|
||||
## Templates
|
||||
|
||||
|
|
@ -289,52 +258,74 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
|
|||
|
||||
## Gotchas
|
||||
|
||||
### HTTP and provider requests
|
||||
|
||||
- **`requestUrl` multipart bodies are hand-built.** `requestUrl` does not accept `FormData`. [src/http.ts](src/http.ts) exports `buildMultipart(parts)` which produces a `Uint8Array` with a random boundary; transcription adapters (Whisper, Rev.ai) call into it. If you add a multipart-LLM provider, reuse this rather than reaching for `FormData`.
|
||||
- **`requestUrl` uses `throw: false` + status check.** All adapters surface non-2xx as `ProviderError` with `status` and `body`, so users see provider-attributed errors instead of opaque network failures.
|
||||
- **Provider auth always goes in a header, never the URL query.** Every adapter passes its key via a header (`Authorization: Bearer`, `x-api-key`, `Token`, and `x-goog-api-key` for Gemini). Do not add a `?key=`/`?token=` query-auth provider: query strings leak into proxy/CDN logs, history, and `requestUrl`'s network-failure message. As a backstop, the network-failure `catch` in [src/http.ts](src/http.ts) `providerRequest` runs `redactQueryStrings` over the message (replaces any `?...` with `?<redacted>`) before building the `ProviderError`, so even a future query-auth slip cannot surface a secret in a Notice or log. The `body.slice(0, 200)` truncation in the `ProviderError` constructor is unrelated and left as-is (response bodies do not carry the request key).
|
||||
- **`app.secretStorage` is reached via a narrow cast + round-trip self-test, never assumed present.** [src/secrets.ts](src/secrets.ts) `getSecretStorage(plugin)` casts `plugin.app` to a local `RawSecretStorage` shape and normalizes the method aliases; `probeSecretStorage` write/read/compares a sentinel before trusting it. Importing nothing Electron-specific keeps it mobile-safe. Old Obsidian (no API) and a broken OS store (Linux without a keyring) both read unavailable and fall back to passphrase. The probe result is module-cached and warmed in `onload` via `warmSecretStorage` so the synchronous `defaultEnvelope()` can prefer it on first run. Keys are namespaced with `manifest.id`; never store under a bare id (the store is shared across plugins).
|
||||
- **`saveManyKeys` is a silent no-op when locked.** When `mode === 'passphrase'` and `unlockedKey === null`, `saveManyKeys` does nothing. This is deliberate: unrelated `plugin.saveSettings()` calls (e.g. user changes a model dropdown) would otherwise persist empty `apiKey` values for every profile, wiping the on-disk encrypted bag. The UI prevents this by disabling key fields and gating all pipeline entry points on `encryptionStatus.locked`. `saveKey` (single-key write) still throws so callers can react.
|
||||
- **Keep zxcvbn lazy and the strength API async.** [src/passphrase-strength.ts](src/passphrase-strength.ts) pulls `@zxcvbn-ts/*` via dynamic `import()` specifically so the ~1.6 MB of dictionaries are not parsed/constructed at plugin load (measured: ~9 ms startup vs ~47 ms with a static import). Do not "simplify" it back to a top-level static import or a synchronous `evaluatePassphrase` — that re-adds the cost to every Obsidian launch on every device. The async ripples (modal `updateStrength` race guard, `await isPassphraseAcceptable` in `changeEncryptionMode`) are intentional. `warmPassphraseStrength()` is fired on passphrase-modal open to hide the one-time build behind the modal animation.
|
||||
|
||||
### Pipeline and provider system
|
||||
|
||||
- **Both provider unions include `'none'`.** [src/types.ts](src/types.ts) `TranscriptionProviderID` and `LLMProviderID` carry a `'none'` member for users who only want one half of the pipeline. The factories in [src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) return sentinel providers (transcription throws on `transcribe()`; LLM `complete()` returns the user message unchanged), but the pipeline never actually calls these because: (a) `collectTranscript` throws a friendlier error when `transcriptionProvider === 'none'` and `source.kind === 'audio'`; (b) `cleanupTranscript` short-circuits and returns the raw transcript when `llmProvider === 'none'` (this also skips wake-name extraction and known-nouns injection, since both only matter when an LLM consumes the system prompt). The settings tab + setup card hide model/baseUrl/apiKey fields for the `'none'` side; `isProfileConfigured` / `isProfileConfiguredForText` treat `'none'` as configured. The modal's Record tab, Quick Record, and the reprocess-audio command all gate on `transcriptionProvider === 'none'` with a "use Paste instead" hint.
|
||||
- **PipelineHost decouples the pipeline from `ReWritePlugin`.** [src/pipeline.ts](src/pipeline.ts) reads `params.host.assistantPrompt` and `params.host.knownNouns` through the narrow `PipelineHost` interface in [src/types.ts](src/types.ts). The plugin class `implements PipelineHost`, but the pipeline never imports `ReWritePlugin` directly, which would create a cycle through the UI layer. New cross-cutting cleanup-stage inputs should extend `PipelineHost` rather than reach for the plugin object.
|
||||
- **Per-provider recording limits live in [src/transcription/limits.ts](src/transcription/limits.ts), not the recorder.** [src/recorder.ts](src/recorder.ts) does not cap recordings at any size; `validateRecording(blobSize, durationMs, providerId)` runs in [src/pipeline.ts](src/pipeline.ts) between the `persist-audio` and `transcribe` stages, throwing a friendly provider-attributed error if the recording exceeds the documented byte or duration ceiling. Both modal and Quick Record thread the recorder's `durationMs` onto the `audio` pipeline source so the duration check has data; the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) omits `durationMs` (no cheap way to measure an arbitrary vault file), so reprocess only triggers the byte check. Limits source: `openai`/`groq` 25 MB, `assemblyai` 5 GB/10 h, `deepgram` 2 GB, `revai` 2 GB/17 h, `mistral-voxtral` 1 GB/30 min, `openai-compatible`/`whisper-local`/`webspeech` no client-side cap.
|
||||
- **Async poll timeout is duration-aware**, also in [src/transcription/limits.ts](src/transcription/limits.ts) via `pollTimeoutMs(durationMs?)`. The two polling adapters ([src/transcription/assemblyai.ts](src/transcription/assemblyai.ts), [src/transcription/revai.ts](src/transcription/revai.ts)) used to share a flat `POLL_TIMEOUT_MS = 60_000`, which made any recording the server took longer than a minute to process fail before it finished. They now derive the timeout from the recorded length: `min(60s + durationMs * 2, 2 h)`, so a short clip with a problem fails in ~1 min while a long job has room. `durationMs` is threaded as the optional 4th arg of `TranscriptionProvider.transcribe(audio, config, signal?, durationMs?)` from [src/pipeline.ts](src/pipeline.ts) (`source.durationMs`); the non-polling adapters ignore it (their impls simply omit the param). It is `undefined` for the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts), no cheap way to measure a vault file), which then falls back to the 2 h ceiling.
|
||||
|
||||
### LLM and token limits
|
||||
|
||||
- **`maxTokens` has two settings-tab views over one value.** `LLMConfig.maxTokens` (per profile, default `2560`) is the single source of truth. The normal-area "Maximum note length" dropdown (`renderNoteLength` in [src/settings/tab.ts](src/settings/tab.ts)) frames it in minutes via `NOTE_LENGTH_PRESETS` at `TOKENS_PER_MIN = 256` (≈150 wpm × ~1.3 tokens/word, padded ~20% for headings/bullets/`Speaker X:` labels; ~10 min → 2560). The Advanced "LLM max tokens" text field edits the same number raw; a value not matching a preset surfaces in the dropdown as a "Custom (N tokens, ~M min)" option. The dropdown calls `this.display()` on change so Advanced reflects the new number; the raw text field does not redraw (focus preservation), so the dropdown updates on the next full render. The Anthropic adapter's `config.maxTokens > 0 ? ... : 2560` fallback ([src/llm/anthropic.ts](src/llm/anthropic.ts)) must stay in sync with the default. The cap is on **output** tokens, so it bounds note length, not input.
|
||||
- **`maxTokens` over a model's output cap is remapped to a friendly error.** When the requested output cap exceeds a model's max output tokens, Anthropic and OpenAI return a cryptic HTTP 400. `remapOutputLimitError` ([src/llm/index.ts](src/llm/index.ts)), applied as a `.catch()` on the `complete()` POST in [src/llm/anthropic.ts](src/llm/anthropic.ts) and [src/llm/openai.ts](src/llm/openai.ts), detects that specific 400 (body names `max_tokens`/`max_completion_tokens` + a "maximum/too large/at most/exceed" phrase) and rethrows a `ProviderError` pointing at the "Maximum note length" setting; all other errors pass through unchanged. Its `never` return preserves the awaited type. Gemini silently clamps `maxOutputTokens` instead of erroring, so it gets no remap (and can therefore truncate without warning on long notes).
|
||||
- **OpenAI reasoning models need `max_completion_tokens`, not `max_tokens`.** [src/llm/openai.ts](src/llm/openai.ts) `usesCompletionTokens(id, model)` switches the param name to `max_completion_tokens` when `id === 'openai'` and the model matches `/^(o\d|gpt-5)/i` (o1/o3/o4 + gpt-5 families), which reject the legacy `max_tokens`. Scoped to the first-party `openai` id only; `openai-compatible` and `mistral` keep `max_tokens`, so a reasoning model proxied behind an openai-compatible endpoint is a known gap. This only fixes the token param; o1-mini/o1-preview also reject `system` messages, which is not handled.
|
||||
- **No baked-in model defaults.** Both profiles ship with `model: ""`. The modal renders an inline setup card that blocks recording/paste until the active profile has a provider, model, key, and (for `openai-compatible`) base URL. If you add a provider, do not bake a default model string; surface it as placeholder hint text.
|
||||
- **`openai-compatible` base URL asymmetry** (literal interpretation of the spec): transcription appends `/v1/audio/transcriptions` to a *root* URL (`http://localhost:8080`); LLM appends `/chat/completions` to a URL that *already includes* `/v1` (`http://localhost:11434/v1`). The settings UI hint text and setup card both guide users; do not "normalize" one to match the other. The `openai-compatible` LLM option is also the supported route for cloud OpenAI-compatible services (DeepSeek, Kimi/Moonshot, Qwen/DashScope, Zhipu GLM) — there are no first-class provider entries for them; the README's "Cloud OpenAI-compatible LLMs" section carries the per-provider base URLs. The LLM option label is "OpenAI-compatible (cloud or local)" to reflect this.
|
||||
|
||||
### Templates, properties, and insertion
|
||||
|
||||
- **Templates are vault files, not settings.** There is no `settings.templates` array. Consumers read `plugin.templates` (refreshed from disk). When you add a field to `NoteTemplate`, update [src/templates-folder.ts](src/templates-folder.ts) on both sides: `parseTemplateFile` reads it out of frontmatter (with a sensible default if missing), and `renderTemplateFile` writes it into the frontmatter the populate button emits. The populate button is non-destructive: it skips files whose `id` already exists, so re-running it tops up the folder without clobbering user edits.
|
||||
- **The two template frontmatter flags have opposite polarity.** `disableSharedCore` is a negative opt-out (set it to turn a default OFF); `enableContextHint` is a positive opt-in (set it to turn a feature ON). Don't "harmonize" them. `enableContextHint` only gates whether the modal / reprocess-picker shows the Context field; the pipeline injects a `## Context` block on any non-empty `PipelineParams.contextHint` without consulting the flag (see Context hint section).
|
||||
- **Note-property extraction must run before the audio-embed prepend, and frontmatter is written newFile-only.** [src/pipeline.ts](src/pipeline.ts) `extractFromBlock` strips the leading ```` ```yaml ```` block off the LLM output inside `cleanupTranscript`, BEFORE `runPipeline` prepends `![[<path>]]\n\n`; prepending first would push the YAML off byte 0 and it would never parse as frontmatter. The parsed values are written via `app.fileManager.processFrontMatter` in `insertNewFile` only (after `vault.create`, before `openLinkText`) so the real `---...---` lands above the embed; `cursor` / `append` ignore `InsertParams.properties`. A present block is always stripped from the body (even when malformed YAML triggers the tolerant line-based fallback); a missing block leaves the whole output as the body. See Note properties.
|
||||
- **`noteTitle` is filename-only — never a frontmatter property.** The reserved `noteTitle` key (`titleFromContent`) shares the one leading ```` ```yaml ```` block with `noteProperties`, but `extractFromBlock` deliberately keeps it OUT of the property `allowed` set and returns it on `CleanupResult.title`. Do NOT "fix" the extractor to also write it into `properties`/frontmatter; it exists to name the file (`{{title}}` token or whole-name replacement in `insertNewFile`). It is the model's own generated string, independent of any `title` noteProperty (e.g. Book log's `title` property and its filename title are separate axes and need not match). Caveat: `resolveNewFilePath` collision detection is case-sensitive (`getAbstractFileByPath`), so on case-insensitive filesystems two titles differing only by case can still collide at `vault.create`; pre-existing, just likelier with content-derived names. See Note title.
|
||||
- **Frontmatter parsing uses `parseYaml` from Obsidian, not the metadata cache.** The metadata cache is async and may not be populated for newly created files; reading content via `app.vault.read(file)`, splitting off the leading `---...---` block, and parsing it with `parseYaml` is synchronous-enough and works immediately after `app.vault.create`.
|
||||
- **New-file collisions are resolved by `insert.ts`, not the caller.** `GlobalSettings.newFileCollisionMode` is `'auto'` (silently iterate `name-1.md`, `name-2.md`, ...) or `'prompt'` (open `RenamePromptModal` defaulted to the next free path; Cancel throws `Insert canceled: file already exists.`). Threaded through `InsertParams.collisionMode` from `pipeline.ts`. The path search uses `app.vault.getAbstractFileByPath` and caps at 1000 iterations. `nextFreePath` is local to [src/insert.ts](src/insert.ts); the equivalent `deCollide` in [src/audio-persist.ts](src/audio-persist.ts) is intentionally not shared — audio always auto-iterates regardless of the setting (the file is a side-effect users keep; the new-note path is the *target* the user named).
|
||||
- **Destination override does not mutate the template object.** [src/ui/modal.ts](src/ui/modal.ts) renders a per-invocation Destination control (insertMode + conditional newFile fields) and threads the result through `PipelineParams.destinationOverride`. [src/pipeline.ts](src/pipeline.ts) shallow-merges the override onto a *copy* of the template via `applyDestinationOverride` before calling `insertOutput`; the cached template and the file on disk remain untouched. The override is ephemeral: it resets when the modal closes and when the template selector changes. Not exposed in Quick Record, `runTextPipeline`, or `runAudioFilePipeline` (no UI surface). The UI is a collapsible `<details>` whose `<summary>` reads `"Destination: Default (<description>)"` (no override) or `"Destination: Custom (<description>)"` (override set, forced open); expand state is tracked on `ReWriteModal.destinationExpanded` so it survives the full-container re-renders that fire when the inner insertMode dropdown changes. `describeDestination(mode, folder, name)` formats the description (e.g. `New file: ReWrite Notes/{{date}}-note`).
|
||||
|
||||
### Cleanup-prompt inputs
|
||||
|
||||
- **Wake-name extraction is regex-only, off by default.** [src/wake-name.ts](src/wake-name.ts) requires `<assistantName>,` (vocative comma) to fire, captures up to the next sentence terminator or next name occurrence, and drops filler matches ("never mind", "scratch that", short tokens). It runs on ALL pipeline sources, including `paste` and `text`. The extracted instructions are appended to the LLM system prompt as a numbered `## Ad-hoc instructions` block, prefaced by `plugin.assistantPrompt` (loaded from `GlobalSettings.assistantPromptPath`); when the file is missing or empty, `DEFAULT_ASSISTANT_PROMPT` from [src/assistant-prompt.ts](src/assistant-prompt.ts) is used as the fallback so behavior is identical to the previous hardcoded textarea. Both the OpenAI and Anthropic adapters route this into the API's system slot. Whisper transcription homophones ("Scribner", "Scrivner") are not fuzzy-matched in v1; document the limitation if a user reports misses.
|
||||
- **Known nouns frontmatter is NOT sent to the LLM.** The vault file at `GlobalSettings.knownNounsPath` uses YAML frontmatter purely for human-readable guidance (token-cost warning, format hint). Only the body lines are parsed via `loadKnownNounsFromFile` and injected by `buildKnownNounsSystemPromptSection`. If a future change opts frontmatter in, it should be a per-vault opt-in setting, not the default. The body parser treats `#` lines and blank lines as ignored; an entry can be either bare canonical (`Anthropic`) or canonical + misheard alternates (`Hoxhunt: hawks hunt, hocks hunt`).
|
||||
|
||||
### Recording and capture
|
||||
|
||||
- **Audio persistence runs before transcription**, not after, so the user keeps the recording even if transcription fails. [src/audio-persist.ts](src/audio-persist.ts) catches its own errors and emits a `Notice`; the pipeline always continues to the transcribe stage even when persistence throws. Cancel paths in [src/ui/modal.ts](src/ui/modal.ts) and [src/ui/quick-record.ts](src/ui/quick-record.ts) call `recorder.cancel()` before `runPipeline()`, so no orphan file is written on cancel. The `![[<path>]]` embed is prepended to the cleaned output unconditionally when an audio file was saved, regardless of insert mode. The reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) skips persistence by passing `sourcePath` on the `audio` source variant; the embed prepend still runs, reusing the existing vault path so reprocessed output links back to the original file.
|
||||
- **A screen wake lock is held for the duration of active recording.** Android (and iOS) suspend the Obsidian Capacitor WebView when the screen sleeps, which kills `MediaRecorder` capture mid-recording. [src/recorder.ts](src/recorder.ts) requests `navigator.wakeLock.request('screen')` in `start()` (and re-acquires in `resume()`), releases it in `pause()`, and tears it down via `stopWakeLock()` from the shared `releaseStream()` (so both `stop()` and `cancel()` cover it). The OS auto-releases a screen wake lock whenever the document becomes hidden, so a `visibilitychange` listener (registered in `startWakeLock`, removed in `stopWakeLock`) re-requests on the next `visible` transition while `state === 'recording'`. The acquire is best-effort and degrades silently: where the Wake Lock API is absent (older WebView, desktop builds, insecure context) or denied (`NotAllowedError`), recording proceeds without it. The API is reached through a narrow local `WakeLockLike` / `WakeLockSentinelLike` interface + `getWakeLock()` cast (it is not in every TS DOM lib version), mirroring the `hotkeyManager` pattern. Cost: the screen stays lit while recording (a partial CPU-only wake lock would need native code Obsidian doesn't expose to plugins). A `stop`/`cancel` that races ahead of the async `request('screen')` is handled by re-checking `state === 'recording'` before retaining the sentinel.
|
||||
- **Live silence detection is a Web Audio tap on the recording stream.** [src/recorder.ts](src/recorder.ts) builds an `AnalyserNode` off the mic `MediaStream` in `start()` (no connection to `destination`, so no monitoring feedback) and samples peak amplitude every 100 ms via a `window.setInterval`. It exposes `getInputLevel()` (0..1 peak), `hasDetectedSound()`, and `getSilentMs()` (ms of continuous silence; returns 0 while paused / stopped or when the analyser could not be created, so the UI never warns when there is nothing to listen to). `resume()` resets the silence baseline so the paused gap is not counted. Both recording UIs poll `getSilentMs()` in their existing 250 ms timer loop and show a "No audio detected" warning past `SILENCE_WARNING_MS` (3 s): the modal's Record tab (`.rewrite-silence-warning`, [src/ui/modal.ts](src/ui/modal.ts)) and the Quick Record floater (`.rewrite-quick-silence-warning` below the controls row, [src/ui/quick-record.ts](src/ui/quick-record.ts) `setSilenceWarning`, hidden while busy). The monitor is torn down in `releaseStream()` (called by `stop()`/`cancel()`); any setup failure (no `AudioContext`, e.g. a stripped environment) degrades to "monitoring unavailable" and the recording proceeds without a warning. The threshold `SILENCE_LEVEL_THRESHOLD = 0.015` clears even quiet speech but flags a muted or dead mic.
|
||||
|
||||
### 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 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.
|
||||
|
||||
### Settings tab UI
|
||||
|
||||
- **`setHeading()` instead of manual `<h2>`** inside settings tabs. `obsidianmd/settings-tab/no-manual-html-headings` forbids manual headings. Same applies anywhere else inside a settings tab that needs a section header. In [src/settings/tab.ts](src/settings/tab.ts), section headings go through the `sectionHeading(parent, name, icon)` helper, which builds the `setHeading()` Setting and prepends a Lucide icon (via `setIcon`) to its `nameEl` styled by `.rewrite-heading-icon`. It returns the `Setting` so callers that attach a status badge (profile, shared core) still reach `nameEl`. Add new headings via this helper, not a bare `new Setting(...).setHeading()`, so they keep an icon.
|
||||
- **`window.confirm` is banned** by ESLint's `no-alert`. If a future phase needs an in-vault confirmation prompt, add a small `Modal` subclass rather than reaching for `window.confirm`.
|
||||
- **Sentence-case lint covers brand and acronym lists** in [eslint.config.mts](eslint.config.mts). Adding a new provider, model family, or product name means adding it to `REWRITE_BRANDS` (or `REWRITE_ACRONYMS` for things like `LLM`). Dropdown option labels also pass through the rule; in [src/settings/tab.ts](src/settings/tab.ts) and [src/ui/setup-card.ts](src/ui/setup-card.ts), labels are iterated via `opt.label` (member access) to dodge the literal-string check.
|
||||
- **Provider option arrays appear in both setup-card.ts and tab.ts.** Intentionally duplicated rather than extracted, per the "don't refactor beyond what the task requires" rule. If the lists drift, fix the user-visible inconsistency, not the duplication.
|
||||
- **Settings tab re-renders the entire container on dropdown changes** that toggle conditional fields (provider, insertMode, activeProfileOverride). Text fields call `saveSettings()` on change but do not redraw, so focus is preserved while typing. Preserve this pattern when adding new conditional fields. (Obsidian's newer review linter flags the `display()` / `this.display()` redraw pattern as deprecated in favor of `getSettingDefinitions`, a declarative `1.13.0+` API that is not in our bundled typings. Migrating would force `minAppVersion` to 1.13.0+ and a full settings rewrite, so it is deliberately deferred; the redraw pattern still works.)
|
||||
- **Profile sections wrap their settings in `.rewrite-profile-section`.** [src/settings/tab.ts](src/settings/tab.ts) `renderProfile()` creates a wrapper div per profile rather than rendering settings as direct children of `containerEl`. The active-on-this-device profile (per `detectActiveProfileKind`) gets `is-active-profile` (accent left border) and a `.rewrite-profile-active-badge` span inside the heading's `nameEl`. The inactive profile's body is wrapped in a `<details class="rewrite-profile-collapsed">` whose expand state lives on `ReWriteSettingTab.inactiveProfileExpanded` so it survives the full-container redraws triggered by dropdowns. New per-profile settings must take `body` as their parent (the wrapper or the `<details>`), not the original `parent` arg, or they will render outside the section's visual frame.
|
||||
- **Both provider unions include `'none'`.** [src/types.ts](src/types.ts) `TranscriptionProviderID` and `LLMProviderID` carry a `'none'` member for users who only want one half of the pipeline. The factories in [src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) return sentinel providers (transcription throws on `transcribe()`; LLM `complete()` returns the user message unchanged), but the pipeline never actually calls these because: (a) `collectTranscript` throws a friendlier error when `transcriptionProvider === 'none'` and `source.kind === 'audio'`; (b) `cleanupTranscript` short-circuits and returns the raw transcript when `llmProvider === 'none'` (this also skips wake-name extraction and known-nouns injection, since both only matter when an LLM consumes the system prompt). The settings tab + setup card hide model/baseUrl/apiKey fields for the `'none'` side; `isProfileConfigured` / `isProfileConfiguredForText` treat `'none'` as configured. The modal's Record tab, Quick Record, and the reprocess-audio command all gate on `transcriptionProvider === 'none'` with a "use Paste instead" hint.
|
||||
- **Templates are vault files, not settings.** There is no `settings.templates` array. Consumers read `plugin.templates` (refreshed from disk). When you add a field to `NoteTemplate`, update [src/templates-folder.ts](src/templates-folder.ts) on both sides: `parseTemplateFile` reads it out of frontmatter (with a sensible default if missing), and `renderTemplateFile` writes it into the frontmatter the populate button emits. The populate button is non-destructive: it skips files whose `id` already exists, so re-running it tops up the folder without clobbering user edits.
|
||||
- **The two template frontmatter flags have opposite polarity.** `disableSharedCore` is a negative opt-out (set it to turn a default OFF); `enableContextHint` is a positive opt-in (set it to turn a feature ON). Don't "harmonize" them. `enableContextHint` only gates whether the modal / reprocess-picker shows the Context field; the pipeline injects a `## Context` block on any non-empty `PipelineParams.contextHint` without consulting the flag (see Context hint section).
|
||||
- **Note-property extraction must run before the audio-embed prepend, and frontmatter is written newFile-only.** [src/pipeline.ts](src/pipeline.ts) `extractFromBlock` strips the leading ```` ```yaml ```` block off the LLM output inside `cleanupTranscript`, BEFORE `runPipeline` prepends `![[<path>]]\n\n`; prepending first would push the YAML off byte 0 and it would never parse as frontmatter. The parsed values are written via `app.fileManager.processFrontMatter` in `insertNewFile` only (after `vault.create`, before `openLinkText`) so the real `---...---` lands above the embed; `cursor` / `append` ignore `InsertParams.properties`. A present block is always stripped from the body (even when malformed YAML triggers the tolerant line-based fallback); a missing block leaves the whole output as the body. See Note properties.
|
||||
- **`noteTitle` is filename-only — never a frontmatter property.** The reserved `noteTitle` key (`titleFromContent`) shares the one leading ```` ```yaml ```` block with `noteProperties`, but `extractFromBlock` deliberately keeps it OUT of the property `allowed` set and returns it on `CleanupResult.title`. Do NOT "fix" the extractor to also write it into `properties`/frontmatter; it exists to name the file (`{{title}}` token or whole-name replacement in `insertNewFile`). It is the model's own generated string, independent of any `title` noteProperty (e.g. Book log's `title` property and its filename title are separate axes and need not match). Caveat: `resolveNewFilePath` collision detection is case-sensitive (`getAbstractFileByPath`), so on case-insensitive filesystems two titles differing only by case can still collide at `vault.create`; pre-existing, just likelier with content-derived names. See Note title.
|
||||
- **Frontmatter parsing uses `parseYaml` from Obsidian, not the metadata cache.** The metadata cache is async and may not be populated for newly created files; reading content via `app.vault.read(file)`, splitting off the leading `---...---` block, and parsing it with `parseYaml` is synchronous-enough and works immediately after `app.vault.create`.
|
||||
- **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`.
|
||||
- **`secrets.json.nosync` uses the `.nosync` suffix on purpose**: iCloud Drive natively skips any file or folder whose name ends in `.nosync`. The README documents per-tool sync exclusion for other tools.
|
||||
- **Per-provider recording limits live in [src/transcription/limits.ts](src/transcription/limits.ts), not the recorder.** [src/recorder.ts](src/recorder.ts) does not cap recordings at any size; `validateRecording(blobSize, durationMs, providerId)` runs in [src/pipeline.ts](src/pipeline.ts) between the `persist-audio` and `transcribe` stages, throwing a friendly provider-attributed error if the recording exceeds the documented byte or duration ceiling. Both modal and Quick Record thread the recorder's `durationMs` onto the `audio` pipeline source so the duration check has data; the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) omits `durationMs` (no cheap way to measure an arbitrary vault file), so reprocess only triggers the byte check. Limits source: `openai`/`groq` 25 MB, `assemblyai` 5 GB/10 h, `deepgram` 2 GB, `revai` 2 GB/17 h, `mistral-voxtral` 1 GB/30 min, `openai-compatible`/`whisper-local`/`webspeech` no client-side cap.
|
||||
- **Async poll timeout is duration-aware**, also in [src/transcription/limits.ts](src/transcription/limits.ts) via `pollTimeoutMs(durationMs?)`. The two polling adapters ([src/transcription/assemblyai.ts](src/transcription/assemblyai.ts), [src/transcription/revai.ts](src/transcription/revai.ts)) used to share a flat `POLL_TIMEOUT_MS = 60_000`, which made any recording the server took longer than a minute to process fail before it finished. They now derive the timeout from the recorded length: `min(60s + durationMs * 2, 2 h)`, so a short clip with a problem fails in ~1 min while a long job has room. `durationMs` is threaded as the optional 4th arg of `TranscriptionProvider.transcribe(audio, config, signal?, durationMs?)` from [src/pipeline.ts](src/pipeline.ts) (`source.durationMs`); the non-polling adapters ignore it (their impls simply omit the param). It is `undefined` for the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts), no cheap way to measure a vault file), which then falls back to the 2 h ceiling.
|
||||
- **WhisperHost lazy-requires Node modules** inside a `Platform.isDesktop` guard. Importing `child_process` / `net` / `fs` at module top would crash on mobile load. The cached `nodeApiCache` is `null` on mobile, so any host method that needs it bails with a clear "desktop only" error.
|
||||
- **`splitArgs` is a naive whitespace split.** The local-whisper `extraArgs` value is tokenized on whitespace only, so a single argument containing spaces (e.g. a quoted path) is not supported. Not a security issue (argv array, no shell). The Extra args settings field description states the limitation; add quote-aware parsing only if users actually hit it. The tokenized list is also scanned by `getHostArg` so a non-loopback `--host` is rejected before spawn (see Local whisper.cpp host section).
|
||||
- **Async settings-tab buttons are concurrency-guarded.** Handlers that run an async op then `this.display()` (Populate buttons, whisper Start/Stop/probe, Lock) are wrapped in `runGuardedButton(b, fn)` in [src/settings/tab.ts](src/settings/tab.ts), which disables the button for the duration so a rapid double-click cannot launch the work (and its full-container re-render) twice. The encryption-mode dropdown uses a `modeChangeInFlight` flag for the same reason. Wrap new async-then-display buttons the same way. Each handler keeps its own try/catch; observability `console.error('ReWrite: <context>', e)` sits alongside the user-facing Notice in those catches (and in the swallowed catches in [src/main.ts](src/main.ts)).
|
||||
- **WhisperHost has three ownership states.** `'spawned'` (this session's child handle is live; log capture works), `'adopted'` (port bound, PID sidecar matches a live PID — we started it in a previous session, no log capture), `'external'` (port bound but no sidecar match — someone else started it). Status enum gains `'external'` alongside `stopped`/`starting`/`running`/`crashed`; `running` means we own it (spawned OR adopted) and Stop works, `external` means we don't and Stop is disabled. `WhisperHost.snapshot()` returns `{ status, baseUrl, ownership, pid }` for UI consumers; `formatWhisperStatus(snap)` produces labels like "Running on http://... (adopted from previous session, pid 12345)." or "External whisper-server on http://... (not started by ReWrite).".
|
||||
- **Transcription never checks `WhisperHost.status()`.** [src/transcription/whisper-local.ts](src/transcription/whisper-local.ts) just asks for `host.baseUrl()` and POSTs. The HTTP API doesn't care who owns the process — if the port is reachable, transcription works. `baseUrl()` returns the URL for both `'running'` and `'external'` states.
|
||||
- **PID sidecar at `<plugin folder>/whisper-host.pid.json`** records `{ pid, port, binaryPath, startedAt }` once the server is ready. `stop()` and the child `exit` handler clear it. `WhisperHost.probe(config)` (called from `onload` and at the top of `start()`) reads the sidecar: if the port is reachable AND the sidecar PID is still alive AND the sidecar port matches the configured port, the host adopts it as `'running'` with ownership `'adopted'`. Otherwise (port bound, no sidecar match) the host transitions to `'external'`. Probe never disturbs state when we already hold a live spawned child. Uses `process.kill(pid, 0)` for liveness probing (added to the lazy `NodeAPI` cache alongside `cp`/`net`/`fs`).
|
||||
- **Stop semantics depend on ownership.** Spawned: `child.kill()` via the live ChildProcess handle. Adopted: `process.kill(pid, signal)` since we only have the PID, then clear the sidecar. External: `stop()` throws "not started by ReWrite — stop it via OS tools." (the settings-tab button is disabled with a tooltip, the status-bar click shows a Notice, the `stop-whisper-host` command is hidden via `checkCallback`). This preserves the "never kill a process we didn't start" invariant — the sidecar is proof we started it.
|
||||
- **`onunload` stops the whisper-host fire-and-forget.** `void this.whisperHost?.stop()` — Obsidian's `Plugin.onunload` signature is `() => void`, so we can't await. Stop() sends SIGTERM with a 3 s SIGKILL fallback, but the unload sequence may complete before that. Child processes do NOT auto-die with the parent on Linux (they reparent to init), on Windows (orphaned but still running), or reliably on macOS (SIGTERM may not land before Obsidian exits). The probe/adopt flow above is what closes this hole on next launch; don't try to make unload async.
|
||||
- **Audio persistence runs before transcription**, not after, so the user keeps the recording even if transcription fails. [src/audio-persist.ts](src/audio-persist.ts) catches its own errors and emits a `Notice`; the pipeline always continues to the transcribe stage even when persistence throws. Cancel paths in [src/ui/modal.ts](src/ui/modal.ts) and [src/ui/quick-record.ts](src/ui/quick-record.ts) call `recorder.cancel()` before `runPipeline()`, so no orphan file is written on cancel. The `![[<path>]]` embed is prepended to the cleaned output unconditionally when an audio file was saved, regardless of insert mode. The reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) skips persistence by passing `sourcePath` on the `audio` source variant; the embed prepend still runs, reusing the existing vault path so reprocessed output links back to the original file.
|
||||
- **A screen wake lock is held for the duration of active recording.** Android (and iOS) suspend the Obsidian Capacitor WebView when the screen sleeps, which kills `MediaRecorder` capture mid-recording. [src/recorder.ts](src/recorder.ts) requests `navigator.wakeLock.request('screen')` in `start()` (and re-acquires in `resume()`), releases it in `pause()`, and tears it down via `stopWakeLock()` from the shared `releaseStream()` (so both `stop()` and `cancel()` cover it). The OS auto-releases a screen wake lock whenever the document becomes hidden, so a `visibilitychange` listener (registered in `startWakeLock`, removed in `stopWakeLock`) re-requests on the next `visible` transition while `state === 'recording'`. The acquire is best-effort and degrades silently: where the Wake Lock API is absent (older WebView, desktop builds, insecure context) or denied (`NotAllowedError`), recording proceeds without it. The API is reached through a narrow local `WakeLockLike` / `WakeLockSentinelLike` interface + `getWakeLock()` cast (it is not in every TS DOM lib version), mirroring the `hotkeyManager` pattern. Cost: the screen stays lit while recording (a partial CPU-only wake lock would need native code Obsidian doesn't expose to plugins). A `stop`/`cancel` that races ahead of the async `request('screen')` is handled by re-checking `state === 'recording'` before retaining the sentinel.
|
||||
- **Live silence detection is a Web Audio tap on the recording stream.** [src/recorder.ts](src/recorder.ts) builds an `AnalyserNode` off the mic `MediaStream` in `start()` (no connection to `destination`, so no monitoring feedback) and samples peak amplitude every 100 ms via a `window.setInterval`. It exposes `getInputLevel()` (0..1 peak), `hasDetectedSound()`, and `getSilentMs()` (ms of continuous silence; returns 0 while paused / stopped or when the analyser could not be created, so the UI never warns when there is nothing to listen to). `resume()` resets the silence baseline so the paused gap is not counted. Both recording UIs poll `getSilentMs()` in their existing 250 ms timer loop and show a "No audio detected" warning past `SILENCE_WARNING_MS` (3 s): the modal's Record tab (`.rewrite-silence-warning`, [src/ui/modal.ts](src/ui/modal.ts)) and the Quick Record floater (`.rewrite-quick-silence-warning` below the controls row, [src/ui/quick-record.ts](src/ui/quick-record.ts) `setSilenceWarning`, hidden while busy). The monitor is torn down in `releaseStream()` (called by `stop()`/`cancel()`); any setup failure (no `AudioContext`, e.g. a stripped environment) degrades to "monitoring unavailable" and the recording proceeds without a warning. The threshold `SILENCE_LEVEL_THRESHOLD = 0.015` clears even quiet speech but flags a muted or dead mic.
|
||||
- **Wake-name extraction is regex-only, off by default.** [src/wake-name.ts](src/wake-name.ts) requires `<assistantName>,` (vocative comma) to fire, captures up to the next sentence terminator or next name occurrence, and drops filler matches ("never mind", "scratch that", short tokens). It runs on ALL pipeline sources, including `paste` and `text`. The extracted instructions are appended to the LLM system prompt as a numbered `## Ad-hoc instructions` block, prefaced by `plugin.assistantPrompt` (loaded from `GlobalSettings.assistantPromptPath`); when the file is missing or empty, `DEFAULT_ASSISTANT_PROMPT` from [src/assistant-prompt.ts](src/assistant-prompt.ts) is used as the fallback so behavior is identical to the previous hardcoded textarea. Both the OpenAI and Anthropic adapters route this into the API's system slot. Whisper transcription homophones ("Scribner", "Scrivner") are not fuzzy-matched in v1; document the limitation if a user reports misses.
|
||||
- **Known nouns frontmatter is NOT sent to the LLM.** The vault file at `GlobalSettings.knownNounsPath` uses YAML frontmatter purely for human-readable guidance (token-cost warning, format hint). Only the body lines are parsed via `loadKnownNounsFromFile` and injected by `buildKnownNounsSystemPromptSection`. If a future change opts frontmatter in, it should be a per-vault opt-in setting, not the default. The body parser treats `#` lines and blank lines as ignored; an entry can be either bare canonical (`Anthropic`) or canonical + misheard alternates (`Hoxhunt: hawks hunt, hocks hunt`).
|
||||
- **PipelineHost decouples the pipeline from `ReWritePlugin`.** [src/pipeline.ts](src/pipeline.ts) reads `params.host.assistantPrompt` and `params.host.knownNouns` through the narrow `PipelineHost` interface in [src/types.ts](src/types.ts). The plugin class `implements PipelineHost`, but the pipeline never imports `ReWritePlugin` directly, which would create a cycle through the UI layer. New cross-cutting cleanup-stage inputs should extend `PipelineHost` rather than reach for the plugin object.
|
||||
- **New-file collisions are resolved by `insert.ts`, not the caller.** `GlobalSettings.newFileCollisionMode` is `'auto'` (silently iterate `name-1.md`, `name-2.md`, ...) or `'prompt'` (open `RenamePromptModal` defaulted to the next free path; Cancel throws `Insert canceled: file already exists.`). Threaded through `InsertParams.collisionMode` from `pipeline.ts`. The path search uses `app.vault.getAbstractFileByPath` and caps at 1000 iterations. `nextFreePath` is local to [src/insert.ts](src/insert.ts); the equivalent `deCollide` in [src/audio-persist.ts](src/audio-persist.ts) is intentionally not shared — audio always auto-iterates regardless of the setting (the file is a side-effect users keep; the new-note path is the *target* the user named).
|
||||
- **Destination override does not mutate the template object.** [src/ui/modal.ts](src/ui/modal.ts) renders a per-invocation Destination control (insertMode + conditional newFile fields) and threads the result through `PipelineParams.destinationOverride`. [src/pipeline.ts](src/pipeline.ts) shallow-merges the override onto a *copy* of the template via `applyDestinationOverride` before calling `insertOutput`; the cached template and the file on disk remain untouched. The override is ephemeral: it resets when the modal closes and when the template selector changes. Not exposed in Quick Record, `runTextPipeline`, or `runAudioFilePipeline` (no UI surface). The UI is a collapsible `<details>` whose `<summary>` reads `"Destination: Default (<description>)"` (no override) or `"Destination: Custom (<description>)"` (override set, forced open); expand state is tracked on `ReWriteModal.destinationExpanded` so it survives the full-container re-renders that fire when the inner insertMode dropdown changes. `describeDestination(mode, folder, name)` formats the description (e.g. `New file: ReWrite Notes/{{date}}-note`).
|
||||
- **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.
|
||||
- **Whisper status bar polls `whisperHost.status()` every 1 s** via `registerInterval`. The host has no event emitter; both [src/settings/tab.ts](src/settings/tab.ts) (re-render on Start/Stop click) and [src/ui/whisper-status-bar.ts](src/ui/whisper-status-bar.ts) (interval poll) re-read the status synchronously. If you add a third consumer, poll the same way; do not bolt on an event system.
|
||||
|
||||
### Secrets and encryption
|
||||
|
||||
See [docs/SECRETS.md](docs/SECRETS.md) for the secrets/encryption gotchas (secretStorage probe, `saveManyKeys` no-op when locked, zxcvbn lazy-load, `.nosync` suffix).
|
||||
|
||||
### Whisper host (desktop)
|
||||
|
||||
See [docs/WHISPER_HOST.md](docs/WHISPER_HOST.md) for the whisper-host gotchas (lazy Node require, `splitArgs`, spawned/adopted/external ownership, PID sidecar, stop semantics, `onunload` fire-and-forget, status-bar poll).
|
||||
|
||||
### Mobile
|
||||
|
||||
- **Mobile keyboard avoidance is CSS-only: pin our popups to the top.** Obsidian mobile (Capacitor) overlays the soft keyboard on top of the WebView without resizing the layout or visual viewport, so there is no reliable JS signal (`visualViewport` does not shrink, the `resize` event does not fire) to react to. The earlier JS helper (`installMobileKeyboardScrollFix`, which read `visualViewport` and shrank `.modal-container`) was a confirmed no-op on the failing cases and has been removed. The fix lives entirely in [styles.css](styles.css): under `.is-mobile`, our modal classes (`.rewrite-modal`, which covers the main + passphrase modals, and `.rewrite-rename-modal`) get `align-self: flex-start; margin-top: 8px; margin-bottom: auto; max-height: calc(100% - 16px)`, pinning the popup to the top of the flex container that centers modals. The keyboard opens from the bottom, so a top-anchored popup and its near-top input fields stay visible above it. Scoped to our classes so core Obsidian modals are untouched. The settings tab (a tall scrollable surface) was never affected and needs no rule: Chromium's native keyboard-aware focus-scroll handles it. If you add a new popup with a text field, give it one of these classes (or add its class to the selector) rather than reaching for a JS keyboard helper. Top-anchoring alone is not enough when something pushes the focused element low within a tall popup, so three companion tweaks keep the relevant element high: (a) `.is-mobile .rewrite-modal .modal-content` gets `padding-top: 8px` and `.is-mobile .rewrite-modal h2` gets `margin-top: 0` to reclaim the empty band Obsidian leaves above the title; (b) the Paste textarea renders at `rows = 4` on mobile (vs 10 on desktop, set in [src/ui/modal.ts](src/ui/modal.ts)) with the desktop 160px `min-height` floor dropped to 80px under `.is-mobile`, so its submit button stays above the keyboard; (c) the change-passphrase tips block is a `<details>` ([src/ui/passphrase-modal.ts](src/ui/passphrase-modal.ts) `renderPassphraseTips`) expanded by default on every platform (opt-out security guidance), but on mobile it auto-collapses (`collapseTipsOnMobile`) when a passphrase field receives focus, so it is seen on open yet stops pushing the fields into the keyboard once the user starts typing. The first field's `autofocus` is disabled on mobile (`autofocus = !Platform.isMobile`) so the auto-collapse fires on the user's tap rather than a premature programmatic focus.
|
||||
|
||||
## Local install for testing
|
||||
|
|
|
|||
13
docs/DIARIZATION.md
Normal file
13
docs/DIARIZATION.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Speaker diarization
|
||||
|
||||
> 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 FEATURES.md item 4; 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).
|
||||
|
||||
## Per-template override
|
||||
|
||||
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).
|
||||
|
||||
## Per-adapter behavior
|
||||
|
||||
[src/transcription/assemblyai.ts](../src/transcription/assemblyai.ts) sets `speaker_labels: true` and formats the returned `utterances[]` (`Speaker A: ...`, native letter labels); [src/transcription/deepgram.ts](../src/transcription/deepgram.ts) adds `diarize=true` and groups per-word `speaker` indices via `formatDiarizedWords` (0-based bumped to `Speaker 1`); [src/transcription/revai.ts](../src/transcription/revai.ts) fetches the JSON transcript (`Accept: application/vnd.rev.transcript.v1.0+json`) instead of `text/plain` and rebuilds labels from `monologues[]` via `formatMonologues` (0-based bumped to `Speaker 1`). Each adapter falls back to its flat-text path when the labeled payload is missing, so toggling off is a clean no-op. Label survival through cleanup is handled by a clause in `DEFAULT_SHARED_CORE` ([src/shared-core.ts](../src/shared-core.ts)) telling the LLM to preserve `Speaker X:` prefixes; the Podcast default template already tolerates labeled and unlabeled input.
|
||||
42
docs/SECRETS.md
Normal file
42
docs/SECRETS.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Secrets encryption
|
||||
|
||||
> Extracted from CLAUDE.md. Subject to the same maintenance rule: when you change secrets/encryption behavior, update this file in the same change, and keep the summary in [CLAUDE.md](../CLAUDE.md) accurate.
|
||||
|
||||
[src/secrets.ts](../src/secrets.ts) supports two encryption modes for API keys, file-wide (not per-key). There is no unencrypted at-rest option.
|
||||
|
||||
- **`secretStorage`** — Obsidian's first-party `app.secretStorage` (GA 1.11.4): a shared, cross-plugin, OS-encrypted store (Keychain/DPAPI/libsecret under the hood, the same backend Electron `safeStorage` used). **Default/preferred when available.** Values live in Obsidian's store, NOT in `secrets.json.nosync` (the on-disk envelope in this mode is just `{ version, mode: 'secretStorage', keys: {} }` to record the mode). Keys are namespaced with `manifest.id` (`rewrite-voice-notes:profile:desktop:transcription`) because the store is shared across plugins. The API is reached via a narrow `SecretStorageLike` interface + cast (the installed typings predate it) that normalizes `getSecret`/`get`, `setSecret`/`set`, `listSecrets`/`list`, and `deleteSecret`/`removeSecret`/`delete` (falling back to `setSecret(id, '')` when no delete exists); each method may be sync or async, so callers `await`. **Availability** is a round-trip self-test (`setSecret` a sentinel, `getSecret`, compare, clean up) cached for the session — a device with no working OS secret store (e.g. Linux without a keyring) reads unavailable and falls back to passphrase. `warmSecretStorage(plugin)` runs the probe once in `onload` BEFORE `getEncryptionStatus`, so the synchronous `defaultEnvelope()` can prefer `secretStorage` on first run. **Sync caveat:** because the store can sync across devices via Obsidian Sync, keys in this mode are not bound to one device (unlike the `.nosync` file); this is surfaced in the settings mode description.
|
||||
- **`passphrase`** — WebCrypto AES-GCM-256 with a key derived from a user-supplied passphrase. The KDF is **Argon2id** by default (via `hash-wasm`, `m = 32 MiB`, `t = 3`, `p = 1`, 32-byte output), with **PBKDF2-SHA256** (600,000 iterations) as the fallback when a device can't run Argon2id. The chosen algorithm + params live in the envelope `kdf` (see below). Each value is stored as `<iv-b64>.<ct-b64>` (12-byte random IV per value) in `secrets.json.nosync`. A `verifier` field stores an encryption of `VERIFIER_PLAINTEXT` so unlock can validate the passphrase without decrypting user keys. Works on every platform including mobile and Linux-without-keyring; the always-available fallback when `secretStorage` is not. **Entropy gate:** `changeEncryptionMode`/`changePassphrase` reject a passphrase scoring below `MIN_PASSPHRASE_SCORE` (3 of 4) via [src/passphrase-strength.ts](../src/passphrase-strength.ts) (zxcvbn-ts wrapper); this is the hard, non-bypassable gate. The passphrase modal ([src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts)) surfaces a live strength meter and a **Generate** button (6-word EFF-wordlist diceware via [src/diceware.ts](../src/diceware.ts) + [src/eff-large-wordlist.ts](../src/eff-large-wordlist.ts)) on create/change flows (`enforceStrength: true`). **zxcvbn-ts is lazy-loaded:** its dictionaries are ~1.6 MB / ~140 ms to build, so [src/passphrase-strength.ts](../src/passphrase-strength.ts) pulls the packages via dynamic `import()` (not a static top-level import), keeping them out of plugin-startup evaluation; `evaluatePassphrase`/`isPassphraseAcceptable` are therefore **async**, and the modal calls `warmPassphraseStrength()` on open so the first keystroke does not pay the build cost. The bytes stay in `main.js` (esbuild has no code-splitting here); only the parse/construct work is deferred.
|
||||
|
||||
History note: an earlier hand-rolled `safeStorage` mode (direct Electron keychain) was REMOVED and superseded by `secretStorage`, which uses the same OS backend but is Obsidian-managed and cross-platform. No users had it (unpublished), so it was dropped outright with no migration; a stale `safeStorage` envelope parses as invalid and resets to default.
|
||||
|
||||
`minAppVersion` is `1.4.4`, raised from `1.4.0` because `FileManager.processFrontMatter` (`@since 1.4.4`, used directly in [src/insert.ts](../src/insert.ts) for note-property frontmatter) is NOT feature-detected. `secretStorage` (the other newer API) IS feature-detected at runtime, so it does not drive the minimum; older-than-secretStorage Obsidian still loads the plugin and simply lands on passphrase.
|
||||
|
||||
File envelope (`SECRETS_VERSION = 2`). For passphrase mode, the `kdf.algo` discriminant distinguishes PBKDF2 from Argon2id; a legacy envelope with no `algo` but an `iterations` field is read as `pbkdf2`:
|
||||
```json
|
||||
{ "version": 2, "mode": "passphrase",
|
||||
"kdf": { "algo": "argon2id", "salt": "<b64>", "memKiB": 32768, "timeCost": 3, "parallelism": 1 },
|
||||
"verifier": "<iv-b64>.<ct-b64>",
|
||||
"keys": { "profile:desktop:transcription": "<iv-b64>.<ct-b64>", ... } }
|
||||
```
|
||||
|
||||
The derived AES-GCM key for passphrase mode lives in module-level state (`unlockedKey`); it never touches disk. `lockSecrets()` forgets it. `unlockSecrets(plugin, passphrase)` derives a candidate key via `deriveKeyFromKdf` (dispatching on `kdf.algo`) and decrypts the `verifier` to check correctness before caching. An Argon2id allocation failure at unlock throws a clear "this device can't allocate enough memory" error (the envelope was created on a device with more RAM). **Opportunistic migration:** on a successful unlock of a `pbkdf2` envelope, if the device can run Argon2id the keys are silently re-encrypted to `argon2id` (best-effort; failures leave it on PBKDF2). This is a deliberate exception to the pre-release no-migration rule, kept so existing passphrase users aren't forced to re-enter keys. `secretStorage` mode never locks and has no KDF/verifier.
|
||||
|
||||
`ReWritePlugin.encryptionStatus` (a snapshot of `{ mode, locked, configured, secretStorageAvailable }`) is loaded on `onload` and refreshed via `plugin.refreshEncryptionStatus()` after every mode change / unlock. UI code reads this synchronously. `locked` and `configured` only vary in passphrase mode (`secretStorage` is always unlocked + configured); `configured` is `false` for a passphrase envelope with no `kdf`/`verifier` yet (first run on a device without secret storage); `secretStorageAvailable` is the cached probe result, used to offer the mode and to steer the user when it is missing.
|
||||
|
||||
When `mode === 'passphrase'` and not yet unlocked (`encryptionStatus.locked === true`, which includes the unconfigured first-run state):
|
||||
- `loadAllKeys` / `loadKey` return empty strings (no error).
|
||||
- `saveManyKeys` is a no-op (so calls to `saveSettings()` from unrelated UI changes do not clobber the on-disk encrypted values with empties).
|
||||
- `saveKey` throws.
|
||||
- All entry points ([src/ui/modal.ts](../src/ui/modal.ts), [src/ui/quick-record.ts](../src/ui/quick-record.ts), [src/ui/text-source.ts](../src/ui/text-source.ts), [src/ui/audio-source.ts](../src/ui/audio-source.ts)) check `plugin.encryptionStatus.locked` and call `plugin.promptUnlock()` instead of proceeding. `promptUnlock` branches on `configured`: a configured envelope opens the unlock modal; an unconfigured one opens a **create-passphrase** modal (`requireConfirm` + `enforceStrength`) that calls `changeEncryptionMode(this, 'passphrase', pass)`. The entry points need no change since unconfigured reports `locked === true`.
|
||||
- The settings tab disables the API key input fields and shows a banner: "Unlock" (configured+locked) or "Set passphrase" (unconfigured).
|
||||
|
||||
`changeEncryptionMode(plugin, newMode, newPassphrase?)` reads all keys to plaintext from the current mode (via `readAllPlain`, which lists `app.secretStorage` for `secretStorage` or decrypts the envelope for `passphrase`), switches, and re-stores them. Requires the current mode to be unlocked (if passphrase AND already configured). Switching TO `secretStorage` requires the probe to pass, then `setSecret`s each value and writes a minimal envelope. Switching TO `passphrase` requires `newPassphrase` (entropy-gated, new envelope built by `buildPassphraseKdfAndKey`) and, when coming FROM `secretStorage`, clears our namespaced entries from the shared store (`clearSecretStorage`) so keys don't linger/sync. `changePassphrase(plugin, newPassphrase)` is a thin wrapper that calls `changeEncryptionMode(plugin, 'passphrase', newPassphrase)`.
|
||||
|
||||
`resetSecrets(plugin, newPassphrase)` is the **forgot-passphrase recovery** path. Unlike `changePassphrase`, it does NOT require unlocking first: it drops `unlockedKey` / `cachedEnvelope` and writes a fresh, **empty** passphrase envelope under the new passphrase (the old keys are unrecoverable without the old passphrase, so they are discarded by design). The new passphrase must pass the same entropy gate. It is exposed in the settings tab **only in the locked state** (`renderEncryption`'s `status.locked` banner branch shows a `mod-warning` "Forgot passphrase? Reset" button beside Unlock; `openResetModal` runs the flow, then `hydrateSecrets` to empty the in-memory keys, `refreshEncryptionStatus`, and `notifySecretsUnlocked`). An unlocked user uses **Change passphrase** instead, so there is no reset row there. The reset modal is a `PassphraseModal` with `requirePhrase: 'DELETE APIS'` (a destructive-confirmation gate: `submit()` blocks until a plain-text field exactly matches the phrase) plus the usual `requireConfirm` + `enforceStrength`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`app.secretStorage` is reached via a narrow cast + round-trip self-test, never assumed present.** [src/secrets.ts](../src/secrets.ts) `getSecretStorage(plugin)` casts `plugin.app` to a local `RawSecretStorage` shape and normalizes the method aliases; `probeSecretStorage` write/read/compares a sentinel before trusting it. Importing nothing Electron-specific keeps it mobile-safe. Old Obsidian (no API) and a broken OS store (Linux without a keyring) both read unavailable and fall back to passphrase. The probe result is module-cached and warmed in `onload` via `warmSecretStorage` so the synchronous `defaultEnvelope()` can prefer it on first run. Keys are namespaced with `manifest.id`; never store under a bare id (the store is shared across plugins).
|
||||
- **`saveManyKeys` is a silent no-op when locked.** When `mode === 'passphrase'` and `unlockedKey === null`, `saveManyKeys` does nothing. This is deliberate: unrelated `plugin.saveSettings()` calls (e.g. user changes a model dropdown) would otherwise persist empty `apiKey` values for every profile, wiping the on-disk encrypted bag. The UI prevents this by disabling key fields and gating all pipeline entry points on `encryptionStatus.locked`. `saveKey` (single-key write) still throws so callers can react.
|
||||
- **Keep zxcvbn lazy and the strength API async.** [src/passphrase-strength.ts](../src/passphrase-strength.ts) pulls `@zxcvbn-ts/*` via dynamic `import()` specifically so the ~1.6 MB of dictionaries are not parsed/constructed at plugin load (measured: ~9 ms startup vs ~47 ms with a static import). Do not "simplify" it back to a top-level static import or a synchronous `evaluatePassphrase` — that re-adds the cost to every Obsidian launch on every device. The async ripples (modal `updateStrength` race guard, `await isPassphraseAcceptable` in `changeEncryptionMode`) are intentional. `warmPassphraseStrength()` is fired on passphrase-modal open to hide the one-time build behind the modal animation.
|
||||
- **`secrets.json.nosync` uses the `.nosync` suffix on purpose**: iCloud Drive natively skips any file or folder whose name ends in `.nosync`. The README documents per-tool sync exclusion for other tools.
|
||||
26
docs/WHISPER_HOST.md
Normal file
26
docs/WHISPER_HOST.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Local whisper.cpp host (desktop)
|
||||
|
||||
> 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.
|
||||
|
||||
The companion CLI installer is [scripts/build-whisper-linux.sh](../scripts/build-whisper-linux.sh): it clones/builds whisper.cpp into `~/.local/share/whisper.cpp` (override `--source-dir`), symlinks the binary to `~/.local/bin/whisper-server` (override `--prefix`, skip with `--no-symlink`), and prints the final Binary path to paste into settings. Keep the Auto-detect candidate list in sync with this script's default paths.
|
||||
|
||||
`start()` validates the binary and model paths exist via `fs.existsSync`, probes the port via `net.createServer().listen(port)` to detect conflicts, spawns the user's whisper-server with `child_process.spawn`, captures stdout/stderr into a 1 MB ring buffer, then polls `net.createConnection` against the port every 250 ms for up to 5 s before declaring `'running'`. Any failure transitions status to `'crashed'` with the log tail surfaced in the error message. `stop()` sends SIGTERM, waits up to 3 s, then SIGKILL.
|
||||
|
||||
**Loopback binding is enforced.** whisper-server has no auth/TLS, so `start()` pins `--host 127.0.0.1` when the user did not supply a `--host` in `extraArgs` (so we don't depend on upstream's default binding), and throws before spawning if a user-supplied `--host` is non-loopback (`getHostArg` + `isLoopbackHost` in [src/whisper-host.ts](../src/whisper-host.ts); loopback = `127.0.0.1` / `localhost` / `::1`). This prevents a stray `--host 0.0.0.0` from silently exposing an unauthenticated transcription server to the LAN. The README's "Network exposure" disclosure and the Extra args field description both state this.
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
- **WhisperHost lazy-requires Node modules** inside a `Platform.isDesktop` guard. Importing `child_process` / `net` / `fs` at module top would crash on mobile load. The cached `nodeApiCache` is `null` on mobile, so any host method that needs it bails with a clear "desktop only" error.
|
||||
- **`splitArgs` is a naive whitespace split.** The local-whisper `extraArgs` value is tokenized on whitespace only, so a single argument containing spaces (e.g. a quoted path) is not supported. Not a security issue (argv array, no shell). The Extra args settings field description states the limitation; add quote-aware parsing only if users actually hit it. The tokenized list is also scanned by `getHostArg` so a non-loopback `--host` is rejected before spawn (see Loopback binding above).
|
||||
- **WhisperHost has three ownership states.** `'spawned'` (this session's child handle is live; log capture works), `'adopted'` (port bound, PID sidecar matches a live PID — we started it in a previous session, no log capture), `'external'` (port bound but no sidecar match — someone else started it). Status enum gains `'external'` alongside `stopped`/`starting`/`running`/`crashed`; `running` means we own it (spawned OR adopted) and Stop works, `external` means we don't and Stop is disabled. `WhisperHost.snapshot()` returns `{ status, baseUrl, ownership, pid }` for UI consumers; `formatWhisperStatus(snap)` produces labels like "Running on http://... (adopted from previous session, pid 12345)." or "External whisper-server on http://... (not started by ReWrite).".
|
||||
- **Transcription never checks `WhisperHost.status()`.** [src/transcription/whisper-local.ts](../src/transcription/whisper-local.ts) just asks for `host.baseUrl()` and POSTs. The HTTP API doesn't care who owns the process — if the port is reachable, transcription works. `baseUrl()` returns the URL for both `'running'` and `'external'` states.
|
||||
- **PID sidecar at `<plugin folder>/whisper-host.pid.json`** records `{ pid, port, binaryPath, startedAt }` once the server is ready. `stop()` and the child `exit` handler clear it. `WhisperHost.probe(config)` (called from `onload` and at the top of `start()`) reads the sidecar: if the port is reachable AND the sidecar PID is still alive AND the sidecar port matches the configured port, the host adopts it as `'running'` with ownership `'adopted'`. Otherwise (port bound, no sidecar match) the host transitions to `'external'`. Probe never disturbs state when we already hold a live spawned child. Uses `process.kill(pid, 0)` for liveness probing (added to the lazy `NodeAPI` cache alongside `cp`/`net`/`fs`).
|
||||
- **Stop semantics depend on ownership.** Spawned: `child.kill()` via the live ChildProcess handle. Adopted: `process.kill(pid, signal)` since we only have the PID, then clear the sidecar. External: `stop()` throws "not started by ReWrite — stop it via OS tools." (the settings-tab button is disabled with a tooltip, the status-bar click shows a Notice, the `stop-whisper-host` command is hidden via `checkCallback`). This preserves the "never kill a process we didn't start" invariant — the sidecar is proof we started it.
|
||||
- **`onunload` stops the whisper-host fire-and-forget.** `void this.whisperHost?.stop()` — Obsidian's `Plugin.onunload` signature is `() => void`, so we can't await. Stop() sends SIGTERM with a 3 s SIGKILL fallback, but the unload sequence may complete before that. Child processes do NOT auto-die with the parent on Linux (they reparent to init), on Windows (orphaned but still running), or reliably on macOS (SIGTERM may not land before Obsidian exits). The probe/adopt flow above is what closes this hole on next launch; don't try to make unload async.
|
||||
- **Whisper status bar polls `whisperHost.status()` every 1 s** via `registerInterval`. The host has no event emitter; both [src/settings/tab.ts](../src/settings/tab.ts) (re-render on Start/Stop click) and [src/ui/whisper-status-bar.ts](../src/ui/whisper-status-bar.ts) (interval poll) re-read the status synchronously. If you add a third consumer, poll the same way; do not bolt on an event system.
|
||||
Loading…
Reference in a new issue