Errors: bump minAppVersion 1.4.0 to 1.4.4 (FileManager.processFrontMatter is since 1.4.4) and update versions.json; remove the disallowed eslint-disable in passphrase-modal and pass the random-string example via a variable to satisfy sentence-case. Popout/compat warnings: setTimeout/clearTimeout to window.*; document to activeDocument, capturing the document ref for paired add/removeEventListener (recorder wake-lock, quick-record popover, settings, floater); globalThis to window for require/process in getNodeApi; replace the rewrite-hidden display-none important rule with el.toggle/hide/show; register activeDocument/activeWindow as eslint globals for no-undef. Attestations: add .github/workflows/release.yml (build, actions/attest-build-provenance, softprops/action-gh-release on tag push). Docs: CLAUDE.md minAppVersion rationale plus CI release/attestation and display() deferral notes; README Vault access disclosure and License corrected to 0BSD; DEVCONFLICTS refreshed. Deferred recommendations: display() to getSettingDefinitions (1.13.0+ rewrite) and vault enumeration (necessary, disclosed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project state
This is the ReWrite (Voice Notes) plugin for Obsidian: record or paste speech, transcribe via a user-configured provider, clean and structure via an LLM, insert per a chosen template. Desktop and mobile.
The v1 implementation is feature-complete against obsidian-voice-notes-spec.md and docs/IMPLEMENTATION_PLAN.md. The spec is still the source of truth for behavior; the implementation plan resolves the spec's internal discrepancies (notably manifest id, base URL handling for openai-compatible, and mobile safeStorage unavailability). docs/claude-scratch/STATUS.md tracks per-phase commit state and the running list of architectural decisions made during implementation; consult it when picking up work or before changing anything cross-cutting.
When extending the plugin, follow the file layout the spec prescribes: provider adapters under src/transcription/ and src/llm/, factories in each index.ts, no provider-specific logic leaking outside its own file.
Documentation maintenance
Update CLAUDE.md with every behavioral change. When modifying code that this document describes (pipeline stages, command IDs, settings keys, gotchas, conventions), update CLAUDE.md in the same change. If a behavioral change has no existing section, add one or drop a note under "Gotchas". Treat the doc update as part of the task, not a follow-up.
There is a second, user-facing doc to keep in sync: the template guide (DEFAULT_TEMPLATE_GUIDE in 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
npm install # install deps
npm run dev # esbuild watch mode → bundles src/main.ts to ./main.js with inline sourcemaps
npm run build # tsc -noEmit type-check, then esbuild production (minified, no sourcemaps)
npm run lint # eslint over the repo (uses eslint-plugin-obsidianmd recommended)
npm version <patch|minor|major> # bumps manifest.json + versions.json via version-bump.mjs
There is no test runner configured. Verification is npm run build && npm run lint (CI parity) plus the manual checklist in obsidian-voice-notes-spec.md (lines 454-476).
CI (.github/workflows/lint.yml) runs npm ci, npm run build, and npm run lint on Node 20.x and 22.x for every push/PR.
Build architecture
- Entry: src/main.ts → bundled to
./main.jsat repo root (the file Obsidian loads). Kept minimal: settings load, ribbon icon, two commands, settings tab, plus a singleactiveQuickRecordref so the Quick Record command can toggle. - Bundler: esbuild.config.mjs.
obsidian,electron, all@codemirror/*, all@lezer/*, and Node built-ins are markedexternal. Never import other runtime deps without bundling them in. - Release artifacts are
main.js,manifest.json, andstyles.cssat the repo root. Do not commit the generatedmain.js. - TypeScript config (tsconfig.json) is strict:
noImplicitAny,strictNullChecks,noImplicitReturns,noUncheckedIndexedAccess,useUnknownInCatchVariables,baseUrl: "src". Target ES6, module ESNext, lib DOM + ES5/6/7 only. No Node lib, so don't reach for Node APIs in plugin code. - ESLint (eslint.config.mts) layers
eslint-plugin-obsidianmd's recommended rules on top oftypescript-eslint. These rules encode Obsidian-specific correctness checks; respect them rather than disabling.
Source layout
src/
├── main.ts # Lifecycle, commands, ribbon, settings tab registration
├── types.ts # Shared interfaces (provider IDs, configs, templates, settings)
├── http.ts # requestUrl wrappers: jsonPost/jsonGet/multipartPost + ProviderError
├── platform.ts # Active-profile resolver + MediaRecorder availability probe
├── secrets.ts # Obsidian secretStorage (preferred) + passphrase (Argon2id/PBKDF2) for API keys
├── passphrase-strength.ts # zxcvbn-ts wrapper (lazy dynamic import, async API): evaluatePassphrase + MIN_PASSPHRASE_SCORE gate + warmPassphraseStrength
├── diceware.ts # generateDicewarePassphrase (EFF wordlist, crypto rejection sampling)
├── eff-large-wordlist.ts # EFF large diceware wordlist (7776 words)
├── recorder.ts # MediaRecorder state machine + getBestMimeType + live input-level monitor (no size cap; per-provider limits live in transcription/limits.ts)
├── audio-transcode.ts # WebAudio decode + resample to 16 kHz mono PCM WAV (shared by whisper-local and mistral-voxtral)
├── pipeline.ts # transcribe → cleanup → insert orchestrator
├── insert.ts # cursor/newFile/append + {{date}}/{{time}} expansion
├── whisper-host.ts # Spawns/stops a user-supplied whisper-server child process (desktop only)
├── templates-folder.ts # Load templates from a vault folder + populate it with the 10 defaults
├── template-guide.ts # Populate a human-facing "Template guide.md" next to the templates folder (never loaded by the plugin)
├── shared-core.ts # Load the shared cleanup preface from a vault Markdown file + populate default (prepended to every template prompt)
├── assistant-prompt.ts # Load the ad-hoc-instructions assistant prompt from a vault Markdown file + populate default
├── known-nouns.ts # Load a vault Markdown file of known nouns + populate default + build the system-prompt section
├── audio-persist.ts # Write the recorded Blob to an attachments folder, return vault-relative path
├── wake-name.ts # Extract "<assistantName>, <directive>" instructions from a transcript
├── settings/
│ ├── index.ts # DEFAULT_SETTINGS, load/save, per-profile secret hydration
│ ├── tab.ts # PluginSettingTab: active profile, two profile sections, templates folder, recording
│ └── default-templates.ts # The 10 default templates used by the populate button (General cleanup, Todo list, Daily note, Meeting notes, Meeting transcript, Idea capture, Lecture, Podcast, Guides, Book log); each prompt = per-template rules only (the shared preface is prepended at runtime from shared-core.ts)
├── ui/
│ ├── modal.ts # Main modal: template select + Record/Paste/From note tabs + setup-card injection
│ ├── setup-card.ts # Inline blocker when active profile is unconfigured (voice vs text purpose)
│ ├── quick-record.ts # QuickRecordController + floating mini-UI for the Quick Record command
│ ├── template-picker.ts # Lightweight modal for picking a template (used by Process text command and editor menu)
│ ├── text-source.ts # resolveActiveTextSource + runTextPipeline helpers for text-source flows
│ └── whisper-status-bar.ts # Status-bar dot for whisper-host start/stop (desktop + whisper-local profile only)
├── transcription/
│ ├── index.ts # TranscriptionProvider interface + createTranscriptionProvider()
│ ├── limits.ts # Per-provider maxBytes/maxDurationMs + validateRecording (called from pipeline)
│ ├── openai.ts # Whisper-shape POST (also used by openai-compatible + groq)
│ ├── assemblyai.ts # upload → submit → poll
│ ├── deepgram.ts # single POST
│ ├── revai.ts # submit → poll → fetch text (JSON transcript when diarizing)
│ ├── mistral-voxtral.ts # Mistral Voxtral STT (JSON response; always transcodes to WAV)
│ └── whisper-local.ts # Thin shim that POSTs to the WhisperHost-managed local server
└── llm/
├── index.ts # LLMProvider interface + createLLMProvider()
├── openai.ts # /v1/chat/completions (also used by openai-compatible + mistral)
├── anthropic.ts # /v1/messages
└── gemini.ts # :generateContent
Pipeline
src/pipeline.ts runs four stages with onStage callbacks for UI:
- Persist audio (
audiosource only): writes the rawBlobto the vault via src/audio-persist.ts before transcription, so the user keeps the recording even if later stages fail. Path resolution: whensettings.attachmentsFolderPathis set, the file goes under that folder with manual de-collision (-1,-2, ...); when empty, the path comes fromapp.fileManager.getAvailablePathForAttachment(filename), which respects Obsidian's own attachments setting. Filename isReWrite-YYYY-MM-DD-HHmmss.<ext>with the extension derived from the blob's mime type (webm/m4a/ogg/wav/mp3, defaultwebm). Failure is non-fatal: a Notice fires and transcription proceeds. The resolved path is later prepended to the cleaned output as![[<path>]]\n\nbefore insertion. - Transcribe:
audio→createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config). Skipped when the source ispasteortext(input passes through). Just before dispatching,validateRecording(blobSize, durationMs, providerId)from src/transcription/limits.ts throws a friendly per-provider error if the recording exceeds the provider's documented byte or duration cap. Because validation runs afterpersist-audio, the user keeps the saved file and can switch providers + reprocess from the vault. - Cleanup:
createLLMProvider(profile.llmProvider).complete(systemPrompt, transcript, config). The system prompt is the template prompt, optionally augmented with an## Ad-hoc instructionsblock when the wake-name scan (src/wake-name.ts) extracts directives from the transcript, a## Contextblock whenparams.contextHintis non-empty (see Context hint below), a## Known nounsblock whenplugin.knownNounsis non-empty (see Assistant prompt and Known nouns sections below), and a## Note propertiesblock when the template declaresnoteProperties(see Note properties below).cleanupTranscriptreturns{ body, properties, title? }: when the template declares properties OR setstitleFromContent, the LLM is asked to emit a leading```yamlblock whichextractFromBlockparses off the front (the rest is the body); otherwisepropertiesis{},titleis undefined, andbodyis the full output. On error, the provider error propagates unchanged; nothing is written to the clipboard (the earlier clipboard fallback was removed as a sensitive-content exposure, and foraudiosources the persisted recording is the recovery path). - Insert:
src/insert.tsroutes tocursor/newFile/appendper the template.cursorfalls back toappendwhen no editor is active;appendfalls back tonewFilewhen no markdown file exists.{{date}}/{{time}}in filename templates expand via Obsidian'smoment, and{{title}}expands to the LLM-generated title (empty when no title). The modal's per-invocation Destination control overridesinsertMode/newFileFolder/newFileNameTemplateviaPipelineParams.destinationOverride; the override is shallow-merged onto a copy of the template before the insert call, so the template file on disk is never mutated. Extractedpropertiesare threaded viaInsertParams.propertiesand written into the note's frontmatter viaapp.fileManager.processFrontMatterinnewFilemode only (see Note properties below). An extractedtitle(fromtitleFromContent) is threaded viaInsertParams.titleand shapes thenewFilefilename via the{{title}}token / whole-name replacement (see Note title below).
The pipeline accepts an AbortSignal (forwarded to providers) and is consumed by src/ui/modal.ts, src/ui/quick-record.ts, and src/ui/text-source.ts (the runTextPipeline helper for command + editor-menu entry points). Every caller passes the plugin itself as host: PipelineHost; PipelineHost is a narrow interface ({ assistantPrompt, knownNouns }) so cleanupTranscript can read the loaded vault content without importing ReWritePlugin (which would form a circular dep through the UI layer).
The PipelineSource union has three variants: audio (recorded blob, optional sourcePath for reprocess flows), paste (textarea input), text (input from an existing note via selection or whole body). Text-source flows skip transcription entirely and only require the LLM half of the profile. The audio variant's sourcePath is set by the reprocess flow (src/ui/audio-source.ts) to point at an existing vault file; when present, the persist stage is skipped and that path is reused for the ![[<path>]]\n\n prepend.
Provider system
src/transcription/index.ts and src/llm/index.ts each define a small interface plus a create...Provider(id) factory. Provider families share one adapter file where the API shapes match: OpenAI Whisper, openai-compatible, and Groq all dispatch into src/transcription/openai.ts with different base URLs; OpenAI GPT, openai-compatible, and Mistral all dispatch into src/llm/openai.ts. Mistral Voxtral does NOT share with openai.ts (see src/transcription/mistral-voxtral.ts) because Voxtral's response is JSON-only (no response_format=text) and it rejects WebM input (so the blob is always transcoded to 16 kHz mono WAV via src/audio-transcode.ts).
API keys are stored per profile on EnvironmentProfile.transcriptionConfig.apiKey / llmConfig.apiKey. Two slots per profile, one for transcription and one for the LLM. No global by-family map; the desktop and mobile profiles each carry their own keys even when both use the same provider (deliberate: per-profile keys make per-function usage tracking easier). Persistence is in src/secrets.ts using the key IDs profile:desktop:transcription, profile:desktop:llm, profile:mobile:transcription, profile:mobile:llm.
Providers may optionally implement listModels(config, signal) returning a string array of model IDs the configured API key can access. Implemented by: OpenAI / Groq / Mistral (via openai.ts shared adapter), Mistral Voxtral (own adapter, filters Mistral's /v1/models catalog by ID substring voxtral), Anthropic, Gemini, Deepgram. Not implemented for openai-compatible (URL-specific, list-shape varies), AssemblyAI, Rev.ai. The settings tab caches results to GlobalSettings.modelCache per side and provider ID; the Refresh button in the model field triggers listModels and updates the cache. The model field is a single adaptive control (populateModelField in src/settings/tab.ts, shared by both sides via a side: 'transcription' | 'llm' arg): a dropdown when the provider supports listModels and the cache is non-empty, otherwise a plain text field. There is never both at once. The dropdown carries a trailing "Custom..." option (sentinel CUSTOM_MODEL_OPTION, never written to config.model) that toggles the same control into the text field (the forceText arg) so a model ID not in the catalog can still be typed; a "Back to list" button returns to the dropdown. A custom value not in the cache is shown as a selected <id> (custom) dropdown option. The Refresh button accompanies the dropdown (and the empty-cache text field, so the first fetch can flip it to a dropdown); on success the field re-renders in dropdown mode. openai-compatible / AssemblyAI / Rev.ai (no listModels) always show a plain text field with no Refresh. For AssemblyAI and Rev.ai the field description appends a "list of models" external link to the provider's docs (transcriptionModelDocsUrl + applyModelFieldDesc in src/settings/tab.ts); openai-compatible has none (the model list is the user's own server). Whichever control is active, the canonical source is profile.config.model.
Speaker diarization
Opt-in per-profile flag TranscriptionConfig.diarize?: boolean (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), true only for assemblyai / deepgram / revai. The settings tab (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 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 sets speaker_labels: true and formats the returned utterances[] (Speaker A: ..., native letter labels); 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 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) telling the LLM to preserve Speaker X: prefixes; the Podcast default template already tolerates labeled and unlabeled input.
Local whisper.cpp host (desktop)
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 discovery, no PATH lookup, no auto-download.
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; 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) 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 (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 (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 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 getSafeStorage pattern; on mobile the host is inert and the provider option is filtered out of dropdowns in src/settings/tab.ts and src/ui/setup-card.ts.
Settings
GlobalSettings (defined in src/types.ts) is the shape of data.json. Loading flow:
plugin.loadData()returnsPartial<GlobalSettings> | null.mergeSettings(DEFAULT_SETTINGS, stored)deep-merges, preferring stored values.hydrateSecrets()reads keys fromsecrets.json.nosyncand writes them into each profile'stranscriptionConfig.apiKey/llmConfig.apiKey.
Saving flow strips secrets out of data.json and writes them to secrets.json.nosync instead (see src/secrets.ts). Never persist API keys to data.json.
Secrets encryption
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-partyapp.secretStorage(GA 1.11.4): a shared, cross-plugin, OS-encrypted store (Keychain/DPAPI/libsecret under the hood, the same backend ElectronsafeStorageused). Default/preferred when available. Values live in Obsidian's store, NOT insecrets.json.nosync(the on-disk envelope in this mode is just{ version, mode: 'secretStorage', keys: {} }to record the mode). Keys are namespaced withmanifest.id(rewrite-voice-notes:profile:desktop:transcription) because the store is shared across plugins. The API is reached via a narrowSecretStorageLikeinterface + cast (the installed typings predate it) that normalizesgetSecret/get,setSecret/set,listSecrets/list, anddeleteSecret/removeSecret/delete(falling back tosetSecret(id, '')when no delete exists); each method may be sync or async, so callersawait. Availability is a round-trip self-test (setSecreta 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 inonloadBEFOREgetEncryptionStatus, so the synchronousdefaultEnvelope()can prefersecretStorageon 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.nosyncfile); 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 (viahash-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 envelopekdf(see below). Each value is stored as<iv-b64>.<ct-b64>(12-byte random IV per value) insecrets.json.nosync. Averifierfield stores an encryption ofVERIFIER_PLAINTEXTso unlock can validate the passphrase without decrypting user keys. Works on every platform including mobile and Linux-without-keyring; the always-available fallback whensecretStorageis not. Entropy gate:changeEncryptionMode/changePassphrasereject a passphrase scoring belowMIN_PASSPHRASE_SCORE(3 of 4) via src/passphrase-strength.ts (zxcvbn-ts wrapper); this is the hard, non-bypassable gate. The passphrase modal (src/ui/passphrase-modal.ts) surfaces a live strength meter and a Generate button (6-word EFF-wordlist diceware via src/diceware.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 pulls the packages via dynamicimport()(not a static top-level import), keeping them out of plugin-startup evaluation;evaluatePassphrase/isPassphraseAcceptableare therefore async, and the modal callswarmPassphraseStrength()on open so the first keystroke does not pay the build cost. The bytes stay inmain.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 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:
{ "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/loadKeyreturn empty strings (no error).saveManyKeysis a no-op (so calls tosaveSettings()from unrelated UI changes do not clobber the on-disk encrypted values with empties).saveKeythrows.- All entry points (src/ui/modal.ts, src/ui/quick-record.ts, src/ui/text-source.ts, src/ui/audio-source.ts) check
plugin.encryptionStatus.lockedand callplugin.promptUnlock()instead of proceeding.promptUnlockbranches onconfigured: a configured envelope opens the unlock modal; an unconfigured one opens a create-passphrase modal (requireConfirm+enforceStrength) that callschangeEncryptionMode(this, 'passphrase', pass). The entry points need no change since unconfigured reportslocked === 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 setSecrets 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.
Templates
Templates are Markdown files in a vault folder, not entries in data.json. The folder path lives on GlobalSettings.templatesFolderPath (default ReWrite/Templates). Each .md file in the folder is one template: YAML frontmatter holds id, name, insertMode, newFileFolder, newFileNameTemplate, plus the three optional boolean flags disableSharedCore, enableContextHint, and diarize; the file body is the LLM prompt. Files are sorted by basename in the modal/picker so users can prefix names (01-..., 02-...) to control order.
src/templates-folder.ts exports loadTemplatesFromFolder(app, folderPath), populateDefaultTemplates(app, folderPath), and updateDefaultTemplates(app, folderPath) (see Updating defaults below). The plugin keeps a cache on plugin.templates: NoteTemplate[], refreshed in src/main.ts on:
workspace.onLayoutReadyafteronload(initial load, after the vault is ready)- vault
create/modify/deleteevents scoped to the templates folder viaisPathInTemplatesFolder - vault
rename(checks both old and new path) - the Templates folder path field changing in settings
- the populate button completing
Consumers (src/main.ts, src/ui/modal.ts, src/ui/quick-record.ts) read plugin.templates directly, never settings.templates (there is no such field). The populate button is non-destructive: it skips any default template whose id already exists on disk, and skips path collisions. Frontmatter id is canonical for identity, so renaming a file does not break the defaultTemplateId / lastUsedTemplateId reference. The first-launch experience is empty templates plus a setup nudge in the modal; the user clicks Populate to get the defaults.
The 10 defaults (src/settings/default-templates.ts) are General cleanup, Todo list, Daily note, Meeting notes, Meeting transcript, Idea capture, Lecture, Podcast, Guides, Book log. Each default template carries ONLY its per-template rules; the shared cleanup preface (guardrail + condensed cleanup + output discipline) is NOT baked into the prompt strings. It lives in the vault SharedCore.md file and is prepended at runtime by the pipeline (see Shared core below). General cleanup carries the full detailed prose-polishing ruleset as its body; the structured templates carry only their section layout. The Daily note default is a prompt-only structured fill (extracted ## Calendar / ## Goals / ## Tasks, each omitted when empty, then ## Braindump = the full cleaned transcript last); no NoteTemplate schema change was needed to drive it. Meeting transcript is the Meeting notes prompt adapted for speaker-labeled input, shipped with diarize: true so it forces diarization on (see Speaker diarization). Guides turns a spoken walkthrough into a step-by-step how-to (insertMode: newFile, enableContextHint: true); its prompt prescribes a strict two-level list format (ordered top-level steps, - bulleted sub-steps indented one tab, no deeper nesting, no a./i./* markers) because LLMs reliably mangle nested ordered lists, plus a trailing ## Gaps section for anything ambiguous or missing. Book log (insertMode: newFile, enableContextHint: true) turns spoken notes into a short book-log body. The five structured newFile defaults ship noteProperties (see Note properties below): Meeting notes / Meeting transcript carry subject / participants / date, Lecture carries subject / lecturer / course, Podcast carries podcast / episode / host / guests, Guides carries topic / tool, and Book log carries title / author / series.
NoteTemplate.disableSharedCore?: boolean (frontmatter disableSharedCore: true) opts a single template out of the shared-core prepend. renderTemplateFile ALWAYS emits the key so the knob is discoverable: empty (disableSharedCore:, parses to null = not disabled) by default, disableSharedCore: true when set. parseTemplateFile treats it as disabled only when the value is boolean true or the string "true" (case-insensitive) — the string form is tolerated because Obsidian's Properties UI may store an edited value as text; any other value (null/empty/false) means not disabled. The three opt-in boolean flags enableContextHint (see Context hint), diarize (see Speaker diarization), and titleFromContent (see Note title) follow the exact same render/parse convention (always-emitted empty key, boolean-or-"true" tolerance); their polarity is positive (set to turn ON), the reverse of disableSharedCore.
The Templates "Populate" button also seeds SharedCore.md when missing (non-destructive), because the shared core is load-bearing for the default templates' quality (it carries their guardrail + output discipline). Populating templates without it would yield prompts with no guardrail. It additionally writes a human-facing "Template guide.md" via populateTemplateGuide (src/template-guide.ts): a Markdown doc explaining the frontmatter properties, how the shared core combines with a template at run time, and how to word prompts. The guide is placed in the PARENT of the templates folder (the ReWrite root by default; vault root if the templates folder has no parent), NOT inside the templates folder, so loadTemplatesFromFolder never tries to parse it as a template. The plugin never reads or caches the guide and never sends it to a provider; it has no loadX/isPathX/cache/refresh, only populateTemplateGuide (non-destructive, skipped when the file exists). Keep DEFAULT_TEMPLATE_GUIDE in sync with the template structure. Whenever you add or change a NoteTemplate field / frontmatter property, change the prompt-assembly order, alter what the shared core carries, or change the default-template set or count, update the guide in the SAME change: add the property to its frontmatter table AND give the behavior its own ## section (the guide already has dedicated sections for the shared core, Context hint, Speaker identification, and Updating defaults). The guide is the user-facing contract for the template format; treat updating it as part of the task, not a follow-up.
Updating defaults
The Templates section ships three buttons sharing one Setting row: Populate (.setCta() primary), Update, and Load prior versions (both secondary). Populate only ever adds missing files, so it can never push a changed default (new field, new noteProperty, reworded prompt) into a file the user already has. Update (updateDefaultTemplates(app, folderPath)) reconciles the user's default-derived files against the current built-ins; Load prior versions (loadPriorTemplateVersions(app, folderPath)) drops earlier shipped prompt versions in as their own selectable templates for comparison.
Default history. src/settings/template-history.ts holds TEMPLATE_HISTORY: Record<string, TemplateVersionSnapshot[]> — the plugin's in-code memory of what each default used to be, keyed by template id, each snapshot carrying { version, template }. It is the base for the merge and the source for Load prior versions. It starts EMPTY. MAINTENANCE RULE (treat like the keep-the-guide-in-sync rule): whenever you change a default in src/settings/default-templates.ts, append the OUTGOING template to TEMPLATE_HISTORY[id] under the manifest version it shipped in. Without the snapshot, Update cannot tell an unedited old prompt from a user edit and falls back to a body conflict. priorVersionsForId(id) and allPriorVersions() return clones so callers can't mutate the registry.
Per-field 3-way merge. mergeTemplate(onDisk, def, priors) (pure, synchronous; priors = priorVersionsForId(id)) treats a field as pristine (user never touched it) when its on-disk value equals the current default OR any prior shipped default; a pristine value is brought forward to the current default, a genuine edit is kept. The prompt body is the only field whose kept edit becomes a report body conflict — an unedited old body is silently updated (recorded in changes). Scalars + flags adopt-or-keep silently (a real adoption is noted in changes). noteProperties union by name: missing defaults appended; an instruction matching a prior default is brought forward; a genuinely edited instruction is kept + changedInstruction; a property the default dropped is always kept + removedProperty (with wasShippedDefault set when the user's value matches a prior default, so the report can say it is safe to delete). Because renderTemplateFile always emits the four flag stubs and emits noteProperties only when non-empty, re-rendering a pristine current default is byte-identical to Populate's output, so an already-current folder is a clean no-op.
updateDefaultTemplates walks the folder, matches files to defaults by frontmatter id (via readTemplateId, also now backing collectExistingIds; non-default-derived files skipped), merges, and vault.modifys in place only when the render differs (CRLF-normalized compare; never renames, even with an ordering prefix). It then recreates any default missing from disk entirely (superset top-up, same name + path-collision skip as Populate) but does NOT seed SharedCore.md or the guide (Populate's job). Counters are mutually exclusive: created + updated + unchanged + conflicts + parseFailed = files seen. A default-derived file that fails to parse is left untouched and reported as parseFailed.
Load prior versions writes each allPriorVersions() snapshot as a standalone template with a distinct id (<id>@<version>) and versioned name (<name> <version>), so it appears in the picker for comparison, never collides with the live template's identity, and is left untouched by Update (its id is not a current default) and Populate. Non-destructive (skips by id/path); reports available so the button can say "no prior versions yet" when the registry is empty.
Anything Update cannot auto-merge is written to Template update report.md via writeTemplateUpdateReport (src/template-guide.ts, TEMPLATE_UPDATE_REPORT_FILENAME), placed next to the guide (parent of the templates folder, OUTSIDE it so loadTemplatesFromFolder never parses it) and overwritten every run. It lists, per non-unchanged template: changes applied automatically, a body conflict (user file kept, default shown beside it in ~~~text fences), removedProperty / changedInstruction / parseFailed. The report types (TemplateUpdateConflict / TemplateUpdateEntry / UpdateResult) live in src/templates-folder.ts; template-guide.ts imports them type-only, so templates-folder.ts → template-guide.ts is the one runtime edge (acyclic). Caveat (documented in the guide and the report footer): a vault.modify re-serializes the frontmatter via stringifyYaml, which drops YAML comments; the prompt body is passed through untouched, and an already-current file is never rewritten.
Shared core
A vault Markdown file whose body is prepended to every template prompt at cleanup time. Path: GlobalSettings.sharedCorePath (default ReWrite/SharedCore.md). src/shared-core.ts exports loadSharedCoreFromFile(app, path), populateDefaultSharedCore(app, path), isPathSharedCore(path, configuredPath), and the DEFAULT_SHARED_CORE constant (the three-paragraph preface: anti-injection guardrail, condensed cleanup rules, output discipline) used as the populated file body. The loader strips any leading frontmatter (guidance only, never sent to the LLM) and returns the trimmed body, or null when the file is missing/empty. The plugin caches it on plugin.sharedCore: string | null, refreshed in src/main.ts on the same triggers as the assistant prompt (workspace.onLayoutReady, scoped vault create/modify/delete/rename, settings-path change, populate button).
src/pipeline.ts cleanupTranscript prepends it: systemPrompt = sharedCore ? ${sharedCore}\n\n${template.prompt} : template.prompt, where sharedCore = template.disableSharedCore ? null : host.sharedCore. So the order in the assembled system prompt is shared core → template prompt → ad-hoc instructions → known nouns. Deleting or emptying SharedCore.md disables it globally (no fallback to a baked-in default; null means inject nothing); disableSharedCore: true disables it for one template. PipelineHost gained sharedCore: string | null alongside assistantPrompt / knownNouns.
Injection caveat: vault and transcript text (the assistant prompt, wake-name ad-hoc directives, known nouns, and the cleaned input itself) all flow into the system prompt unescaped. The shared-core preface is the anti-injection defense, so a template with disableSharedCore: true runs without that guardrail. This is intentional (the user owns their vault), but renderTemplates in src/settings/tab.ts surfaces a .rewrite-warning-text line naming any loaded template whose disableSharedCore === true so the loss of protection is visible.
The settings "Shared core" section (renderSharedCore in src/settings/tab.ts) shows an Enabled/Disabled badge next to the heading (.rewrite-status-badge.is-enabled / .is-disabled, inserted into the heading's nameEl, driven by plugin.sharedCore !== null). Like the other settings status text, it reflects state as of the last full-container render, not live keystrokes in the path field. The Templates "Populate" button calls populateDefaultSharedCore after populateDefaultTemplates so a first-run Populate seeds the shared core too (it is load-bearing for the default prompts' guardrail/output discipline); non-destructive, skipped when the file exists.
Assistant prompt
The system-prompt preface inserted above extracted ad-hoc directives lives as a Markdown file in the vault, not a settings textarea. Path: GlobalSettings.assistantPromptPath (default ReWrite/AssistantPrompt.md). src/assistant-prompt.ts exports loadAssistantPromptFromFile(app, path), populateDefaultAssistantPrompt(app, path), isPathAssistantPrompt(path, configuredPath), and the DEFAULT_ASSISTANT_PROMPT constant used as the fallback when the file is missing or empty. The plugin caches the body on plugin.assistantPrompt: string | null, refreshed in src/main.ts on the same triggers as templates (workspace.onLayoutReady, scoped vault create/modify/delete/rename, settings-path change, populate button). The file body is the prompt; frontmatter is currently ignored (the loader tolerates it for future extensions). src/pipeline.ts reads params.host.assistantPrompt ?? DEFAULT_ASSISTANT_PROMPT inside cleanupTranscript.
Branding note: the "AI name" / "Agent control prompt" labels in older builds are replaced by "Assistant name" / "Assistant prompt file" everywhere. The wake-name feature itself keeps the term "wake name" (it's the trigger word, not the persona). The setting key is assistantName; the persona is "the assistant".
Known nouns
A vault Markdown file of proper nouns the LLM should preserve verbatim. Path: GlobalSettings.knownNounsPath (default ReWrite/KnownNouns.md). src/known-nouns.ts exports loadKnownNounsFromFile, populateDefaultKnownNouns, isPathKnownNouns, and buildKnownNounsSystemPromptSection(nouns). File format: YAML frontmatter for human-readable guidance (token-cost warning, format hint), Markdown body with one noun per line, optional canonical: alt1, alt2 for misheard variants. Lines starting with # and blank lines are skipped. Frontmatter is parsed but NOT sent to the LLM in v1; future opt-in is possible but should not become the default. The default file body includes both the guidance frontmatter and two illustrative example nouns.
Cache: plugin.knownNouns: KnownNoun[] (default []), refreshed on the same triggers as the assistant prompt. src/pipeline.ts appends the section returned by buildKnownNounsSystemPromptSection(host.knownNouns) to the system prompt when non-empty. Order in the assembled prompt: shared core, template prompt, ad-hoc instructions (if any), context hint (if any), known nouns (if any), note properties (if any).
Note properties
A template can declare frontmatter properties the LLM fills in from the content, written into the new note's YAML frontmatter. NoteTemplate.noteProperties?: NotePropertySpec[] (src/types.ts, NotePropertySpec = { name; instruction }). Authored in template frontmatter as a YAML map (key = property name, value = instruction string), parsed into an ordered array (object key order drives both the prompt and the write order). Unlike the three boolean flags, renderTemplateFile emits noteProperties ONLY when non-empty (a nested map has no useful always-empty stub); parseTemplateFile reads any object-map value, trims keys, coerces missing instructions to "", and skips blank keys (src/templates-folder.ts).
Pipeline (src/pipeline.ts): cleanupTranscript returns { body, properties, title? }. When template.noteProperties is non-empty OR wantsTitle is set (and llmProvider !== 'none'), a ## Note properties section is appended LAST in the system prompt (after known nouns) listing the exact keys + instructions and telling the model to emit ONE leading ```yaml block containing exactly those keys, then a blank line, then the note body. That section explicitly overrides the shared-core "no code fences / output only the note" output discipline, but only for the single leading block. extractFromBlock(raw, specs, wantsTitle) then: seeds properties with every declared key = '' (the scaffold), matches a leading ```yaml fence, parseYamls it, and overlays values for declared keys only (extras ignored, non-strings String()-coerced, null/undefined skipped); the reserved noteTitle key is pulled into title (when wantsTitle) and is NOT added to properties. When no block is present the whole output is the body. When a block IS present it is ALWAYS stripped from the body even if its YAML is malformed (the model emitted a properties block, not content); a strict parseYaml failure falls back to a tolerant line-based read (key: value lines, stripQuotes peels matched/stray quotes) so one bad value does not blank the whole scaffold. The prompt steers the model with an explicit unquoted example and "leave unknown values blank" wording.
Write side: runPipeline extracts BEFORE prepending the ![[audio]] embed (otherwise the embed pushes the YAML off byte 0), builds finalContent from body, and threads properties + title via InsertParams (src/insert.ts). Only insertNewFile applies properties, via app.fileManager.processFrontMatter after vault.create (which prepends a real ---...--- block above the embed); cursor / append ignore them (they write into a user-owned existing note). Scope is newFile-only by design (matches the "new files" use case; never silently rewrites an existing note's frontmatter). The Meeting notes, Meeting transcript, Lecture, Podcast, Guides, and Book log defaults ship with noteProperties.
Note title
A template can have the LLM name the new note from the content (and context). NoteTemplate.titleFromContent?: boolean (src/types.ts), parsed/rendered with the same always-emitted-stub convention as the other opt-in flags. The title rides the SAME leading ```yaml channel as Note properties, under the reserved key noteTitle (module constant RESERVED_TITLE_KEY in src/pipeline.ts). Activation: the block is requested when noteProps.length > 0 || wantsTitle, where wantsTitle = template.titleFromContent && !allowed.has('noteTitle') (a user property literally named noteTitle wins and is written to frontmatter; title-from-content is then disabled so the key is never double-defined). extractFromBlock returns the value on CleanupResult.title and deliberately keeps noteTitle OUT of the property allowed set, so it is never written to frontmatter.
Filename use (newFile only; cursor / append ignore InsertParams.title, symmetric with properties): generation is always requested when the flag is set and consumed only in insertNewFile, so it does not depend on destinationOverride (which can change insertMode after cleanup). src/insert.ts expandFilenameTemplate(template, title?) adds a {{title}} token; insertNewFile substitutes it when present (then collapses doubled spaces a missing token leaves and trims), or makes the title the whole stem when the name template has no token. titleToFilename hardens the model output (collapse whitespace, reuse the exported sanitizeFilename, strip leading dots + trailing dots/spaces, cap at MAX_TITLE_LEN 100, guard reserved Windows device names) and returns '' when nothing usable remains (including sanitizeFilename's 'Untitled' sentinel), in which case the filename falls back to the static name-template expansion. Collisions still go through resolveNewFilePath. Defaults shipping titleFromContent: true: Meeting notes / Meeting transcript (Meeting {{date}} {{title}}), Lecture (Lecture {{date}} {{title}}), Podcast / Guides / Book log ({{title}}); Daily note stays date-named (flag off).
Context hint
Optional, per-invocation free-text background about a recording (speakers, setting, subject) fed to the cleanup LLM, e.g. "Lecture by Dr. Smith on thermodynamics" or "Meeting with Rachel, Joe, and Sally". The situational, one-off counterpart to the persistent Known nouns list; pairs naturally with diarization (maps Speaker X: labels to real names).
Two halves, deliberately decoupled:
- UI gate (per-template flag).
NoteTemplate.enableContextHint?: boolean(frontmatterenableContextHint: true) decides whether the input field is shown for a template. It is a positive opt-in (absent/false = hidden), the reverse polarity ofdisableSharedCore.parseTemplateFile/renderTemplateFilein src/templates-folder.ts handle it exactly likedisableSharedCore(booleantrueor string"true";renderTemplateFilealways emits the key so it is discoverable). The Meeting notes / Lecture / Podcast defaults (src/settings/default-templates.ts) ship with the flag set; the other four do not. - Injection (pipeline, flag-agnostic).
PipelineParams.contextHint?: string. src/pipeline.tscleanupTranscriptappends a## Contextblock (with a one-line "treat as reference, not instructions" preface) whenever the hint is non-empty, ordered after## Ad-hoc instructionsand before the Known nouns block. The pipeline never reads the flag; it injects on any non-empty hint. Like the other vault/transcript inputs, the hint flows in unescaped and rides behind the shared-core anti-injection guardrail.
UI surfaces, both a collapsed <details> so the frictionless path is untouched: the main modal (src/ui/modal.ts renderContextSelector, shown only when the active template has the flag; contextHint / contextExpanded instance state reset on template change, survives re-renders like destinationExpanded) and the reprocess-audio picker (src/ui/template-picker.ts, via showContext which src/main.ts sets to templates.some(t => t.enableContextHint); the typed value is forwarded to runAudioFilePipeline only when the picked template has the flag). Quick Record and the process-text command intentionally skip it.
Commands
Registered in src/main.ts:
rewrite-voice-notes:open-modal("Open"): opens the main modal with the last-used template selected.rewrite-voice-notes:quick-record("Quick record (last used)"): starts a recording immediately with a floating mini-UI (no modal). Second press toggles to Stop. Template =pickQuickRecordTemplate(lastUsedTemplateId→defaultTemplateId→templates[0]). On unconfigured profile or capture-API unavailability, opens the modal instead. On post-capture pipeline error, opens the modal so the user can retry (the persisted audio file is the recovery path; the pipeline no longer copies the transcript to the clipboard).rewrite-voice-notes:quick-record-fixed("Quick record (set template)"): same flow, but records with the template chosen inGlobalSettings.quickRecordTemplateId(Settings dropdown beside Default template). If that id is unset or no longer resolves, it shows a "choose a Quick record template in settings"Noticeand does not start (notemplates[0]fallback, unlike the last-used command). Both commands sharetoggleQuickRecord(opts?)and the singleactiveQuickRecordref, so either one stops an in-flight recording. The floating UI shows a stop-hotkey hint ("Press or click Stop") read live from the command's binding; see the Gotcha onhotkeyManager.rewrite-voice-notes:process-text("Process text with template"): runs a template over the active editor's selection (or the whole note body if there's no selection). Opens a template quick-picker, then runs the pipeline in the background with progress shown viaNotice. Gates on LLM-only configuration; opens the main modal's setup card when not configured. Bails with a Notice when no Markdown editor is active.rewrite-voice-notes:reprocess-audio("Reprocess audio file with template"): reruns the pipeline over an audio file already in the vault. Opens anAudioFilePickerModal(FuzzySuggestModal<TFile>filtered toAUDIO_EXTENSIONSfrom src/audio-persist.ts) then the template quick-picker, then callsrunAudioFilePipelinein src/ui/audio-source.ts. The pipeline skips itspersist-audiostage because theaudiosource variant carries asourcePath(the existing vault path is reused for the![[<path>]]\n\nprepend). Gates on the full voice profile (isProfileConfigured).rewrite-voice-notes:start-whisper-host/rewrite-voice-notes:stop-whisper-host: start or stop the local whisper.cpp server. Both usecheckCallbackso the palette only shows them on desktop, when the active profile's transcription provider iswhisper-local(start) or when the host is currentlyrunning/starting(stop). Errors surface viaNotice. Same code paths as the settings-tab Start/Stop button.
Plus an editor-menu item "ReWrite with template..." registered via workspace.on('editor-menu', ...) (and a second "Reprocess audio with template..." item that appears only when the cursor sits inside an ![[<audio>]] embed, resolved via app.metadataCache.getFirstLinkpathDest), a workspace.on('file-menu', ...) handler that adds "Reprocess audio with template..." for audio files in the file explorer, an addRibbonIcon('mic', 'ReWrite', ...) that opens the modal, and a status-bar item (src/ui/whisper-status-bar.ts) showing the live whisper-host status. The status bar polls whisperHost.status() every 1 s via registerInterval, click toggles start/stop, and the item is hidden via the rewrite-hidden CSS class when on mobile or when the active profile is not whisper-local.
Code style
Per .editorconfig: tabs (width 4), LF, UTF-8, final newline. Matches the existing source.
Obsidian plugin conventions
AGENTS.md has the full Obsidian-specific playbook. The non-obvious rules that actually constrain implementation:
- Never change
manifest.json'sidafter release. It'srewrite-voice-notes. Locked. (Renamed once pre-release from the invalidrewrite-plugin, which Obsidian's manifest rules reject because an id may not end inplugin; no users were affected. Do not change it again.) - Use
this.register*helpers (registerEvent,registerDomEvent,registerInterval) for anything that needs cleanup. Otherwise reload/unload leaks. The Quick Record floater is the one exception (adocument.bodydiv lifecycled byQuickRecordController.cancel(), whichonunloadcalls). - Mobile compatibility: avoid Node/Electron APIs unless
manifest.jsonsetsisDesktopOnly: true. It'sfalse, and the spec's mobile profile depends on this. - Keep src/main.ts minimal: only plugin lifecycle, command registration, settings tab registration. Feature logic belongs in dedicated modules.
- Defer heavy work: no long tasks in
onload. Providers/recorders lazy-init when first used. - Network policy: provider calls go to user-configured endpoints with user-provided keys. No telemetry, no auto-update of plugin code, no
fetch+eval. - Releases: GitHub release tag must exactly match
manifest.json'sversion(no leadingv). Attachmain.js,manifest.json,styles.cssas individual binary assets (not zipped). This is automated by .github/workflows/release.yml: pushing a version tag builds the bundle, runsactions/attest-build-provenance(GitHub artifact attestations for provenance), and publishes the assets viasoftprops/action-gh-release. To cut a release, push a tag named exactly the version; do not hand-upload assets (that loses attestation).
Gotchas
requestUrlmultipart bodies are hand-built.requestUrldoes not acceptFormData. src/http.ts exportsbuildMultipart(parts)which produces aUint8Arraywith a random boundary; transcription adapters (Whisper, Rev.ai) call into it. If you add a multipart-LLM provider, reuse this rather than reaching forFormData.requestUrlusesthrow: false+ status check. All adapters surface non-2xx asProviderErrorwithstatusandbody, 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, andx-goog-api-keyfor Gemini). Do not add a?key=/?token=query-auth provider: query strings leak into proxy/CDN logs, history, andrequestUrl's network-failure message. As a backstop, the network-failurecatchin src/http.tsproviderRequestrunsredactQueryStringsover the message (replaces any?...with?<redacted>) before building theProviderError, so even a future query-auth slip cannot surface a secret in a Notice or log. Thebody.slice(0, 200)truncation in theProviderErrorconstructor is unrelated and left as-is (response bodies do not carry the request key). app.secretStorageis reached via a narrow cast + round-trip self-test, never assumed present. src/secrets.tsgetSecretStorage(plugin)castsplugin.appto a localRawSecretStorageshape and normalizes the method aliases;probeSecretStoragewrite/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 inonloadviawarmSecretStorageso the synchronousdefaultEnvelope()can prefer it on first run. Keys are namespaced withmanifest.id; never store under a bare id (the store is shared across plugins).saveManyKeysis a silent no-op when locked. Whenmode === 'passphrase'andunlockedKey === null,saveManyKeysdoes nothing. This is deliberate: unrelatedplugin.saveSettings()calls (e.g. user changes a model dropdown) would otherwise persist emptyapiKeyvalues for every profile, wiping the on-disk encrypted bag. The UI prevents this by disabling key fields and gating all pipeline entry points onencryptionStatus.locked.saveKey(single-key write) still throws so callers can react.- Keep zxcvbn lazy and the strength API async. src/passphrase-strength.ts pulls
@zxcvbn-ts/*via dynamicimport()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 synchronousevaluatePassphrase— that re-adds the cost to every Obsidian launch on every device. The async ripples (modalupdateStrengthrace guard,await isPassphraseAcceptableinchangeEncryptionMode) are intentional.warmPassphraseStrength()is fired on passphrase-modal open to hide the one-time build behind the modal animation. maxTokenshas two settings-tab views over one value.LLMConfig.maxTokens(per profile, default2560) is the single source of truth. The normal-area "Maximum note length" dropdown (renderNoteLengthin src/settings/tab.ts) frames it in minutes viaNOTE_LENGTH_PRESETSatTOKENS_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 callsthis.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'sconfig.maxTokens > 0 ? ... : 2560fallback (src/llm/anthropic.ts) must stay in sync with the default. The cap is on output tokens, so it bounds note length, not input.maxTokensover 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), applied as a.catch()on thecomplete()POST in src/llm/anthropic.ts and src/llm/openai.ts, detects that specific 400 (body namesmax_tokens/max_completion_tokens+ a "maximum/too large/at most/exceed" phrase) and rethrows aProviderErrorpointing at the "Maximum note length" setting; all other errors pass through unchanged. Itsneverreturn preserves the awaited type. Gemini silently clampsmaxOutputTokensinstead of erroring, so it gets no remap (and can therefore truncate without warning on long notes).- OpenAI reasoning models need
max_completion_tokens, notmax_tokens. src/llm/openai.tsusesCompletionTokens(id, model)switches the param name tomax_completion_tokenswhenid === 'openai'and the model matches/^(o\d|gpt-5)/i(o1/o3/o4 + gpt-5 families), which reject the legacymax_tokens. Scoped to the first-partyopenaiid only;openai-compatibleandmistralkeepmax_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 rejectsystemmessages, 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 (foropenai-compatible) base URL. If you add a provider, do not bake a default model string; surface it as placeholder hint text. openai-compatiblebase URL asymmetry (literal interpretation of the spec): transcription appends/v1/audio/transcriptionsto a root URL (http://localhost:8080); LLM appends/chat/completionsto 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. Theopenai-compatibleLLM 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.setHeading()instead of manual<h2>inside settings tabs.obsidianmd/settings-tab/no-manual-html-headingsforbids manual headings. Same applies anywhere else inside a settings tab that needs a section header. In src/settings/tab.ts, section headings go through thesectionHeading(parent, name, icon)helper, which builds thesetHeading()Setting and prepends a Lucide icon (viasetIcon) to itsnameElstyled by.rewrite-heading-icon. It returns theSettingso callers that attach a status badge (profile, shared core) still reachnameEl. Add new headings via this helper, not a barenew Setting(...).setHeading(), so they keep an icon.window.confirmis banned by ESLint'sno-alert. If a future phase needs an in-vault confirmation prompt, add a smallModalsubclass rather than reaching forwindow.confirm.- Sentence-case lint covers brand and acronym lists in eslint.config.mts. Adding a new provider, model family, or product name means adding it to
REWRITE_BRANDS(orREWRITE_ACRONYMSfor things likeLLM). Dropdown option labels also pass through the rule; in src/settings/tab.ts and src/ui/setup-card.ts, labels are iterated viaopt.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 thedisplay()/this.display()redraw pattern as deprecated in favor ofgetSettingDefinitions, a declarative1.13.0+API that is not in our bundled typings. Migrating would forceminAppVersionto 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.tsrenderProfile()creates a wrapper div per profile rather than rendering settings as direct children ofcontainerEl. The active-on-this-device profile (perdetectActiveProfileKind) getsis-active-profile(accent left border) and a.rewrite-profile-active-badgespan inside the heading'snameEl. The inactive profile's body is wrapped in a<details class="rewrite-profile-collapsed">whose expand state lives onReWriteSettingTab.inactiveProfileExpandedso it survives the full-container redraws triggered by dropdowns. New per-profile settings must takebodyas their parent (the wrapper or the<details>), not the originalparentarg, or they will render outside the section's visual frame. - Both provider unions include
'none'. src/types.tsTranscriptionProviderIDandLLMProviderIDcarry a'none'member for users who only want one half of the pipeline. The factories in src/transcription/index.ts and src/llm/index.ts return sentinel providers (transcription throws ontranscribe(); LLMcomplete()returns the user message unchanged), but the pipeline never actually calls these because: (a)collectTranscriptthrows a friendlier error whentranscriptionProvider === 'none'andsource.kind === 'audio'; (b)cleanupTranscriptshort-circuits and returns the raw transcript whenllmProvider === '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/isProfileConfiguredForTexttreat'none'as configured. The modal's Record tab, Quick Record, and the reprocess-audio command all gate ontranscriptionProvider === 'none'with a "use Paste instead" hint. - Templates are vault files, not settings. There is no
settings.templatesarray. Consumers readplugin.templates(refreshed from disk). When you add a field toNoteTemplate, update src/templates-folder.ts on both sides:parseTemplateFilereads it out of frontmatter (with a sensible default if missing), andrenderTemplateFilewrites it into the frontmatter the populate button emits. The populate button is non-destructive: it skips files whoseidalready exists, so re-running it tops up the folder without clobbering user edits. - The two template frontmatter flags have opposite polarity.
disableSharedCoreis a negative opt-out (set it to turn a default OFF);enableContextHintis a positive opt-in (set it to turn a feature ON). Don't "harmonize" them.enableContextHintonly gates whether the modal / reprocess-picker shows the Context field; the pipeline injects a## Contextblock on any non-emptyPipelineParams.contextHintwithout 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
extractFromBlockstrips the leading```yamlblock off the LLM output insidecleanupTranscript, BEFORErunPipelineprepends![[<path>]]\n\n; prepending first would push the YAML off byte 0 and it would never parse as frontmatter. The parsed values are written viaapp.fileManager.processFrontMatterininsertNewFileonly (aftervault.create, beforeopenLinkText) so the real---...---lands above the embed;cursor/appendignoreInsertParams.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. noteTitleis filename-only — never a frontmatter property. The reservednoteTitlekey (titleFromContent) shares the one leading```yamlblock withnoteProperties, butextractFromBlockdeliberately keeps it OUT of the propertyallowedset and returns it onCleanupResult.title. Do NOT "fix" the extractor to also write it intoproperties/frontmatter; it exists to name the file ({{title}}token or whole-name replacement ininsertNewFile). It is the model's own generated string, independent of anytitlenoteProperty (e.g. Book log'stitleproperty and its filename title are separate axes and need not match). Caveat:resolveNewFilePathcollision detection is case-sensitive (getAbstractFileByPath), so on case-insensitive filesystems two titles differing only by case can still collide atvault.create; pre-existing, just likelier with content-derived names. See Note title.- Frontmatter parsing uses
parseYamlfrom Obsidian, not the metadata cache. The metadata cache is async and may not be populated for newly created files; reading content viaapp.vault.read(file), splitting off the leading---...---block, and parsing it withparseYamlis synchronous-enough and works immediately afterapp.vault.create. - Quick Record uses a custom floating div, not a
Notice. ObsidianNoticedoes not support real interactive buttons. The floater is aposition: fixeddiv ondocument.body, owned byQuickRecordController, withcancel()wired intoonunload. secrets.json.nosyncuses the.nosyncsuffix 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, not the recorder. src/recorder.ts does not cap recordings at any size;
validateRecording(blobSize, durationMs, providerId)runs in src/pipeline.ts between thepersist-audioandtranscribestages, throwing a friendly provider-attributed error if the recording exceeds the documented byte or duration ceiling. Both modal and Quick Record thread the recorder'sdurationMsonto theaudiopipeline source so the duration check has data; the reprocess flow (src/ui/audio-source.ts) omitsdurationMs(no cheap way to measure an arbitrary vault file), so reprocess only triggers the byte check. Limits source:openai/groq25 MB,assemblyai5 GB/10 h,deepgram2 GB,revai2 GB/17 h,mistral-voxtral1 GB/30 min,openai-compatible/whisper-local/webspeechno client-side cap. - Async poll timeout is duration-aware, also in src/transcription/limits.ts via
pollTimeoutMs(durationMs?). The two polling adapters (src/transcription/assemblyai.ts, src/transcription/revai.ts) used to share a flatPOLL_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.durationMsis threaded as the optional 4th arg ofTranscriptionProvider.transcribe(audio, config, signal?, durationMs?)from src/pipeline.ts (source.durationMs); the non-polling adapters ignore it (their impls simply omit the param). It isundefinedfor the reprocess flow (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.isDesktopguard. Importingchild_process/net/fsat module top would crash on mobile load. The cachednodeApiCacheisnullon mobile, so any host method that needs it bails with a clear "desktop only" error. splitArgsis a naive whitespace split. The local-whisperextraArgsvalue 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 bygetHostArgso a non-loopback--hostis 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 inrunGuardedButton(b, fn)in 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 amodeChangeInFlightflag for the same reason. Wrap new async-then-display buttons the same way. Each handler keeps its own try/catch; observabilityconsole.error('ReWrite: <context>', e)sits alongside the user-facing Notice in those catches (and in the swallowed catches in 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'alongsidestopped/starting/running/crashed;runningmeans we own it (spawned OR adopted) and Stop works,externalmeans 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 just asks forhost.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.jsonrecords{ pid, port, binaryPath, startedAt }once the server is ready.stop()and the childexithandler clear it.WhisperHost.probe(config)(called fromonloadand at the top ofstart()) 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. Usesprocess.kill(pid, 0)for liveness probing (added to the lazyNodeAPIcache alongsidecp/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, thestop-whisper-hostcommand is hidden viacheckCallback). This preserves the "never kill a process we didn't start" invariant — the sidecar is proof we started it. onunloadstops the whisper-host fire-and-forget.void this.whisperHost?.stop()— Obsidian'sPlugin.onunloadsignature 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 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 and src/ui/quick-record.ts callrecorder.cancel()beforerunPipeline(), 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) skips persistence by passingsourcePathon theaudiosource 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
MediaRecordercapture mid-recording. src/recorder.ts requestsnavigator.wakeLock.request('screen')instart()(and re-acquires inresume()), releases it inpause(), and tears it down viastopWakeLock()from the sharedreleaseStream()(so bothstop()andcancel()cover it). The OS auto-releases a screen wake lock whenever the document becomes hidden, so avisibilitychangelistener (registered instartWakeLock, removed instopWakeLock) re-requests on the nextvisibletransition whilestate === '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 localWakeLockLike/WakeLockSentinelLikeinterface +getWakeLock()cast (it is not in every TS DOM lib version), mirroring thehotkeyManagerpattern. Cost: the screen stays lit while recording (a partial CPU-only wake lock would need native code Obsidian doesn't expose to plugins). Astop/cancelthat races ahead of the asyncrequest('screen')is handled by re-checkingstate === 'recording'before retaining the sentinel. - Live silence detection is a Web Audio tap on the recording stream. src/recorder.ts builds an
AnalyserNodeoff the micMediaStreaminstart()(no connection todestination, so no monitoring feedback) and samples peak amplitude every 100 ms via awindow.setInterval. It exposesgetInputLevel()(0..1 peak),hasDetectedSound(), andgetSilentMs()(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 pollgetSilentMs()in their existing 250 ms timer loop and show a "No audio detected" warning pastSILENCE_WARNING_MS(3 s): the modal's Record tab (.rewrite-silence-warning, src/ui/modal.ts) and the Quick Record floater (.rewrite-quick-silence-warningbelow the controls row, src/ui/quick-record.tssetSilenceWarning, hidden while busy). The monitor is torn down inreleaseStream()(called bystop()/cancel()); any setup failure (noAudioContext, e.g. a stripped environment) degrades to "monitoring unavailable" and the recording proceeds without a warning. The thresholdSILENCE_LEVEL_THRESHOLD = 0.015clears even quiet speech but flags a muted or dead mic. - Wake-name extraction is regex-only, off by default. 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, includingpasteandtext. The extracted instructions are appended to the LLM system prompt as a numbered## Ad-hoc instructionsblock, prefaced byplugin.assistantPrompt(loaded fromGlobalSettings.assistantPromptPath); when the file is missing or empty,DEFAULT_ASSISTANT_PROMPTfrom 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.knownNounsPathuses YAML frontmatter purely for human-readable guidance (token-cost warning, format hint). Only the body lines are parsed vialoadKnownNounsFromFileand injected bybuildKnownNounsSystemPromptSection. 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 readsparams.host.assistantPromptandparams.host.knownNounsthrough the narrowPipelineHostinterface in src/types.ts. The plugin classimplements PipelineHost, but the pipeline never importsReWritePlugindirectly, which would create a cycle through the UI layer. New cross-cutting cleanup-stage inputs should extendPipelineHostrather than reach for the plugin object. - New-file collisions are resolved by
insert.ts, not the caller.GlobalSettings.newFileCollisionModeis'auto'(silently iteratename-1.md,name-2.md, ...) or'prompt'(openRenamePromptModaldefaulted to the next free path; Cancel throwsInsert canceled: file already exists.). Threaded throughInsertParams.collisionModefrompipeline.ts. The path search usesapp.vault.getAbstractFileByPathand caps at 1000 iterations.nextFreePathis local to src/insert.ts; the equivalentdeCollidein 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 renders a per-invocation Destination control (insertMode + conditional newFile fields) and threads the result through
PipelineParams.destinationOverride. src/pipeline.ts shallow-merges the override onto a copy of the template viaapplyDestinationOverridebefore callinginsertOutput; 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, orrunAudioFilePipeline(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 onReWriteModal.destinationExpandedso 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). The popover is a child of the floater div, listens for outside-click via a capture-phase
documentlistener and Escape viadocument keydown, and cleans up both listeners on dismiss. The popover dismisses on selection, Escape, outside click, and whensetBusyruns (the pipeline is in flight). Selecting a template updatescontroller.templatebut does NOT updatelastUsedTemplateId; 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 whenstartQuickRecordis passed acommandIdwhose binding resolves (see next Gotcha). - The stop-hotkey hint reads Obsidian's internal
app.hotkeyManager.formatCommandHotkey(app, commandId)in src/ui/quick-record.ts prefersgetHotkeys(id)(user binding) overgetDefaultHotkeys(id)(plugin default), formats it platform-aware viaPlatform.isMacOS, and returnsnullwhen unbound (the floater then shows only the Stop button, no placeholder text). The manager is not in the publicobsidiantypings, so it is reached through a narrow localHotkeyManagerinterface +as unknown ascast rather than disabling a lint rule. The hint is computed fresh each time a recording starts (commandIdis${manifest.id}:quick-recordor: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 viaregisterInterval. The host has no event emitter; both src/settings/tab.ts (re-render on Start/Stop click) and 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. - 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 (
visualViewportdoes not shrink, theresizeevent does not fire) to react to. The earlier JS helper (installMobileKeyboardScrollFix, which readvisualViewportand shrank.modal-container) was a confirmed no-op on the failing cases and has been removed. The fix lives entirely in styles.css: under.is-mobile, our modal classes (.rewrite-modal, which covers the main + passphrase modals, and.rewrite-rename-modal) getalign-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-contentgetspadding-top: 8pxand.is-mobile .rewrite-modal h2getsmargin-top: 0to reclaim the empty band Obsidian leaves above the title; (b) the Paste textarea renders atrows = 4on mobile (vs 10 on desktop, set in src/ui/modal.ts) with the desktop 160pxmin-heightfloor 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.tsrenderPassphraseTips) 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'sautofocusis disabled on mobile (autofocus = !Platform.isMobile) so the auto-collapse fires on the user's tap rather than a premature programmatic focus.
Local install for testing
Build, then place/symlink main.js, manifest.json, and styles.css into <Vault>/.obsidian/plugins/rewrite-voice-notes/ and reload Obsidian (Settings, Community plugins).
Never use em dashes in your own writing.
Do not use the em dash character in any prose, lists, code comments, or analysis you produce. Use commas, periods, parentheses, semicolons, or colons instead, whichever fits the sentence best. Exception: when directly quoting a source inside quotation marks, preserve em dashes exactly as they appear. Do not silently edit quoted text.
Why: Consistent formatting preference for original writing, while keeping quoted material faithful to the source.