From 58ff5a758206cb2cd96b3ad7e900ece8126e7a26 Mon Sep 17 00:00:00 2001 From: WiseGuru <42100212+WiseGuru@users.noreply.github.com> Date: Sat, 30 May 2026 19:41:39 -0700 Subject: [PATCH] Quick record feature --- CLAUDE.md | 15 ++++- docs/FEATURES.md | 17 ++++++ docs/PLUGIN_REVIEW.md | 87 ++++++++++++++++++++++++++++ src/http.ts | 32 +++++------ src/known-nouns.ts | 4 +- src/llm/gemini.ts | 8 +-- src/main.ts | 29 ++++++++-- src/pipeline.ts | 15 +---- src/secrets.ts | 17 ++++++ src/settings/index.ts | 1 + src/settings/tab.ts | 115 +++++++++++++++++++++++++++++++------ src/types.ts | 1 + src/ui/passphrase-modal.ts | 24 ++++++++ src/ui/quick-record.ts | 53 +++++++++++++++-- src/whisper-host.ts | 4 -- styles.css | 10 ++++ 16 files changed, 358 insertions(+), 74 deletions(-) create mode 100644 docs/PLUGIN_REVIEW.md diff --git a/CLAUDE.md b/CLAUDE.md index 0f9ba81..0b6fe1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,7 @@ src/ 1. **Persist audio** (`audio` source only): writes the raw `Blob` to the vault via [src/audio-persist.ts](src/audio-persist.ts) before transcription, so the user keeps the recording even if later stages fail. Path resolution: when `settings.attachmentsFolderPath` is set, the file goes under that folder with manual de-collision (`-1`, `-2`, ...); when empty, the path comes from `app.fileManager.getAvailablePathForAttachment(filename)`, which respects Obsidian's own attachments setting. Filename is `ReWrite-YYYY-MM-DD-HHmmss.` with the extension derived from the blob's mime type (`webm` / `m4a` / `ogg` / `wav` / `mp3`, default `webm`). Failure is non-fatal: a Notice fires and transcription proceeds. The resolved path is later prepended to the cleaned output as `![[]]\n\n` before insertion. 2. **Transcribe**: `audio` → `createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Just before dispatching, `validateRecording(blobSize, durationMs, providerId)` from [src/transcription/limits.ts](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 after `persist-audio`, the user keeps the saved file and can switch providers + reprocess from the vault. -3. **Cleanup**: `createLLMProvider(profile.llmProvider).complete(systemPrompt, transcript, config)`. The system prompt is the template prompt, optionally augmented with an `## Ad-hoc instructions` block when the wake-name scan ([src/wake-name.ts](src/wake-name.ts)) extracts directives from the transcript, and a `## Known nouns` block when `plugin.knownNouns` is non-empty (see Assistant prompt and Known nouns sections below). On error, the (possibly stripped) transcript is copied to the clipboard before re-throwing, so the user keeps their words. +3. **Cleanup**: `createLLMProvider(profile.llmProvider).complete(systemPrompt, transcript, config)`. The system prompt is the template prompt, optionally augmented with an `## Ad-hoc instructions` block when the wake-name scan ([src/wake-name.ts](src/wake-name.ts)) extracts directives from the transcript, and a `## Known nouns` block when `plugin.knownNouns` is non-empty (see Assistant prompt and Known nouns sections below). 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 for `audio` sources the persisted recording is the recovery path). 4. **Insert**: `src/insert.ts` routes to `cursor` / `newFile` / `append` per the template. `cursor` falls back to `append` when no editor is active; `append` falls back to `newFile` when no markdown file exists. `{{date}}` / `{{time}}` in filename templates expand via Obsidian's `moment`. The modal's per-invocation Destination control overrides `insertMode` / `newFileFolder` / `newFileNameTemplate` via `PipelineParams.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. The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), and [src/ui/text-source.ts](src/ui/text-source.ts) (the `runTextPipeline` helper for command + editor-menu entry points). Every caller passes the plugin itself as `host: PipelineHost`; `PipelineHost` is a narrow interface ({ `assistantPrompt`, `knownNouns` }) so `cleanupTranscript` can read the loaded vault content without importing `ReWritePlugin` (which would form a circular dep through the UI layer). @@ -157,6 +157,8 @@ When `mode === 'passphrase'` and not yet unlocked (`encryptionStatus.locked === `changeEncryptionMode(plugin, newMode, newPassphrase?)` decrypts all keys with the current mode, switches the envelope, and re-encrypts them. Requires the current mode to be unlocked (if passphrase AND already configured). For `passphrase` newMode, `newPassphrase` is required and must pass the entropy gate; the new envelope is built by `buildPassphraseKdfAndKey` (tries Argon2id, falls back to PBKDF2 on any derivation failure). `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`; 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. @@ -182,6 +184,8 @@ A vault Markdown file whose body is prepended to every template prompt at cleanu [src/pipeline.ts](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](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](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 @@ -201,7 +205,8 @@ Cache: `plugin.knownNouns: KnownNoun[]` (default `[]`), refreshed on the same tr Registered in [src/main.ts](src/main.ts): - **`rewrite-plugin:open-modal`** ("Open"): opens the main modal with the last-used template selected. -- **`rewrite-plugin:quick-record`** ("Quick record"): starts a recording immediately with a floating mini-UI (no modal). Second press toggles to Stop. On unconfigured profile or capture-API unavailability, opens the modal instead. On post-capture pipeline error, opens the modal so the user can retry (LLM-stage failures leave the raw transcript on the clipboard). +- **`rewrite-plugin: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-plugin:quick-record-fixed`** ("Quick record (set template)"): same flow, but records with the template chosen in `GlobalSettings.quickRecordTemplateId` (Settings dropdown beside Default template). If that id is unset or no longer resolves, it shows a "choose a Quick record template in settings" `Notice` and does not start (no `templates[0]` fallback, unlike the last-used command). Both commands share `toggleQuickRecord(opts?)` and the single `activeQuickRecord` ref, so either one stops an in-flight recording. The floating UI shows a stop-hotkey hint ("Press or click Stop") read live from the command's binding; see the Gotcha on `hotkeyManager`. - **`rewrite-plugin:process-text`** ("Process text with template"): runs a template over the active editor's selection (or the whole note body if there's no selection). Opens a template quick-picker, then runs the pipeline in the background with progress shown via `Notice`. Gates on LLM-only configuration; opens the main modal's setup card when not configured. Bails with a Notice when no Markdown editor is active. - **`rewrite-plugin:reprocess-audio`** ("Reprocess audio file with template"): reruns the pipeline over an audio file already in the vault. Opens an `AudioFilePickerModal` (`FuzzySuggestModal` filtered to `AUDIO_EXTENSIONS` from [src/audio-persist.ts](src/audio-persist.ts)) then the template quick-picker, then calls `runAudioFilePipeline` in [src/ui/audio-source.ts](src/ui/audio-source.ts). The pipeline skips its `persist-audio` stage because the `audio` source variant carries a `sourcePath` (the existing vault path is reused for the `![[]]\n\n` prepend). Gates on the full voice profile (`isProfileConfigured`). - **`rewrite-plugin:start-whisper-host`** / **`rewrite-plugin:stop-whisper-host`**: start or stop the local whisper.cpp server. Both use `checkCallback` so the palette only shows them on desktop, when the active profile's transcription provider is `whisper-local` (start) or when the host is currently `running` / `starting` (stop). Errors surface via `Notice`. Same code paths as the settings-tab Start/Stop button. @@ -228,6 +233,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **`requestUrl` multipart bodies are hand-built.** `requestUrl` does not accept `FormData`. [src/http.ts](src/http.ts) exports `buildMultipart(parts)` which produces a `Uint8Array` with a random boundary; transcription adapters (Whisper, Rev.ai) call into it. If you add a multipart-LLM provider, reuse this rather than reaching for `FormData`. - **`requestUrl` uses `throw: false` + status check.** All adapters surface non-2xx as `ProviderError` with `status` and `body`, so users see provider-attributed errors instead of opaque network failures. +- **Provider auth always goes in a header, never the URL query.** Every adapter passes its key via a header (`Authorization: Bearer`, `x-api-key`, `Token`, and `x-goog-api-key` for Gemini). Do not add a `?key=`/`?token=` query-auth provider: query strings leak into proxy/CDN logs, history, and `requestUrl`'s network-failure message. As a backstop, the network-failure `catch` in [src/http.ts](src/http.ts) `providerRequest` runs `redactQueryStrings` over the message (replaces any `?...` with `?`) 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). - **`safeStorage` is lazy-required inside a `Platform.isDesktop` guard** in [src/secrets.ts](src/secrets.ts). Importing `electron` at module top would crash on mobile load (it's marked `external` in esbuild). Any failure is treated as "encryption unavailable", which is also the mobile path and the Linux-without-keyring path. The settings tab surfaces the active backend via `safeStorage.getSelectedStorageBackend()` so users can see why it failed (e.g. `basic_text` is Chromium's last-resort backend and counts as unencrypted). - **`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. @@ -246,6 +252,8 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **`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. - **WhisperHost lazy-requires Node modules** the same way [src/secrets.ts](src/secrets.ts) does for `safeStorage`. 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. +- **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: ', 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 `/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`). @@ -257,7 +265,8 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **PipelineHost decouples the pipeline from `ReWritePlugin`.** [src/pipeline.ts](src/pipeline.ts) reads `params.host.assistantPrompt` and `params.host.knownNouns` through the narrow `PipelineHost` interface in [src/types.ts](src/types.ts). The plugin class `implements PipelineHost`, but the pipeline never imports `ReWritePlugin` directly, which would create a cycle through the UI layer. New cross-cutting cleanup-stage inputs should extend `PipelineHost` rather than reach for the plugin object. - **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 `
` whose `` reads `"Destination: Default ()"` (no override) or `"Destination: Custom ()"` (override set, forced open); expand state is tracked on `ReWriteModal.destinationExpanded` so it survives the full-container re-renders that fire when the inner insertMode dropdown changes. `describeDestination(mode, folder, name)` formats the description (e.g. `New file: ReWrite Notes/{{date}}-note`). -- **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. +- **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. - **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 `
` ([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. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 184ad9b..0afcfe6 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -31,6 +31,23 @@ Add a "Long-form audio (lectures, podcasts)" section to the README (or a `docs/L If a hosted-YouTube-fetch feature is ever requested later, the shell-out-to-`yt-dlp` adapter is still the right shape (desktop only, user-supplied binary path, mirrors the local-whisper.cpp pattern), but ship that only on demand. +### 4. Speaker identification (diarization) for recorded/long-form audio + +Opt-in mode where the transcription provider returns speaker-attributed text (e.g. `Speaker A: ...` / `Speaker B: ...`) and the attribution is preserved through cleanup into the inserted note. Natural fit for meetings, interviews, and multi-host podcasts. Item #3 already documents the provider support matrix as a transcript caveat; this item promotes it into a tracked, opt-in feature. + +**Provider gating.** Enable only for providers that expose diarization: AssemblyAI (`speaker_labels: true`, returns an `utterances[]` array), Deepgram (`diarize=true`), and Rev.ai. Disabled/hidden for OpenAI Whisper, Groq, Mistral Voxtral, and local whisper.cpp (no diarization). Gate the toggle the same way the `whisper-local` option is platform-gated in [src/settings/tab.ts](../src/settings/tab.ts) and [src/ui/setup-card.ts](../src/ui/setup-card.ts): show it only when the active profile's transcription provider supports diarization. + +**Interface decision (load-bearing).** `TranscriptionProvider.transcribe()` returns `Promise` today ([src/transcription/index.ts](../src/transcription/index.ts) lines 12-16). Two options: + +1. **Embed labels into the returned string** (`Speaker A: ...\n\nSpeaker B: ...`) inside each capable adapter, leaving the interface and the rest of the pipeline untouched; the LLM cleanup treats the labels as ordinary text. Lowest-risk, ships fastest. **Recommended as the v1 shape.** +2. Extend the return type to carry structured segments (`{ speaker, text }[]`). Only worth it if a later feature needs the structure (per-speaker callouts, styling). Defer until something asks for it. + +**Config + settings.** Add a per-profile opt-in flag (e.g. `TranscriptionConfig.diarize?: boolean` in [src/types.ts](../src/types.ts), alongside `apiKey`/`baseUrl`/`model`/`language`), surfaced as a toggle in the per-profile transcription section of the settings tab, shown only for diarization-capable providers. Each capable adapter reads the flag and sets the provider-specific request parameter: [src/transcription/assemblyai.ts](../src/transcription/assemblyai.ts) additionally has to request `speaker_labels` and format the returned `utterances[]` into labeled text (it currently reads the flat `text` field), [src/transcription/deepgram.ts](../src/transcription/deepgram.ts) sets `diarize`, and [src/transcription/revai.ts](../src/transcription/revai.ts) requests speaker-segmented output. + +**Cleanup interaction.** Speaker labels must survive the LLM pass. Check the shared-core preface ([src/shared-core.ts](../src/shared-core.ts)) and the default templates so they do not collapse or strip `Speaker X:` markers during cleanup. The Podcast default template ([src/settings/default-templates.ts](../src/settings/default-templates.ts)) is already written to tolerate both labeled and unlabeled input, so it is the natural first consumer; a meeting-notes variant could follow. + +**Honest assessment.** Opt-in and provider-limited by nature. Diarization quality varies (speaker count guesses, label churn mid-conversation, the usual homophone issues on proper nouns), so set expectations in the UI hint and pair it with item #3's long-form guide. Ship the string-embed version first; revisit structured segments only on demand. + --- ## Done diff --git a/docs/PLUGIN_REVIEW.md b/docs/PLUGIN_REVIEW.md new file mode 100644 index 0000000..ec1275e --- /dev/null +++ b/docs/PLUGIN_REVIEW.md @@ -0,0 +1,87 @@ +# ReWrite Plugin Review — Findings Report + +## Context + +The ReWrite (Voice Notes) Obsidian plugin is feature-complete for v1 and approaching its first community-directory release. Before shipping publicly we wanted a sweep for problems that are cheap to fix now and expensive after release: credential leaks, dead/redundant code, hardcoded or "leaked" instruction sets, prompt-injection surfaces, and observability gaps. + +**Scope: report only.** This document is the deliverable; the final step is to save it into the repo's `docs/` folder. No source code changes are made in this pass. Each finding lists a recommended fix so the work is ready to pick up later. User decisions captured so far: the clipboard fallback should be **removed** (the saved audio file is sufficient recovery for recordings); repo hygiene needs no change. + +The audit ran as three passes (security; prompt-leakage + code-quality; dead/redundant code). Findings were verified against the actual source. Two early candidates were checked and **retracted** (see Corrections). + +## Findings + +### Security + +- **HIGH — Gemini API key in URL query string.** [src/llm/gemini.ts:31](src/llm/gemini.ts#L31) (`complete()`) and [src/llm/gemini.ts:61](src/llm/gemini.ts#L61) (`listModels()`) put the key in `?key=...`. Keys in URLs leak into proxy/CDN/server access logs, browser history, Referer, and crash dumps. Every other adapter uses an auth header (OpenAI `Bearer`, Anthropic `x-api-key`, Deepgram `Token`, etc.). + - *Second leak path (same root cause):* on a network-level failure (DNS, TLS, timeout) [src/http.ts:59-62](src/http.ts#L59-L62) builds `ProviderError(provider, 0, msg, ...)` from the underlying exception message. `requestUrl`'s error message can contain the full request URL, which for Gemini carries `?key=`. So the key can surface in a user-visible error string (and any logs) independent of the response-body risk noted in the LOW finding below. The "no adapter echoes a key back" note there holds for response *bodies*, but not for this URL-in-error path. Both paths close together once the key moves to a header. + - *Recommended fix:* drop `key=` from both URLs; pass the key via the `x-goog-api-key` header through the existing `jsonPost`/`jsonGet` headers map in [src/http.ts](src/http.ts). + +- **MEDIUM — Raw transcript auto-copied to clipboard on LLM error.** [src/pipeline.ts:135-143](src/pipeline.ts#L135-L143) writes the working transcript to the clipboard on cleanup failure, wrapped in a nested try/catch. Silently exposes sensitive content to clipboard-history/monitoring tools. + - *Decision (user):* **remove the fallback** and its nested try/catch; cleanup failures just propagate the provider error. + - *Nuance:* fires for all sources. `paste`/`text` inputs always still exist, so removal loses nothing. For a recorded `audio` source the saved audio file allows recovery, but the already-paid-for transcript is lost on LLM failure (recovery = re-transcribe, another API call). Accepted because audio is persisted. + +- **LOW — Provider error bodies surfaced in exception messages.** The `body.slice(0, 200)` truncation lives only in the `ProviderError` constructor at [src/http.ts:10](src/http.ts#L10); [src/http.ts:65](src/http.ts#L65) just passes `res.text` into it (not a second occurrence of the slice). Almost always a JSON error blob; low risk; no adapter echoes a key back in a *response body*. (For the URL-in-error-message risk, which does carry the Gemini key, see the HIGH Gemini finding above.) Leave as-is or note the risk. + +#### Verified correct (do not flag) +- AES-GCM with a fresh random 12-byte IV per value; no IV reuse ([src/secrets.ts:369-377](src/secrets.ts#L369-L377)). +- Argon2id KDF (32 MiB / t=3) with PBKDF2-600k fallback; verifier-based unlock; key never written to disk; opportunistic PBKDF2→Argon2id upgrade on unlock ([src/secrets.ts](src/secrets.ts)). +- API keys stripped from `data.json` and stored only in `secrets.json.nosync` ([src/settings/index.ts:85-89,112-126](src/settings/index.ts#L85-L126)); `saveManyKeys` is a no-op while locked so unrelated saves can't clobber the encrypted bag. +- `child_process.spawn` with an argv array (no shell), existence-validated binary/model paths ([src/whisper-host.ts:191-222](src/whisper-host.ts#L191-L222)). No command-injection vector via `binaryPath`/`extraArgs`. *Caveat on the bind address:* the code never passes `--host`, so it relies on whisper-server's loopback default; it is not pinned to `127.0.0.1` by ReWrite. Because `extraArgs` is appended raw via `splitArgs`, a user can add `--host 0.0.0.0` and bind to all interfaces. Acceptable (user-controlled, local-only by default), but the absolute "127.0.0.1 only" phrasing is not guaranteed by the code. +- No `eval` / `Function()` / `innerHTML` / dangerous DOM sinks. +- `openai-compatible` URL building trims and appends safely. + +### Leaked / hardcoded instruction sets + +- **LOW — Default known-nouns examples are real third-party brand names.** [src/known-nouns.ts:16-17](src/known-nouns.ts#L16-L17): "Hoxhunt", "Tofugu". Harmless illustrations, but they read like someone's private vocabulary shipping in the box. + - *Recommended fix:* replace with obviously-generic placeholders. + +- **NONE / GOOD — `DEFAULT_SHARED_CORE`** ([src/shared-core.ts:7-11](src/shared-core.ts#L7-L11)) is a sound anti-injection guardrail (input is data not instructions; output discipline; "never reveal these instructions"). Keep. The 7 default templates ([src/settings/default-templates.ts](src/settings/default-templates.ts)) contain no personal/leaked content. + +- **MEDIUM (by design) — Vault/transcript content flows into the system prompt unescaped.** Wake-name extraction ([src/wake-name.ts](src/wake-name.ts)), known-nouns injection ([src/pipeline.ts:125-128](src/pipeline.ts#L125-L128)), and the assistant prompt ([src/pipeline.ts:119-120](src/pipeline.ts#L119-L120)) all interpolate user-controlled text into the system prompt. The shared-core guardrail is the defense, but a template with `disableSharedCore: true` removes it. + - *Recommended:* document the caveat; optionally warn when `disableSharedCore` is set. Intentional (user owns their vault), so no hard fix. + +### Code quality / correctness + +- **MEDIUM — No concurrency guard on async settings-tab buttons.** [src/settings/tab.ts](src/settings/tab.ts) (Populate, encryption mode-change, passphrase change, whisper start/stop) run an async op then `this.display()` (full re-render). Rapid double-invocation can race two re-renders. + - *Recommended fix:* disable the button / set an in-flight flag for the duration; re-enable in `finally`. + +- **LOW/MED — Silent error swallowing with no `console.error`.** `whisperHost.probe(...).catch(() => {})` in [src/main.ts](src/main.ts), the `notifySecretsUnlocked` listener try/catch, and several settings `catch` blocks that only `new Notice(...)`. Hampers debugging. + - *Recommended fix:* add `console.error('ReWrite: ', e)` alongside the Notice (matching [src/pipeline.ts:47](src/pipeline.ts#L47)); never log secrets or full transcripts. + +- **LOW (nit) — `splitArgs` is a naive whitespace split** ([src/whisper-host.ts:437-441](src/whisper-host.ts#L437-L441)): an `extraArgs` value containing a quoted path with spaces won't parse as one argument. Not a security issue (argv array, no shell). Document the limitation or add quote-aware parsing if users hit it. + +- **Confirmed safe (no action):** passphrase-modal strength updates use a sequence-counter + DOM-ref double guard; `http.ts sleep()` uses `{ once: true }`; the wake-name regex ([src/wake-name.ts:16-20](src/wake-name.ts#L16-L20)) escapes the user-supplied name and the replace callback guards against filler/short matches (no ReDoS); settings full re-render on dropdown change is documented intended behavior. + +### Dead / redundant code + +- **Confirmed unused export: `textPost`** at [src/http.ts:104](src/http.ts#L104). Repo-wide grep finds only the definition, no callers. + - *Recommended fix:* remove it. +- **Confirmed unused export: `isWhisperHostAvailable`** at [src/whisper-host.ts:105](src/whisper-host.ts#L105). Repo-wide grep finds only the definition, no callers. + - *Recommended fix:* remove it (the desktop/mobile guarding is done via `Platform.isDesktop` and `getNodeApi()` elsewhere). +- **Known-intentional duplication (do NOT flag), per CLAUDE.md:** provider option arrays in `setup-card.ts` vs `tab.ts`; `deCollide` ([src/audio-persist.ts](src/audio-persist.ts)) vs `nextFreePath` ([src/insert.ts](src/insert.ts)). +- **Dependencies all used:** `hash-wasm` + `@zxcvbn-ts/*` (secrets/passphrase-strength), `obsidian` (platform). No unused runtime deps. + +### Corrections (earlier candidates, now retracted) +- **`main.js` / `.gitignore`:** NOT an issue. A `.gitignore` exists at the repo root and correctly ignores `main.js` (confirmed untracked via `git ls-files`), `data.json`, `secrets.json*`, and `*.nosync`. My earlier "main.js committed / no .gitignore" claim was based on a misread and is withdrawn. +- **`audioFilename`:** NOT dead. It is imported and used in [src/transcription/openai.ts:28](src/transcription/openai.ts#L28) and [src/transcription/revai.ts:36](src/transcription/revai.ts#L36). + +## Priority order (for a future remediation pass) + +1. HIGH — Gemini key → `x-goog-api-key` header ([src/llm/gemini.ts](src/llm/gemini.ts)). +2. MEDIUM — remove the clipboard fallback + nested try/catch ([src/pipeline.ts](src/pipeline.ts)). +3. MEDIUM — settings async-button concurrency guards ([src/settings/tab.ts](src/settings/tab.ts)). +4. LOW/MED — observability `console.error` in swallowed catches ([src/main.ts](src/main.ts), [src/settings/tab.ts](src/settings/tab.ts)). +5. LOW — remove dead exports `textPost` and `isWhisperHostAvailable`; genericize known-nouns examples; document the `disableSharedCore` injection caveat. +6. Per CLAUDE.md, any of the above that changes behavior must update CLAUDE.md in the same change. + +## Deliverable / next step + +Save this report into the repo at **`docs/PLUGIN_REVIEW.md`** (sibling to `IMPLEMENTATION_PLAN.md`). No other files change in this pass. + +## Verification (for whoever implements the fixes later) + +- CI parity: `npm run build` (tsc + esbuild) and `npm run lint` must pass after each change. +- Gemini: with a test key, run a cleanup + a settings model-refresh; confirm it works and the request carries `x-goog-api-key` with no `key=` in the URL. +- Clipboard: force an LLM-stage error (bad key); confirm the provider error surfaces and nothing is written to the clipboard. +- Settings concurrency: rapid double-click Populate and whisper Start/Stop; no double-render / no thrown errors. +- Dead code: after removing `textPost` + `isWhisperHostAvailable`, `npm run build` + `npm run lint` confirm nothing referenced was deleted. diff --git a/src/http.ts b/src/http.ts index 4fa43a3..53f9faf 100644 --- a/src/http.ts +++ b/src/http.ts @@ -43,6 +43,14 @@ function abortIfSignaled(signal: AbortSignal | undefined): void { } } +// Replaces any `?...` query portion in a string with `?` so a request +// URL surfacing inside an error message cannot leak query-string secrets (e.g. a +// provider that authenticates via `?key=`). Stops at whitespace so only the URL's +// query is masked, not the rest of the message. +function redactQueryStrings(text: string): string { + return text.replace(/\?\S*/g, '?'); +} + export async function providerRequest( init: ProviderRequestInit, ): Promise { @@ -57,7 +65,11 @@ export async function providerRequest( throw: false, }); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + const raw = e instanceof Error ? e.message : String(e); + // requestUrl's network-failure message can echo the full request URL, which + // for query-authenticated providers would carry the API key. Strip query + // strings defensively before the message reaches a Notice or the log. + const msg = redactQueryStrings(raw); throw new ProviderError(init.provider, 0, msg, `${init.provider} request failed: ${msg}`); } abortIfSignaled(init.signal); @@ -101,24 +113,6 @@ export async function jsonGet( return res.json as T; } -export async function textPost( - provider: string, - url: string, - body: string | ArrayBuffer, - headers: Record = {}, - signal?: AbortSignal, -): Promise { - const res = await providerRequest({ - provider, - url, - method: 'POST', - headers, - body, - signal, - }); - return res.text; -} - export async function multipartPost( provider: string, url: string, diff --git a/src/known-nouns.ts b/src/known-nouns.ts index a56dba1..6a8c10e 100644 --- a/src/known-nouns.ts +++ b/src/known-nouns.ts @@ -13,8 +13,8 @@ guidance: | misheard variants so the LLM knows what to correct from. --- -Hoxhunt: hawks hunt, hocks hunt -Tofugu: toe fugue +Qublith: cublith, koblith +Project Marigold `; export async function loadKnownNounsFromFile(app: App, path: string): Promise { diff --git a/src/llm/gemini.ts b/src/llm/gemini.ts index eef7adb..0443b5d 100644 --- a/src/llm/gemini.ts +++ b/src/llm/gemini.ts @@ -28,7 +28,7 @@ export function createGeminiLLM(): LLMProvider { ): Promise { if (!config.apiKey) throw new Error('gemini: API key is not configured'); if (!config.model) throw new Error('gemini: model is not configured'); - const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(config.model)}:generateContent?key=${encodeURIComponent(config.apiKey)}`; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(config.model)}:generateContent`; const body: Record = { system_instruction: { parts: [{ text: systemPrompt }] }, contents: [{ parts: [{ text: userMessage }] }], @@ -40,7 +40,7 @@ export function createGeminiLLM(): LLMProvider { 'gemini', url, body, - {}, + { 'x-goog-api-key': config.apiKey }, signal, ); if (response.promptFeedback?.blockReason) { @@ -58,8 +58,8 @@ export function createGeminiLLM(): LLMProvider { }, async listModels(config, signal) { if (!config.apiKey) throw new Error('gemini: API key is not configured'); - const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(config.apiKey)}&pageSize=1000`; - const response = await jsonGet('gemini', url, {}, signal); + const url = `https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000`; + const response = await jsonGet('gemini', url, { 'x-goog-api-key': config.apiKey }, signal); const out: string[] = []; for (const row of response.models ?? []) { const methods = Array.isArray(row.supportedGenerationMethods) diff --git a/src/main.ts b/src/main.ts index be22863..5ceef1b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,7 +42,7 @@ export default class ReWritePlugin extends Plugin implements PipelineHost { } else if (snap.status === 'external') { new Notice(`ReWrite: detected external whisper-server on ${snap.baseUrl}. Transcription will use it; ReWrite won't stop it.`); } - }).catch(() => { /* non-fatal */ }); + }).catch((e) => { console.error('ReWrite: whisper-host probe failed', e); }); } this.addSettingTab(new ReWriteSettingTab(this.app, this)); @@ -60,12 +60,20 @@ export default class ReWritePlugin extends Plugin implements PipelineHost { this.addCommand({ id: 'quick-record', - name: 'Quick record', + name: 'Quick record (last used)', callback: () => { void this.toggleQuickRecord(); }, }); + this.addCommand({ + id: 'quick-record-fixed', + name: 'Quick record (set template)', + callback: () => { + void this.toggleQuickRecord({ fixed: true }); + }, + }); + this.addCommand({ id: 'process-text', name: 'Process text with template', @@ -186,7 +194,7 @@ export default class ReWritePlugin extends Plugin implements PipelineHost { notifySecretsUnlocked(): void { for (const cb of this.unlockListeners) { - try { cb(); } catch { /* swallow */ } + try { cb(); } catch (e) { console.error('ReWrite: secrets-unlocked listener failed', e); } } } @@ -273,6 +281,7 @@ export default class ReWritePlugin extends Plugin implements PipelineHost { try { await this.whisperHost.start(this.settings.localWhisper); } catch (e) { + console.error('ReWrite: whisper-host start failed', e); new Notice(e instanceof Error ? e.message : String(e)); } } @@ -281,18 +290,28 @@ export default class ReWritePlugin extends Plugin implements PipelineHost { try { await this.whisperHost.stop(); } catch (e) { + console.error('ReWrite: whisper-host stop failed', e); new Notice(e instanceof Error ? e.message : String(e)); } } - private async toggleQuickRecord(): Promise { + private async toggleQuickRecord(opts?: { fixed?: boolean }): Promise { if (this.activeQuickRecord) { await this.activeQuickRecord.finish(); return; } + const commandId = `${this.manifest.id}:${opts?.fixed ? 'quick-record-fixed' : 'quick-record'}`; + let template: NoteTemplate | undefined; + if (opts?.fixed) { + template = this.templates.find((t) => t.id === this.settings.quickRecordTemplateId); + if (!template) { + new Notice('ReWrite: choose a quick record template in settings.'); + return; + } + } this.activeQuickRecord = await startQuickRecord(this, () => { this.activeQuickRecord = null; - }); + }, { template, commandId }); } private processTextWithTemplate(preResolved?: TextResolution): void { diff --git a/src/pipeline.ts b/src/pipeline.ts index 1ac58f6..2c0504b 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -128,18 +128,5 @@ async function cleanupTranscript(params: PipelineParams, transcript: string): Pr } const llm = createLLMProvider(params.profile.llmProvider); - try { - return await llm.complete(systemPrompt, workingTranscript, params.profile.llmConfig, params.signal); - } catch (e) { - const original = e instanceof Error ? e : new Error(String(e)); - try { - await navigator.clipboard.writeText(workingTranscript); - throw new Error(`${original.message} (Raw transcript copied to clipboard as fallback.)`); - } catch (clipErr) { - if (clipErr instanceof Error && clipErr.message.includes('copied to clipboard')) { - throw clipErr; - } - throw new Error(`${original.message} (Clipboard fallback also failed.)`); - } - } + return await llm.complete(systemPrompt, workingTranscript, params.profile.llmConfig, params.signal); } diff --git a/src/secrets.ts b/src/secrets.ts index 2ebbd3d..07c6828 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -617,6 +617,23 @@ export async function changeEncryptionMode( await writeEnvelope(plugin, next); } +// Forgot-passphrase recovery. Discards all existing key material (the old keys are +// unrecoverable without the old passphrase) and writes a fresh, empty passphrase envelope +// under a new passphrase. Unlike changePassphrase, this does NOT require unlocking first. +export async function resetSecrets(plugin: Plugin, newPassphrase: string): Promise { + if (newPassphrase.length === 0) { + throw new Error('A passphrase is required.'); + } + if (!(await isPassphraseAcceptable(newPassphrase))) { + throw new Error('Passphrase is too weak. Use a longer, more unique passphrase (try the Generate button).'); + } + // The old passphrase is forgotten, so the old keys are gone for good. Drop any cached + // state and write a fresh, empty passphrase envelope under the new key. + unlockedKey = null; + cachedEnvelope = null; + await writePassphraseEnvelope(plugin, newPassphrase, {}); +} + export async function changePassphrase(plugin: Plugin, newPassphrase: string): Promise { const envelope = await ensureEnvelope(plugin); if (envelope.mode !== 'passphrase') { diff --git a/src/settings/index.ts b/src/settings/index.ts index 3c3d26a..4849120 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -52,6 +52,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = { mobileProfile: MOBILE_DEFAULT_PROFILE, defaultTemplateId: '', lastUsedTemplateId: '', + quickRecordTemplateId: '', recordingFormat: 'webm', templatesFolderPath: 'ReWrite/Templates', sharedCorePath: 'ReWrite/SharedCore.md', diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 4f0d3c2..11d5c1e 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -19,7 +19,7 @@ import { populateDefaultTemplates } from '../templates-folder'; import { populateDefaultSharedCore } from '../shared-core'; import { populateDefaultAssistantPrompt } from '../assistant-prompt'; import { populateDefaultKnownNouns } from '../known-nouns'; -import { changeEncryptionMode, EncryptionMode, lockSecrets } from '../secrets'; +import { changeEncryptionMode, EncryptionMode, lockSecrets, resetSecrets } from '../secrets'; import { hydrateSecrets } from '.'; import { PassphraseModal } from '../ui/passphrase-modal'; @@ -58,6 +58,10 @@ export class ReWriteSettingTab extends PluginSettingTab { // fields (provider, insertMode, activeProfileOverride). private inactiveProfileExpanded = false; + // Guards the encryption-mode dropdown against a second change racing the first + // while its async re-encryption + re-render is still in flight. + private modeChangeInFlight = false; + constructor(app: App, private readonly plugin: ReWritePlugin) { super(app, plugin); } @@ -93,6 +97,20 @@ export class ReWriteSettingTab extends PluginSettingTab { return setting; } + // Disables a settings button for the duration of its async handler so a rapid + // double-click cannot launch the work (and its full-container re-render) twice + // before the first invocation yields. The handler keeps its own try/catch; this + // only owns the in-flight guard, re-enabling in `finally` (harmless when the + // handler ended in `this.display()` and detached the button). + private async runGuardedButton(b: { setDisabled(disabled: boolean): unknown }, fn: () => Promise): Promise { + b.setDisabled(true); + try { + await fn(); + } finally { + b.setDisabled(false); + } + } + private apiKeyPlaceholder(): string { const status = this.plugin.encryptionStatus; if (status.locked) { @@ -131,6 +149,8 @@ export class ReWriteSettingTab extends PluginSettingTab { unlockBtn.addEventListener('click', () => { this.plugin.promptUnlock(() => this.display()); }); + const resetBtn = banner.createEl('button', { text: 'Forgot passphrase? Reset', cls: 'mod-warning' }); + resetBtn.addEventListener('click', () => this.openResetModal()); } else if (status.mode === 'safeStorage') { banner.addClass('is-ok'); const backend = status.safeStorageBackend ? ` (${status.safeStorageBackend})` : ''; @@ -197,16 +217,38 @@ export class ReWriteSettingTab extends PluginSettingTab { .setName('Lock now') .setDesc('Forgets the passphrase in memory. You will need to re-enter it before recording.') .addButton((b) => { - b.setButtonText('Lock').onClick(async () => { + b.setButtonText('Lock').onClick(() => void this.runGuardedButton(b, async () => { lockSecrets(); await hydrateSecrets(this.plugin, this.plugin.settings); await this.plugin.refreshEncryptionStatus(); this.display(); - }); + })); }); } } + private openResetModal(): void { + new PassphraseModal({ + app: this.app, + title: 'Reset API key passphrase', + description: 'Forgot your passphrase? This permanently deletes every stored API key and sets a new passphrase. The old keys cannot be recovered; you will re-enter each API key afterward.', + confirmLabel: 'Delete keys and set passphrase', + requirePhrase: 'DELETE APIS', + requireConfirm: true, + enforceStrength: true, + onSubmit: async (pass) => { + await resetSecrets(this.plugin, pass); + // Old in-memory keys are gone; re-hydrate to empty them so a later save + // cannot rewrite stale values, then refresh status and re-render. + await hydrateSecrets(this.plugin, this.plugin.settings); + await this.plugin.refreshEncryptionStatus(); + this.plugin.notifySecretsUnlocked(); + new Notice('ReWrite: API keys cleared. New passphrase set.'); + this.display(); + }, + }).open(); + } + private encryptionModeDescription(status: { mode: EncryptionMode; safeStorageAvailable: boolean; safeStorageBackend: string | null }): string { const lines: string[] = []; if (status.safeStorageAvailable) { @@ -219,6 +261,8 @@ export class ReWriteSettingTab extends PluginSettingTab { } private async handleModeChange(next: EncryptionMode): Promise { + if (this.modeChangeInFlight) return; + this.modeChangeInFlight = true; try { if (next === 'passphrase') { new PassphraseModal({ @@ -245,9 +289,12 @@ export class ReWriteSettingTab extends PluginSettingTab { new Notice('ReWrite: switched to OS keychain storage.'); this.display(); } catch (e) { + console.error('ReWrite: encryption mode change failed', e); new Notice(`ReWrite: ${e instanceof Error ? e.message : String(e)}`); await this.plugin.refreshEncryptionStatus(); this.display(); + } finally { + this.modeChangeInFlight = false; } } @@ -635,7 +682,7 @@ export class ReWriteSettingTab extends PluginSettingTab { new Setting(parent) .setName('Extra args') - .setDesc('Space-separated CLI args appended after -m, --port.') + .setDesc('Space-separated CLI args appended after -m, --port. Split on whitespace only, so a single value containing spaces (such as a quoted path) is not supported.') .addText((t) => { t.setValue(cfg.extraArgs); t.onChange(async (v) => { @@ -650,36 +697,39 @@ export class ReWriteSettingTab extends PluginSettingTab { const statusSetting = new Setting(parent).setName('Status').setDesc(formatWhisperStatus(snap)); statusSetting.addButton((b) => { if (snap.status === 'running' || snap.status === 'starting') { - b.setButtonText('Stop').onClick(async () => { + b.setButtonText('Stop').onClick(() => void this.runGuardedButton(b, async () => { try { await host.stop(); } catch (e) { + console.error('ReWrite: whisper-host stop failed', e); new Notice(e instanceof Error ? e.message : String(e)); } this.display(); - }); + })); } else if (snap.status === 'external') { b.setButtonText('External').setDisabled(true).setTooltip('Not started by ReWrite. Stop the process from your task manager.'); } else { - b.setButtonText('Start').setCta().onClick(async () => { + b.setButtonText('Start').setCta().onClick(() => void this.runGuardedButton(b, async () => { try { await host.start(cfg); } catch (e) { + console.error('ReWrite: whisper-host start failed', e); new Notice(e instanceof Error ? e.message : String(e)); } this.display(); - }); + })); } }); statusSetting.addExtraButton((b) => { - b.setIcon('refresh-cw').setTooltip('Probe the configured port for an existing server').onClick(async () => { + b.setIcon('refresh-cw').setTooltip('Probe the configured port for an existing server').onClick(() => void this.runGuardedButton(b, async () => { try { await host.probe(cfg); } catch (e) { + console.error('ReWrite: whisper-host probe failed', e); new Notice(e instanceof Error ? e.message : String(e)); } this.display(); - }); + })); }); const log = host.getLog(); @@ -716,7 +766,7 @@ export class ReWriteSettingTab extends PluginSettingTab { .setName('Populate with default templates') .setDesc('Writes the seven built-in templates into the folder above, plus the shared core file if it is missing. It skips any template that already exists, so you can run it again to top up after deleting one.') .addButton((b) => { - b.setButtonText('Populate').setCta().onClick(async () => { + b.setButtonText('Populate').setCta().onClick(() => void this.runGuardedButton(b, async () => { try { const result = await populateDefaultTemplates(this.app, s.templatesFolderPath); await this.plugin.refreshTemplates(); @@ -728,9 +778,10 @@ export class ReWriteSettingTab extends PluginSettingTab { new Notice(`ReWrite: populated ${result.folder}. Created ${result.created}, skipped ${result.skipped}.${coreNote}`); this.display(); } catch (e) { + console.error('ReWrite: populate templates failed', e); new Notice(`ReWrite: populate failed. ${e instanceof Error ? e.message : String(e)}`); } - }); + })); }); const loaded = this.plugin.templates; @@ -739,6 +790,18 @@ export class ReWriteSettingTab extends PluginSettingTab { : `Loaded ${loaded.length} template${loaded.length === 1 ? '' : 's'}: ${loaded.map((t) => t.name).join(', ')}.`; parent.createEl('p', { text: listDesc, cls: 'rewrite-section-desc' }); + // Surface templates that opt out of the shared core: doing so drops the + // anti-injection guardrail and output discipline that the shared core carries, + // so the user should know which templates run without it. + const noGuardrail = loaded.filter((t) => t.disableSharedCore === true); + if (noGuardrail.length > 0) { + const warn = parent.createEl('p', { cls: 'rewrite-section-desc rewrite-warning-text' }); + warn.createEl('strong', { text: 'Shared core disabled: ' }); + warn.createSpan({ + text: `${noGuardrail.map((t) => t.name).join(', ')}. ${noGuardrail.length === 1 ? 'This template runs' : 'These templates run'} without the shared core, so the anti-injection guardrail and output rules it carries do not apply. Vault and transcript text reach the model with less protection.`, + }); + } + if (loaded.length > 0) { new Setting(parent) .setName('Default template') @@ -752,6 +815,19 @@ export class ReWriteSettingTab extends PluginSettingTab { await this.commit(); }); }); + + new Setting(parent) + .setName('Quick record (set template)') + .setDesc('Template used by the quick record (set template) command.') + .addDropdown((dd) => { + dd.addOption('', '(none, choose one)'); + for (const tpl of loaded) dd.addOption(tpl.id, tpl.name); + dd.setValue(loaded.some((t) => t.id === s.quickRecordTemplateId) ? s.quickRecordTemplateId : ''); + dd.onChange(async (v) => { + s.quickRecordTemplateId = v; + await this.commit(); + }); + }); } new Setting(parent) @@ -847,7 +923,7 @@ export class ReWriteSettingTab extends PluginSettingTab { .setName('Populate default assistant prompt') .setDesc('Writes the built-in default into the file above. Skipped if the file already exists.') .addButton((b) => { - b.setButtonText('Populate').setCta().onClick(async () => { + b.setButtonText('Populate').setCta().onClick(() => void this.runGuardedButton(b, async () => { try { const created = await populateDefaultAssistantPrompt(this.app, this.plugin.settings.assistantPromptPath); await this.plugin.refreshAssistantPrompt(); @@ -856,9 +932,10 @@ export class ReWriteSettingTab extends PluginSettingTab { : `ReWrite: ${this.plugin.settings.assistantPromptPath} already exists.`); this.display(); } catch (e) { + console.error('ReWrite: populate assistant prompt failed', e); new Notice(`ReWrite: ${e instanceof Error ? e.message : String(e)}`); } - }); + })); }) .addExtraButton((b) => { b.setIcon('external-link').setTooltip('Open file in a new pane').onClick(() => { @@ -910,7 +987,7 @@ export class ReWriteSettingTab extends PluginSettingTab { .setName('Re-create shared core file') .setDesc('Writes a starter file with the default shared core. Skipped if the file already exists; delete the file first to restore the default.') .addButton((b) => { - b.setButtonText('Populate').setCta().onClick(async () => { + b.setButtonText('Populate').setCta().onClick(() => void this.runGuardedButton(b, async () => { try { const created = await populateDefaultSharedCore(this.app, this.plugin.settings.sharedCorePath); await this.plugin.refreshSharedCore(); @@ -919,9 +996,10 @@ export class ReWriteSettingTab extends PluginSettingTab { : `ReWrite: ${this.plugin.settings.sharedCorePath} already exists.`); this.display(); } catch (e) { + console.error('ReWrite: populate shared core failed', e); new Notice(`ReWrite: ${e instanceof Error ? e.message : String(e)}`); } - }); + })); }) .addExtraButton((b) => { b.setIcon('external-link').setTooltip('Open file in a new pane').onClick(() => { @@ -967,7 +1045,7 @@ export class ReWriteSettingTab extends PluginSettingTab { .setName('Populate default known nouns') .setDesc('Writes a starter file with guidance frontmatter and example nouns. Skipped if the file already exists.') .addButton((b) => { - b.setButtonText('Populate').setCta().onClick(async () => { + b.setButtonText('Populate').setCta().onClick(() => void this.runGuardedButton(b, async () => { try { const created = await populateDefaultKnownNouns(this.app, this.plugin.settings.knownNounsPath); await this.plugin.refreshKnownNouns(); @@ -976,9 +1054,10 @@ export class ReWriteSettingTab extends PluginSettingTab { : `ReWrite: ${this.plugin.settings.knownNounsPath} already exists.`); this.display(); } catch (e) { + console.error('ReWrite: populate known nouns failed', e); new Notice(`ReWrite: ${e instanceof Error ? e.message : String(e)}`); } - }); + })); }) .addExtraButton((b) => { b.setIcon('external-link').setTooltip('Open file in a new pane').onClick(() => { diff --git a/src/types.ts b/src/types.ts index 0efcbd6..c7d3251 100644 --- a/src/types.ts +++ b/src/types.ts @@ -98,6 +98,7 @@ export interface GlobalSettings { mobileProfile: EnvironmentProfile; defaultTemplateId: string; lastUsedTemplateId: string; + quickRecordTemplateId: string; recordingFormat: RecordingFormatPreference; templatesFolderPath: string; sharedCorePath: string; diff --git a/src/ui/passphrase-modal.ts b/src/ui/passphrase-modal.ts index aa8ab9a..c30bb25 100644 --- a/src/ui/passphrase-modal.ts +++ b/src/ui/passphrase-modal.ts @@ -11,6 +11,10 @@ export interface PassphrasePromptParams { confirmLabel?: string; // When true, render a second "Confirm passphrase" field that must match. requireConfirm?: boolean; + // When set, render a plain-text confirmation field above the passphrase that must + // exactly match this phrase before submit is allowed (e.g. "DELETE APIS" for the + // destructive reset flow). + requirePhrase?: string; // When true (create/change flows), render the strength meter + Generate button and // block submit below MIN_PASSPHRASE_SCORE. Leave false for the unlock flow. enforceStrength?: boolean; @@ -21,11 +25,13 @@ export interface PassphrasePromptParams { export class PassphraseModal extends Modal { private passphrase = ''; private confirm = ''; + private phrase = ''; private busy = false; private errorEl: HTMLElement | null = null; private tipsEl: HTMLDetailsElement | null = null; private passInput: HTMLInputElement | null = null; private confirmInput: HTMLInputElement | null = null; + private phraseInput: HTMLInputElement | null = null; private strengthBarEl: HTMLElement | null = null; private strengthTextEl: HTMLElement | null = null; private strengthTimer: number | null = null; @@ -45,6 +51,19 @@ export class PassphraseModal extends Modal { contentEl.createEl('p', { text: this.params.description, cls: 'rewrite-passphrase-desc' }); } + if (this.params.requirePhrase) { + const phrase = this.params.requirePhrase; + new Setting(contentEl) + .setName(`Type "${phrase}" to confirm`) + .setDesc('This permanently deletes all stored API keys.') + .addText((t) => { + t.inputEl.addClass('rewrite-passphrase-confirm-phrase'); + this.phraseInput = t.inputEl; + t.onChange((v) => { this.phrase = v; }); + t.inputEl.addEventListener('keydown', (e) => this.onKeydown(e)); + }); + } + if (this.params.requireConfirm) { this.renderPassphraseTips(contentEl); } @@ -118,6 +137,7 @@ export class PassphraseModal extends Modal { this.strengthTextEl = null; this.passphrase = ''; this.confirm = ''; + this.phrase = ''; this.contentEl.empty(); } @@ -250,6 +270,10 @@ export class PassphraseModal extends Modal { if (this.busy) return; this.clearError(); + if (this.params.requirePhrase && this.phrase !== this.params.requirePhrase) { + this.setError(`Type "${this.params.requirePhrase}" exactly to confirm.`); + return; + } if (this.passphrase.length === 0) { this.setError('Enter a passphrase.'); return; diff --git a/src/ui/quick-record.ts b/src/ui/quick-record.ts index 36c2da4..239f3f7 100644 --- a/src/ui/quick-record.ts +++ b/src/ui/quick-record.ts @@ -1,4 +1,4 @@ -import { Notice } from 'obsidian'; +import { App, Notice, Platform } from 'obsidian'; import type ReWritePlugin from '../main'; import { PipelineSource, PipelineStage, runPipeline } from '../pipeline'; import { isMediaRecorderAvailable, resolveActiveProfile } from '../platform'; @@ -18,6 +18,7 @@ export class QuickRecordController { private readonly plugin: ReWritePlugin, template: NoteTemplate, private readonly onDispose: () => void, + private readonly stopHotkey: string | null = null, ) { this.template = template; } @@ -37,6 +38,7 @@ export class QuickRecordController { this.floater?.setTemplateName(t.name); }, initialTemplateName: this.template.name, + stopHotkey: this.stopHotkey, }); this.timerHandle = window.setInterval(() => { const ms = this.recorder?.getElapsedMs() ?? 0; @@ -106,6 +108,7 @@ export class QuickRecordController { export async function startQuickRecord( plugin: ReWritePlugin, onDispose: () => void, + opts?: { template?: NoteTemplate; commandId?: string }, ): Promise { const settings = plugin.settings; const { profile } = resolveActiveProfile(settings); @@ -128,7 +131,7 @@ export async function startQuickRecord( return null; } - const template = pickQuickRecordTemplate(plugin); + const template = opts?.template ?? pickQuickRecordTemplate(plugin); if (!template) { new Notice('ReWrite: add a template before using quick record.'); new ReWriteModal(plugin.app, plugin).open(); @@ -141,7 +144,8 @@ export async function startQuickRecord( return null; } - const controller = new QuickRecordController(plugin, template, onDispose); + const stopHotkey = opts?.commandId ? formatCommandHotkey(plugin.app, opts.commandId) : null; + const controller = new QuickRecordController(plugin, template, onDispose, stopHotkey); try { await controller.begin(); } catch (e) { @@ -157,12 +161,43 @@ function pickQuickRecordTemplate(plugin: ReWritePlugin): NoteTemplate | undefine const s = plugin.settings; const templates = plugin.templates; return ( - templates.find((t) => t.id === s.defaultTemplateId) - ?? templates.find((t) => t.id === s.lastUsedTemplateId) + templates.find((t) => t.id === s.lastUsedTemplateId) + ?? templates.find((t) => t.id === s.defaultTemplateId) ?? templates[0] ); } +interface Hotkey { + modifiers: string[]; + key: string; +} + +interface HotkeyManager { + getHotkeys(id: string): Hotkey[] | undefined; + getDefaultHotkeys(id: string): Hotkey[] | undefined; +} + +// Obsidian's hotkey manager is internal and not in the public typings; read through a +// narrow cast. Returns null when the command has no binding (custom or default). +function formatCommandHotkey(app: App, commandId: string): string | null { + const manager = (app as unknown as { hotkeyManager?: HotkeyManager }).hotkeyManager; + if (!manager) return null; + const hotkeys = manager.getHotkeys(commandId) ?? manager.getDefaultHotkeys(commandId); + const hotkey = hotkeys?.[0]; + if (!hotkey) return null; + const mac = Platform.isMacOS; + const symbols: Record = { + Mod: mac ? '⌘' : 'Ctrl', + Meta: mac ? '⌘' : 'Win', + Ctrl: 'Ctrl', + Shift: mac ? '⇧' : 'Shift', + Alt: mac ? '⌥' : 'Alt', + }; + const parts = hotkey.modifiers.map((m) => symbols[m] ?? m); + parts.push(hotkey.key.length === 1 ? hotkey.key.toUpperCase() : hotkey.key); + return mac ? parts.join('') : parts.join('+'); +} + interface QuickRecordFloaterOptions { onStop: () => void; onCancel: () => void; @@ -170,6 +205,7 @@ interface QuickRecordFloaterOptions { getActiveTemplateId: () => string; onPickTemplate: (t: NoteTemplate) => void; initialTemplateName: string; + stopHotkey?: string | null; } class QuickRecordFloater { @@ -202,6 +238,13 @@ class QuickRecordFloater { this.togglePopover(); }); + if (options.stopHotkey) { + this.el.createSpan({ + cls: 'rewrite-quick-stop-hint', + text: `Press ${options.stopHotkey} or`, + }); + } + const stopBtn = this.el.createEl('button', { text: 'Stop', cls: 'mod-cta rewrite-quick-stop', diff --git a/src/whisper-host.ts b/src/whisper-host.ts index 7a0b47e..47f1455 100644 --- a/src/whisper-host.ts +++ b/src/whisper-host.ts @@ -102,10 +102,6 @@ function getNodeApi(): NodeAPI | null { } } -export function isWhisperHostAvailable(): boolean { - return getNodeApi() !== null; -} - export function formatWhisperStatus(snap: WhisperSnapshot): string { switch (snap.status) { case 'stopped': diff --git a/styles.css b/styles.css index b639071..afa0d10 100644 --- a/styles.css +++ b/styles.css @@ -158,6 +158,10 @@ margin-bottom: 8px; } +.rewrite-settings .rewrite-warning-text { + color: var(--text-warning, var(--color-yellow)); +} + .rewrite-settings .rewrite-profile-section { margin-bottom: 16px; } @@ -252,6 +256,12 @@ padding: 2px 8px; } +.rewrite-quick-floater .rewrite-quick-stop-hint { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + margin-left: 4px; +} + .rewrite-quick-floater .rewrite-quick-template { max-width: 14ch; overflow: hidden;