Add four UX features and bundle pending pre-release work

This session's features:
- Quick Record mode selector: third button on the floater opens a
  popover of templates, switchable mid-recording. Updates the
  controller's template in place; does not touch lastUsedTemplateId
  until completion.
- Per-invocation destination override on the main modal: a Destination
  row under the template picker overrides insertMode and the new-file
  fields for one run, plumbed via PipelineParams.destinationOverride
  and shallow-merged onto a template copy before insertOutput. The
  template file on disk is never mutated.
- Assistant prompt as a vault file: replaces the settings textarea
  with ReWrite/AssistantPrompt.md, loaded via src/assistant-prompt.ts
  and cached on plugin.assistantPrompt. AI/Agent labels standardized
  to "Assistant"; aiName -> assistantName; adHocInstructionsPrompt
  field removed.
- Known Nouns list: ReWrite/KnownNouns.md with YAML guidance
  frontmatter and a Markdown body of nouns (optional misheard
  alternates after a colon). buildKnownNounsSystemPromptSection
  injects a "Known nouns" block into the LLM system prompt only when
  the list is non-empty. Frontmatter is for humans and is not sent
  to the LLM in v1.

Cross-cutting: PipelineParams gains host: PipelineHost (narrow
interface in src/types.ts) so the pipeline can read assistantPrompt
and knownNouns without importing ReWritePlugin and forming a cycle.

Pre-existing uncommitted work also rolled in:
- Passphrase encryption for secrets.json.nosync via AES-GCM +
  PBKDF2 (src/secrets.ts), with PassphraseModal and a settings-tab
  banner/dropdown for mode selection.
- WhisperHost adopt/external states + PID sidecar so an orphaned
  whisper-server from a previous session is adopted, and external
  ones are never killed by ReWrite (src/whisper-host.ts).
- Whisper status bar (src/ui/whisper-status-bar.ts) and
  start/stop commands.
- Audio persistence before transcription (src/audio-persist.ts)
  plus the reprocess-audio flow (src/ui/audio-source.ts,
  src/ui/audio-file-picker.ts).
- Wake-name extraction (src/wake-name.ts).
- Templates as vault Markdown files in a folder
  (src/templates-folder.ts).
- Linux build script for whisper-server
  (scripts/build-whisper-linux.sh).
- README, CLAUDE.md, docs/FEATURES.md, eslint.config.mts updates
  covering all of the above.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WiseGuru 2026-05-26 10:13:42 -07:00
parent 624e240cc1
commit 3ec83943eb
26 changed files with 3229 additions and 465 deletions

110
CLAUDE.md
View file

@ -52,16 +52,22 @@ src/
├── 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 5 defaults
├── 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, recording
│ └── default-templates.ts # The 5 seeded templates (General cleanup, Todo list, etc.)
│ ├── tab.ts # PluginSettingTab: active profile, two profile sections, templates folder, recording
│ └── default-templates.ts # The 5 default templates used by the populate button (General cleanup, Todo list, etc.)
├── 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
│ ├── 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()
│ ├── openai.ts # Whisper-shape POST (also used by openai-compatible + groq)
@ -79,15 +85,16 @@ src/
## Pipeline
[src/pipeline.ts](src/pipeline.ts) runs three stages with `onStage` callbacks for UI:
[src/pipeline.ts](src/pipeline.ts) runs four stages with `onStage` callbacks for UI:
1. **Transcribe**: `audio``createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Short-circuited when the source is `webspeech` (the transcript was already captured live by `src/webspeech.ts` during recording).
2. **Cleanup**: `createLLMProvider(profile.llmProvider).complete(template.prompt, transcript, config)`. On error, the raw transcript is copied to the clipboard before re-throwing, so the user keeps their words.
3. **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`.
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.<ext>` 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 `![[<path>]]\n\n` before insertion.
2. **Transcribe**: `audio``createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Short-circuited when the source is `webspeech` (the transcript was already captured live by `src/webspeech.ts` during recording).
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.
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).
The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), and [src/ui/text-source.ts](src/ui/text-source.ts) (the `runTextPipeline` helper for command + editor-menu entry points). Every caller passes the plugin itself as `host: PipelineHost`; `PipelineHost` is a narrow interface ({ `assistantPrompt`, `knownNouns` }) so `cleanupTranscript` can read the loaded vault content without importing `ReWritePlugin` (which would form a circular dep through the UI layer).
The `PipelineSource` union has four variants: `audio` (recorded blob), `paste` (textarea input), `webspeech` (live-captured transcript), `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 `PipelineSource` union has four variants: `audio` (recorded blob, optional `sourcePath` for reprocess flows), `paste` (textarea input), `webspeech` (live-captured transcript), `text` (input from an existing note via selection or whole body). Text-source flows skip transcription entirely and only require the LLM half of the profile. The `audio` variant's `sourcePath` is set by the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) to point at an existing vault file; when present, the persist stage is skipped and that path is reused for the `![[<path>]]\n\n` prepend.
## Provider system
@ -103,7 +110,7 @@ Providers may optionally implement `listModels(config, signal)` returning a stri
`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.
The `whisper-local` transcription provider ([src/transcription/whisper-local.ts](src/transcription/whisper-local.ts)) is a thin shim that POSTs to `http://127.0.0.1:<port>/v1/audio/transcriptions` using the same multipart shape as OpenAI Whisper. The shim grabs the host reference via `bindWhisperHost(host)` called from [src/main.ts](src/main.ts) on load. If the host status is anything other than `'running'`, the adapter throws a `ProviderError` with a "start it from settings" message; the pipeline surfaces that as a Notice. No API key is collected for this provider (no auth, no settings field).
The `whisper-local` transcription provider ([src/transcription/whisper-local.ts](src/transcription/whisper-local.ts)) is a thin shim that POSTs to `http://127.0.0.1:<port>/inference` (whisper.cpp's native server route, not OpenAI's `/v1/audio/transcriptions`). Before sending, the recorded blob is transcoded to 16 kHz mono 16-bit PCM WAV via 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 — it is *not* applied to any other transcription provider. The shim grabs the host reference via `bindWhisperHost(host)` called from [src/main.ts](src/main.ts) on load. If the host status is anything other than `'running'`, the adapter throws a `ProviderError` with a "start it from settings" message; the pipeline surfaces that as a Notice. No API key is collected for this provider (no auth, no settings field).
Mobile compatibility: `WhisperHost` and the `whisper-local` option are guarded everywhere by `Platform.isDesktop`. Node modules (`child_process`, `net`, `fs`) are lazy-required inside the `Platform.isDesktop` branch, mirroring the [src/secrets.ts](src/secrets.ts) `getSafeStorage` pattern; on mobile the host is inert and the provider option is filtered out of dropdowns in [src/settings/tab.ts](src/settings/tab.ts) and [src/ui/setup-card.ts](src/ui/setup-card.ts).
@ -113,11 +120,64 @@ Mobile compatibility: `WhisperHost` and the `whisper-local` option are guarded e
1. `plugin.loadData()` returns `Partial<GlobalSettings> | null`.
2. `mergeSettings(DEFAULT_SETTINGS, stored)` deep-merges, preferring stored values.
3. If `merged.templates.length === 0`, [src/settings/default-templates.ts](src/settings/default-templates.ts) seeds the 5 starter templates and sets `defaultTemplateId` if unset. This handles first launch *and* migrations from any pre-Phase-11 install with an empty templates array.
4. `hydrateSecrets()` reads keys from `secrets.json.nosync` and writes them into each profile's `transcriptionConfig.apiKey` / `llmConfig.apiKey`.
3. `hydrateSecrets()` reads keys from `secrets.json.nosync` and writes them into each profile's `transcriptionConfig.apiKey` / `llmConfig.apiKey`.
Saving flow strips secrets out of `data.json` and writes them to `secrets.json.nosync` instead (see [src/secrets.ts](src/secrets.ts)). Never persist API keys to `data.json`.
## Secrets encryption
[src/secrets.ts](src/secrets.ts) supports three encryption modes for `secrets.json.nosync`, file-wide (not per-key):
- **`safeStorage`** — Electron's OS keychain (Keychain on macOS, DPAPI on Windows, libsecret/kwallet on Linux). Default when available. Each value is the base64 ciphertext of `safeStorage.encryptString`. Desktop only; chosen automatically on first run when `safeStorage.isEncryptionAvailable()` returns true. Backend name is surfaced via `safeStorage.getSelectedStorageBackend()`.
- **`passphrase`** — WebCrypto AES-GCM-256 with a key derived from a user-supplied passphrase via PBKDF2-SHA256, 600,000 iterations, 16-byte random salt (per-file). Each value is stored as `<iv-b64>.<ct-b64>` (12-byte random IV per value). A `verifier` field stores an encryption of `VERIFIER_PLAINTEXT` so unlock can validate the passphrase without trying to decrypt user keys. Works on every platform including mobile and Linux-without-keyring.
- **`plaintext`** — no encryption. Only used as the auto-fallback on first run when `safeStorage` isn't available; users must explicitly opt back into it after switching away.
File envelope (`SECRETS_VERSION = 2`):
```json
{ "version": 2, "mode": "passphrase",
"kdf": { "iterations": 600000, "salt": "<b64>" },
"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 and decrypts the `verifier` to check correctness before caching.
`ReWritePlugin.encryptionStatus` (a snapshot of `{ mode, locked, safeStorageAvailable, safeStorageBackend }`) is loaded on `onload` and refreshed via `plugin.refreshEncryptionStatus()` after every mode change / unlock. UI code reads this synchronously.
When `mode === 'passphrase'` and not yet unlocked (`encryptionStatus.locked === true`):
- `loadAllKeys` / `loadKey` return empty strings (no error).
- `saveManyKeys` is a no-op (so calls to `saveSettings()` from unrelated UI changes do not clobber the on-disk encrypted values with empties).
- `saveKey` throws.
- All entry points ([src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), [src/ui/text-source.ts](src/ui/text-source.ts), [src/ui/audio-source.ts](src/ui/audio-source.ts)) check `plugin.encryptionStatus.locked` and call `plugin.promptUnlock()` instead of proceeding.
- The settings tab disables the API key input fields and shows a red "Unlock" banner at the top.
`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). For `passphrase` newMode, `newPassphrase` is required. `changePassphrase(plugin, newPassphrase)` is a thin wrapper that calls `changeEncryptionMode(plugin, 'passphrase', newPassphrase)`.
## 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.
[src/templates-folder.ts](src/templates-folder.ts) exports `loadTemplatesFromFolder(app, folderPath)` and `populateDefaultTemplates(app, folderPath)`. The plugin keeps a cache on `plugin.templates: NoteTemplate[]`, refreshed in [src/main.ts](src/main.ts) on:
- `workspace.onLayoutReady` after `onload` (initial load, after the vault is ready)
- vault `create` / `modify` / `delete` events scoped to the templates folder via `isPathInTemplatesFolder`
- vault `rename` (checks both old and new path)
- the Templates folder path field changing in settings
- the populate button completing
Consumers ([src/main.ts](src/main.ts), [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts)) read `plugin.templates` directly, never `settings.templates` (there is no such field). The populate button is non-destructive: it skips any default template whose `id` already exists on disk, and skips path collisions. Frontmatter `id` is canonical for identity, so renaming a file does not break the `defaultTemplateId` / `lastUsedTemplateId` reference. The first-launch experience is empty templates plus a setup nudge in the modal; the user clicks Populate to get the defaults.
## 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](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](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](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](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](src/pipeline.ts) appends the section returned by `buildKnownNounsSystemPromptSection(host.knownNouns)` to the system prompt when non-empty. Order in the assembled prompt: template prompt, then ad-hoc instructions (if any), then known nouns (if any).
## Commands
Registered in [src/main.ts](src/main.ts):
@ -125,8 +185,10 @@ 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: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<TFile>` filtered to `AUDIO_EXTENSIONS` from [src/audio-persist.ts](src/audio-persist.ts)) then the template quick-picker, then calls `runAudioFilePipeline` in [src/ui/audio-source.ts](src/ui/audio-source.ts). The pipeline skips its `persist-audio` stage because the `audio` source variant carries a `sourcePath` (the existing vault path is reused for the `![[<path>]]\n\n` prepend). Gates on the full voice profile (`isProfileConfigured`).
- **`rewrite-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.
Plus an editor-menu item "ReWrite with template..." registered via `workspace.on('editor-menu', ...)` and an `addRibbonIcon('mic', 'ReWrite', ...)` that opens the modal.
Plus an editor-menu item "ReWrite with template..." registered via `workspace.on('editor-menu', ...)` (and a second "Reprocess audio with template..." item that appears only when the cursor sits inside an `![[<audio>]]` embed, resolved via `app.metadataCache.getFirstLinkpathDest`), a `workspace.on('file-menu', ...)` handler that adds "Reprocess audio with template..." for audio files in the file explorer, an `addRibbonIcon('mic', 'ReWrite', ...)` that opens the modal, and a status-bar item ([src/ui/whisper-status-bar.ts](src/ui/whisper-status-bar.ts)) showing the live whisper-host status. The status bar polls `whisperHost.status()` every 1 s via `registerInterval`, click toggles start/stop, and the item is hidden via the `rewrite-hidden` CSS class when on mobile or when the active profile is not `whisper-local`.
## Code style
@ -148,21 +210,33 @@ 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.
- **`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.
- **`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.
- **No baked-in model defaults.** Both profiles ship with `model: ""`. The modal renders an inline setup card that blocks recording/paste until the active profile has a provider, model, key, and (for `openai-compatible`) base URL. If you add a provider, do not bake a default model string; surface it as placeholder hint text.
- **`openai-compatible` base URL asymmetry** (literal interpretation of the spec): transcription appends `/v1/audio/transcriptions` to a *root* URL (`http://localhost:8080`); LLM appends `/chat/completions` to a URL that *already includes* `/v1` (`http://localhost:11434/v1`). The settings UI hint text and setup card both guide users; do not "normalize" one to match the other.
- **Web Speech adapter throws "should not be called".** The pipeline short-circuits when `source.kind === 'webspeech'` and uses the transcript captured live by `src/webspeech.ts` during recording. The factory still has the case to keep the switch exhaustive. On mobile WKWebView (iOS) `SpeechRecognition` is undefined, so [src/platform.ts](src/platform.ts) exports `isWebSpeechAvailable()`; the modal/setup-card and Quick Record both check it before starting a Web Speech session.
- **`setHeading()` instead of manual `<h2>`** inside settings tabs. `obsidianmd/settings-tab/no-manual-html-headings` forbids manual headings. Same applies anywhere else inside a settings tab that needs a section header.
- **`window.confirm` is banned** by ESLint's `no-alert`. [src/settings/tab.ts](src/settings/tab.ts) ships a small in-file `ConfirmModal extends Modal` used for template deletion; reuse that pattern if another phase needs confirmation, don't reach for `window.confirm`.
- **`window.confirm` is banned** by ESLint's `no-alert`. If a future phase needs an in-vault confirmation prompt, add a small `Modal` subclass rather than reaching for `window.confirm`.
- **Sentence-case lint covers brand and acronym lists** in [eslint.config.mts](eslint.config.mts). Adding a new provider, model family, or product name means adding it to `REWRITE_BRANDS` (or `REWRITE_ACRONYMS` for things like `LLM`). Dropdown option labels also pass through the rule; in [src/settings/tab.ts](src/settings/tab.ts) and [src/ui/setup-card.ts](src/ui/setup-card.ts), labels are iterated via `opt.label` (member access) to dodge the literal-string check.
- **Provider option arrays appear in both setup-card.ts and tab.ts.** Intentionally duplicated rather than extracted, per the "don't refactor beyond what the task requires" rule. If the lists drift, fix the user-visible inconsistency, not the duplication.
- **Settings tab re-renders the entire container on dropdown changes** that toggle conditional fields (provider, insertMode, activeProfileOverride). Text fields call `saveSettings()` on change but do not redraw, so focus is preserved while typing. Preserve this pattern when adding new conditional fields.
- **Default templates re-seed when `templates.length === 0`**, not via a `hasSeeded` flag. This is deliberate: it migrates pre-Phase-11 installs and gives a user who deleted everything a way back to a working state. Stable IDs (`tpl-default-...`) mean re-seed produces no duplicates.
- **Templates are vault files, not settings.** There is no `settings.templates` array. Consumers read `plugin.templates` (refreshed from disk). When you add a field to `NoteTemplate`, update [src/templates-folder.ts](src/templates-folder.ts) on both sides: `parseTemplateFile` reads it out of frontmatter (with a sensible default if missing), and `renderTemplateFile` writes it into the frontmatter the populate button emits. The populate button is non-destructive: it skips files whose `id` already exists, so re-running it tops up the folder without clobbering user edits.
- **Frontmatter parsing uses `parseYaml` from Obsidian, not the metadata cache.** The metadata cache is async and may not be populated for newly created files; reading content via `app.vault.read(file)`, splitting off the leading `---...---` block, and parsing it with `parseYaml` is synchronous-enough and works immediately after `app.vault.create`.
- **Quick Record uses a custom floating div, not a `Notice`.** Obsidian `Notice` does not support real interactive buttons. The floater is a `position: fixed` div on `document.body`, owned by `QuickRecordController`, with `cancel()` wired into `onunload`.
- **`secrets.json.nosync` uses the `.nosync` suffix on purpose**: iCloud Drive natively skips any file or folder whose name ends in `.nosync`. The README documents per-tool sync exclusion for other tools.
- **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.
- **Whisper-host orphan detection is observe-only.** If `start()` finds the configured port already bound (the port-in-use probe fails), it throws a "Port in use. Check Activity Monitor or Task Manager." message. The plugin must NEVER kill a process it didn't start. Surface the conflict; let the user decide.
- **`onunload` stops the whisper-host fire-and-forget.** `void this.whisperHost?.stop()` — Obsidian's `Plugin.onunload` signature is `() => void`, so we can't await. Stop() sends SIGTERM with a 3 s SIGKILL fallback, but the unload sequence may complete before that. On Obsidian quit, the child dies with the parent process anyway; on plugin disable/reload, a brief orphan window is possible. Don't try to make unload async.
- **WhisperHost has three ownership states.** `'spawned'` (this session's child handle is live; log capture works), `'adopted'` (port bound, PID sidecar matches a live PID — we started it in a previous session, no log capture), `'external'` (port bound but no sidecar match — someone else started it). Status enum gains `'external'` alongside `stopped`/`starting`/`running`/`crashed`; `running` means we own it (spawned OR adopted) and Stop works, `external` means we don't and Stop is disabled. `WhisperHost.snapshot()` returns `{ status, baseUrl, ownership, pid }` for UI consumers; `formatWhisperStatus(snap)` produces labels like "Running on http://... (adopted from previous session, pid 12345)." or "External whisper-server on http://... (not started by ReWrite).".
- **Transcription never checks `WhisperHost.status()`.** [src/transcription/whisper-local.ts](src/transcription/whisper-local.ts) just asks for `host.baseUrl()` and POSTs. The HTTP API doesn't care who owns the process — if the port is reachable, transcription works. `baseUrl()` returns the URL for both `'running'` and `'external'` states.
- **PID sidecar at `<plugin folder>/whisper-host.pid.json`** records `{ pid, port, binaryPath, startedAt }` once the server is ready. `stop()` and the child `exit` handler clear it. `WhisperHost.probe(config)` (called from `onload` and at the top of `start()`) reads the sidecar: if the port is reachable AND the sidecar PID is still alive AND the sidecar port matches the configured port, the host adopts it as `'running'` with ownership `'adopted'`. Otherwise (port bound, no sidecar match) the host transitions to `'external'`. Probe never disturbs state when we already hold a live spawned child. Uses `process.kill(pid, 0)` for liveness probing (added to the lazy `NodeAPI` cache alongside `cp`/`net`/`fs`).
- **Stop semantics depend on ownership.** Spawned: `child.kill()` via the live ChildProcess handle. Adopted: `process.kill(pid, signal)` since we only have the PID, then clear the sidecar. External: `stop()` throws "not started by ReWrite — stop it via OS tools." (the settings-tab button is disabled with a tooltip, the status-bar click shows a Notice, the `stop-whisper-host` command is hidden via `checkCallback`). This preserves the "never kill a process we didn't start" invariant — the sidecar is proof we started it.
- **`onunload` stops the whisper-host fire-and-forget.** `void this.whisperHost?.stop()` — Obsidian's `Plugin.onunload` signature is `() => void`, so we can't await. Stop() sends SIGTERM with a 3 s SIGKILL fallback, but the unload sequence may complete before that. Child processes do NOT auto-die with the parent on Linux (they reparent to init), on Windows (orphaned but still running), or reliably on macOS (SIGTERM may not land before Obsidian exits). The probe/adopt flow above is what closes this hole on next launch; don't try to make unload async.
- **Audio persistence runs before transcription**, not after, so the user keeps the recording even if transcription fails. [src/audio-persist.ts](src/audio-persist.ts) catches its own errors and emits a `Notice`; the pipeline always continues to the transcribe stage even when persistence throws. Cancel paths in [src/ui/modal.ts](src/ui/modal.ts) and [src/ui/quick-record.ts](src/ui/quick-record.ts) call `recorder.cancel()` before `runPipeline()`, so no orphan file is written on cancel. The `![[<path>]]` embed is prepended to the cleaned output unconditionally when an audio file was saved, regardless of insert mode. The reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) skips persistence by passing `sourcePath` on the `audio` source variant; the embed prepend still runs, reusing the existing vault path so reprocessed output links back to the original file.
- **Wake-name extraction is regex-only, off by default.** [src/wake-name.ts](src/wake-name.ts) requires `<assistantName>,` (vocative comma) to fire, captures up to the next sentence terminator or next name occurrence, and drops filler matches ("never mind", "scratch that", short tokens). It runs on ALL pipeline sources, including `paste` and `text`. The extracted instructions are appended to the LLM system prompt as a numbered `## Ad-hoc instructions` block, prefaced by `plugin.assistantPrompt` (loaded from `GlobalSettings.assistantPromptPath`); when the file is missing or empty, `DEFAULT_ASSISTANT_PROMPT` from [src/assistant-prompt.ts](src/assistant-prompt.ts) is used as the fallback so behavior is identical to the previous hardcoded textarea. Both the OpenAI and Anthropic adapters route this into the API's system slot. Whisper transcription homophones ("Scribner", "Scrivner") are not fuzzy-matched in v1; document the limitation if a user reports misses.
- **Known nouns frontmatter is NOT sent to the LLM.** The vault file at `GlobalSettings.knownNounsPath` uses YAML frontmatter purely for human-readable guidance (token-cost warning, format hint). Only the body lines are parsed via `loadKnownNounsFromFile` and injected by `buildKnownNounsSystemPromptSection`. If a future change opts frontmatter in, it should be a per-vault opt-in setting, not the default. The body parser treats `#` lines and blank lines as ignored; an entry can be either bare canonical (`Anthropic`) or canonical + misheard alternates (`Hoxhunt: hawks hunt, hocks hunt`).
- **PipelineHost decouples the pipeline from `ReWritePlugin`.** [src/pipeline.ts](src/pipeline.ts) reads `params.host.assistantPrompt` and `params.host.knownNouns` through the narrow `PipelineHost` interface in [src/types.ts](src/types.ts). The plugin class `implements PipelineHost`, but the pipeline never imports `ReWritePlugin` directly, which would create a cycle through the UI layer. New cross-cutting cleanup-stage inputs should extend `PipelineHost` rather than reach for the plugin object.
- **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).
- **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.
- **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.
## Local install for testing

View file

@ -47,8 +47,13 @@ When you click Start in settings, the plugin launches whisper-server as a child
### Setup
1. Download or build whisper.cpp. The repo's [releases page](https://github.com/ggerganov/whisper.cpp/releases) ships prebuilt `whisper-server` binaries for common platforms; or `make server` from a clone.
2. Download a GGML model file (e.g. `ggml-base.en.bin`, `ggml-small.bin`, `ggml-large-v3.bin`) from [Hugging Face](https://huggingface.co/ggerganov/whisper.cpp/tree/main). Larger models are more accurate and slower.
1. Obtain a `whisper-server` binary:
- **Windows**: download the latest `whisper-bin-x64.zip` (CPU) or `whisper-cublas-12.4.0-bin-x64.zip` (NVIDIA GPU) from the [whisper.cpp releases page](https://github.com/ggerganov/whisper.cpp/releases), unzip somewhere stable (e.g. `C:\Tools\whisper.cpp\`), and use the path to `whisper-server.exe` inside that folder.
- **macOS**: the easiest path is Homebrew (`brew install whisper-cpp`), which installs a `whisper-server` binary; `which whisper-server` will show its absolute path. Or build from source the same way as Linux below.
- **Linux**: there are no official Linux binaries on the releases page, so you build from source once. See "Building whisper-server on Linux" just below.
2. Download a GGML model file. Two sources work out of the box:
- **Upstream GGML models** from [Hugging Face](https://huggingface.co/ggerganov/whisper.cpp/tree/main), e.g. `ggml-base.en.bin`, `ggml-small.bin`, `ggml-large-v3.bin`. Larger models are more accurate and slower.
- **FUTO whisper-acft models** (see the next section). These are quantized, finetuned variants that support dynamic audio context; they load with the same `-m` flag as upstream models.
3. Open ReWrite settings, scroll to "Local whisper.cpp server (desktop)", and fill in:
- Binary path: absolute path to `whisper-server` (or `whisper-server.exe` on Windows).
- Model path: absolute path to the `.bin` file.
@ -56,12 +61,85 @@ When you click Start in settings, the plugin launches whisper-server as a child
4. Click Start. The status indicator transitions from Stopped to Starting to Running. View log shows whisper-server's stdout/stderr if startup fails.
5. In the profile you want to use it from, set Transcription provider to "Local whisper.cpp (desktop only)". The Transcription model field is decorative for this provider; whisper-server uses whichever model file is loaded at startup.
### Building `whisper-server` on Linux
whisper.cpp doesn't publish prebuilt Linux binaries, so you need to compile it once. The build is quick (under a minute on a modern laptop) and produces a single executable that you then point the plugin at.
1. Install the toolchain. Pick the line for your distro:
- Debian / Ubuntu / Mint: `sudo apt update && sudo apt install -y build-essential cmake git`
- Fedora / RHEL: `sudo dnf install -y gcc-c++ make cmake git`
- Arch / Manjaro: `sudo pacman -S --needed base-devel cmake git`
- openSUSE: `sudo zypper install -y gcc-c++ make cmake git`
2. Clone and build:
```bash
git clone https://github.com/ggerganov/whisper.cpp.git
cd whisper.cpp
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j --config Release
```
The default build includes the `server` example, so no extra flags are needed. If you have an NVIDIA GPU and want CUDA acceleration, replace the first `cmake` line with `cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CUDA=ON` (requires the CUDA toolkit installed; the build takes substantially longer).
3. After the build finishes, the binary lives at:
```
<path-to-clone>/build/bin/whisper-server
```
Copy or symlink it somewhere stable (e.g. `~/.local/bin/whisper-server`) if you want to keep the absolute path short. Make sure it is executable: `chmod +x build/bin/whisper-server`.
4. Use that absolute path as Binary path in the plugin's "Local whisper.cpp server (desktop)" settings section. To sanity-check the binary outside the plugin first, run it once from a terminal with a model file: `./build/bin/whisper-server -m /path/to/model.bin --port 8080`. You should see `whisper server listening at http://127.0.0.1:8080`. Hit Ctrl-C to stop, then let the plugin manage it from there.
If `cmake --build` fails with `error: 'std::filesystem' has not been declared` or similar C++17 errors, your distro's default GCC is too old. Install a newer one (`sudo apt install g++-12` on Ubuntu) and rerun the `cmake -B build ...` step with `-DCMAKE_CXX_COMPILER=g++-12` appended.
### FUTO whisper-acft models
[whisper-acft](https://github.com/futo-org/whisper-acft) is a set of Whisper checkpoints finetuned by FUTO so that whisper.cpp's encoder tolerates a dynamic `audio_ctx` (the number of audio frames the encoder processes). With stock Whisper models, lowering `audio_ctx` to match shorter clips makes the decoder unstable; the ACFT models were retrained to handle this gracefully, which can cut latency on short utterances substantially (often a 2x to 4x speedup on the small models, depending on hardware).
The published checkpoints are quantized to `q8_0` and ship in the same GGML container that whisper.cpp's `-m` flag already accepts, so no special build of whisper-server is required. You just need a whisper.cpp version recent enough to recognize the `-ac` / `--audio-context` flag (any reasonably current `whisper-server` release does).
1. Pick a model and download the `.bin` directly. English-only checkpoints are smaller and faster for English input; multilingual handles other languages but is slightly slower at the same size.
English-only:
- tiny.en: `https://voiceinput.futo.org/VoiceInput/tiny_en_acft_q8_0.bin`
- base.en: `https://voiceinput.futo.org/VoiceInput/base_en_acft_q8_0.bin`
- small.en: `https://voiceinput.futo.org/VoiceInput/small_en_acft_q8_0.bin`
Multilingual:
- tiny: `https://voiceinput.futo.org/VoiceInput/tiny_acft_q8_0.bin`
- base: `https://voiceinput.futo.org/VoiceInput/base_acft_q8_0.bin`
- small: `https://voiceinput.futo.org/VoiceInput/small_acft_q8_0.bin`
Save the file anywhere you like (the same folder as your other GGML models is fine). Verify the download finished cleanly before pointing the plugin at it; a truncated `.bin` will fail to load with a cryptic error in the log tail.
2. In ReWrite settings under "Local whisper.cpp server (desktop)", set Model path to the absolute path of the FUTO `.bin` you just downloaded. Binary path and Port are unchanged from the standard setup above.
3. Expand the "Advanced" disclosure inside that section and set Extra args to:
```
-ac 768
```
`-ac` (alias `--audio-context`) caps the encoder context at the given number of mel frames. Lower values run faster but only stay accurate on ACFT-finetuned models, which is the whole point of using them. A few starting points:
- `-ac 768` is a sensible default for short to medium clips (roughly up to ~15 s). Drop to `-ac 512` for short voice memos under ~10 s.
- `-ac 1500` (the whisper.cpp default for 30 s of audio) disables the speedup. Use this if you regularly dictate longer than ~20 s and notice the tail being cut off.
- You can pass additional flags on the same line, space-separated, e.g. `-ac 768 -t 4` to also cap CPU threads at 4.
4. Click Start (or Restart if the host was already running). The status pill should return to Running and the View log output should show whisper-server loading the ACFT model file. From the consuming profile, the Local whisper.cpp transcription provider needs no changes; it forwards the audio over the same `/v1/audio/transcriptions` endpoint and the `-ac` value is applied server-side.
If transcription quality drops noticeably (truncated sentences, missing trailing words), raise `-ac` toward 1500 until it stabilizes. The right value depends on how long your typical recording is; there is no single best number.
### Troubleshooting
- **Port already in use**: another process is bound to the configured port. Change the port (or stop the other process). The plugin will not kill processes it did not start.
- **Antivirus quarantine on Windows**: Windows Defender or third-party AV may flag `whisper-server.exe` on first run. The plugin cannot work around this; whitelist the binary in your AV settings.
- **Permission denied on macOS or Linux**: ensure the binary is executable (`chmod +x whisper-server`).
- **Process did not become ready within 5 s**: the model failed to load (file path wrong, file corrupted, RAM exhausted). The log tail will show whisper.cpp's error.
- **`unknown argument: -ac` (or `--audio-context`) in the log**: your `whisper-server` build predates the dynamic audio-context flag. Update to a current whisper.cpp release, or remove the `-ac` value from Extra args (you can still use the FUTO model files without the flag, you just lose the latency benefit).
- **FUTO model loads but transcripts are truncated or jumbled**: `-ac` is set too low for the length of audio you are dictating. Raise it (e.g. `768` to `1024` to `1500`) until the output is stable.
## Excluding `secrets.json.nosync` from sync

View file

@ -16,6 +16,64 @@ Phase A shipped (see Done). Remaining work:
## Done
### Mode selector on the Quick Record floater
The Quick Record floating mini-UI ([src/ui/quick-record.ts](../src/ui/quick-record.ts)) gained a third button between the timer and Stop that opens a popover-style list of `plugin.templates`. Selecting an entry updates `QuickRecordController.template` in place and the button label so the user can see which template will run. Switching does NOT update `lastUsedTemplateId`; that still only happens after a successful completion. The popover is a child of the floater div, dismisses on selection / Escape / outside-click (capture-phase listeners on `document`, registered and torn down per open), and is force-closed when `setBusy` runs so the user can't switch templates after Stop is pressed. CSS additions in [styles.css](../styles.css): `.rewrite-quick-template` (the button), `.rewrite-quick-popover` (the floating list), `.rewrite-quick-popover-item` (rows, with an `is-active` modifier for the current selection). No destination-override UI on the floater per the v1 scope decision.
### Per-invocation destination override on the main modal
The ReWrite modal ([src/ui/modal.ts](../src/ui/modal.ts)) renders a "Destination" row under the template picker exposing Insert mode (cursor / new file / append) and, conditionally, folder + filename-template inputs for `newFile` mode. The selection defaults to the active template's values and only becomes a non-null `DestinationOverride` once the user touches any field. A "Reset to template default" button clears it back to null. Switching the template selector resets the override (the new template's values become the new defaults). The override is plumbed through `PipelineParams.destinationOverride` and applied by `applyDestinationOverride` in [src/pipeline.ts](../src/pipeline.ts), which spreads the override onto a copy of the template before calling `insertOutput` — the template object passed in is never mutated, and the underlying Markdown file on disk is never touched. Applies to all three modal tabs (Record / Paste / From note) because they all converge on the modal's single `execute()` call site. Not exposed in Quick Record / `runTextPipeline` / `runAudioFilePipeline` to keep those paths frictionless; if a user asks for it later we can add a destination row to the template picker modal.
### Move the assistant prompt into the vault; standardize branding to "Assistant"
The system-prompt preface that frames extracted ad-hoc directives moved from a settings textarea (`GlobalSettings.adHocInstructionsPrompt`) to a Markdown file in the vault (`GlobalSettings.assistantPromptPath`, default `ReWrite/AssistantPrompt.md`). [src/assistant-prompt.ts](../src/assistant-prompt.ts) exports `loadAssistantPromptFromFile`, `populateDefaultAssistantPrompt`, `isPathAssistantPrompt`, and `DEFAULT_ASSISTANT_PROMPT`. The loader strips any leading frontmatter (tolerated for future extensions but not consumed in v1) and returns the trimmed body. Cache lives at `plugin.assistantPrompt: string | null`, refreshed in [src/main.ts](../src/main.ts) on `workspace.onLayoutReady`, scoped vault `create/modify/delete/rename` events, the path-field change, and the Populate button. [src/pipeline.ts](../src/pipeline.ts) reads `params.host.assistantPrompt ?? DEFAULT_ASSISTANT_PROMPT` inside `cleanupTranscript`, so a missing or empty file falls back to the previous hardcoded preface.
Branding standardized to "Assistant" everywhere the old "AI" / "Agent" labels appeared. `GlobalSettings.aiName``assistantName`; "AI name" label → "Assistant name"; "Agent control prompt" textarea → "Assistant prompt file" path field. `GlobalSettings.adHocInstructionsPrompt` is gone (the file is the source of truth). The wake-name term is preserved separately because it names the trigger, not the persona. Pre-release rule applies: no migration code, no compatibility reads — dev installs reset via deleting `data.json`. The settings tab's "Ad-hoc instructions" section heading stays (the *feature* is ad-hoc-instruction extraction; "Assistant" is the persona inside it).
### Known nouns list in the vault
A new vault file at `GlobalSettings.knownNounsPath` (default `ReWrite/KnownNouns.md`) holds proper nouns the LLM should preserve verbatim, with optional misheard alternates. File format: YAML frontmatter for human-readable guidance (token-cost warning, format hint) plus a 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; the structure leaves a future opt-in possible. The default file body (created by the Populate button) includes both the guidance frontmatter and two example nouns the user can keep or delete.
[src/known-nouns.ts](../src/known-nouns.ts) exports `loadKnownNounsFromFile`, `populateDefaultKnownNouns`, `isPathKnownNouns`, and `buildKnownNounsSystemPromptSection(nouns)`. Cache on `plugin.knownNouns: KnownNoun[]`, refreshed on the same triggers as the assistant prompt. [src/pipeline.ts](../src/pipeline.ts) appends the section returned by `buildKnownNounsSystemPromptSection` to the system prompt only when non-empty; order is template prompt → ad-hoc instructions → known nouns. The settings tab has a dedicated "Known nouns" section with the path field, a Populate button, an Open-file button, and a reminder that an unbounded list inflates token cost on every recording. The default file body carries the same warning so users see it the first time they open the file.
Side change: `PipelineParams` gained a `host: PipelineHost` field (narrow interface in [src/types.ts](../src/types.ts): `{ assistantPrompt; knownNouns }`). The plugin class `implements PipelineHost`; the pipeline never imports `ReWritePlugin` directly (would form a cycle through the UI layer). All four call sites (modal, Quick Record, text-source, audio-source) updated to pass `host: plugin`.
### Reprocess a saved recording
Added [src/ui/audio-source.ts](../src/ui/audio-source.ts) (`isAudioFile`, `collectAudioFiles`, `readAudioFileAsBlob`, `runAudioFilePipeline`) and [src/ui/audio-file-picker.ts](../src/ui/audio-file-picker.ts) (a `FuzzySuggestModal<TFile>` over the vault's audio files). Extended `audio` `PipelineSource` with an optional `sourcePath: string`; when set, [src/pipeline.ts](../src/pipeline.ts) skips the `persist-audio` stage entirely and routes the existing path straight to the `![[<path>]]\n\n` prepend, so reprocessed output keeps the audio link without writing a duplicate file. Three entry points: a `rewrite-plugin:reprocess-audio` command opens the fuzzy file picker, an editor-menu item appears when the cursor sits inside an `![[<audio>]]` embed (resolved via `app.metadataCache.getFirstLinkpathDest`), and a file-menu item appears for audio files in the file explorer. All three open the existing `TemplatePickerModal` and call `runAudioFilePipeline`, which gates on the full voice profile (`isProfileConfigured` from [src/ui/setup-card.ts](../src/ui/setup-card.ts)) and falls back to opening the main modal if either side is unconfigured. Mime type for the reread blob is derived from the file extension via the new `extensionToMime` helper in [src/audio-persist.ts](../src/audio-persist.ts) (reverse of `mimeToExtension`; also exports an `AUDIO_EXTENSIONS` constant the picker and file-menu both filter against).
### Custom prompt for ad-hoc instruction handling
Replaced the hardcoded preface line in the `## Ad-hoc instructions` block at [src/pipeline.ts](../src/pipeline.ts) with a user-editable string. Originally landed as a settings textarea (`GlobalSettings.adHocInstructionsPrompt`); later moved into a vault Markdown file (`GlobalSettings.assistantPromptPath`) and rebranded under "Assistant" — see "Move the assistant prompt into the vault" above. Both LLM adapters route `systemPrompt` into the API system slot, so neither change required provider edits.
### Adopt orphaned whisper-server processes; surface external ones
Closing Obsidian doesn't reliably kill spawned `whisper-server` children on any platform (Linux reparents to init, Windows orphans, macOS SIGTERM may not land before exit). Previously the new plugin instance had no PID handle, so `start()` would hit "port in use", `stop()` was a no-op, and transcription would fail because [src/transcription/whisper-local.ts](../src/transcription/whisper-local.ts) artificially required `host.status() === 'running'` even though the HTTP API doesn't care who owns the process.
Reworked into a three-state model: `'spawned'` (live child handle this session, log capture works), `'adopted'` (port bound, PID sidecar matches a live PID — we started it last session, no log capture), `'external'` (port bound but no sidecar match — someone else started it). Status enum gains `'external'`. `WhisperHost.start()` writes a sidecar at `<plugin folder>/whisper-host.pid.json` 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()`) does the discovery dance; never disturbs state when we already hold a live spawned child.
Transcription drops the `status()` check entirely and just asks `host.baseUrl()`, which returns the URL for both `'running'` and `'external'` so transcription Just Works regardless of ownership. Stop semantics: spawned uses the ChildProcess handle, adopted uses `process.kill(pid, signal)`, external throws "not started by ReWrite — stop it via OS tools." (settings tab button is disabled with a tooltip, status-bar click shows a Notice, `stop-whisper-host` command is hidden). Status bar/settings labels read "Running on http://... (adopted from previous session, pid N).", "External whisper-server on http://... (not started by ReWrite).", etc. New `is-external` dot color (orange) in [styles.css](../styles.css). On `onload`, a Notice fires when we adopt ("Adopted whisper-server from previous session (pid N)") or detect external ("Detected external whisper-server on URL. Transcription will use it; ReWrite won't stop it."). Preserves the "never kill a process we didn't start" invariant — the sidecar is proof we started it.
### Start/stop local whisper.cpp server from outside settings
Surfaced a status-bar item ([src/ui/whisper-status-bar.ts](../src/ui/whisper-status-bar.ts)) showing a colored dot plus "Whisper: <status>" label. Click toggles start/stop, hover tooltip matches the settings-tab status string (extracted to `formatWhisperStatus` in [src/whisper-host.ts](../src/whisper-host.ts) so both surfaces share one implementation). The item polls `whisperHost.status()` every 1 s via `registerInterval` and hides via the `rewrite-hidden` CSS class when on mobile or when the active profile's transcription provider is not `whisper-local`. Added two command-palette entries (`start-whisper-host` / `stop-whisper-host`) using `checkCallback` so they only appear when actionable on the current platform and host state. Same error-surfacing pattern as the settings tab (`Notice` with the error message). Did not add a ribbon variant or modal-header indicator, per the FEATURES.md "pick one" guidance.
### Persist recorded audio to an attachments folder
Added [src/audio-persist.ts](../src/audio-persist.ts) with `mimeToExtension`, `buildAudioFilename`, and `persistAudio`. The pipeline ([src/pipeline.ts](../src/pipeline.ts)) gained a new `'persist-audio'` stage that runs BEFORE transcription on `audio` sources, so the user keeps the recording even if transcription fails. Filename is `ReWrite-YYYY-MM-DD-HHmmss.<ext>` with the extension derived from the blob's mime type (`webm` / `m4a` / `ogg` / `wav` / `mp3`, default `webm`). Path resolution: when the new `GlobalSettings.attachmentsFolderPath` setting is non-empty, the file lands under that folder with manual de-collision (`-1`, `-2`, ...); when empty, the path comes from `app.fileManager.getAvailablePathForAttachment(filename)`, honoring Obsidian's own attachments setting. An `![[<path>]]\n\n` embed is unconditionally prepended to the cleaned output when audio was persisted, regardless of insert mode. Persistence failure is non-fatal: a Notice fires and transcription proceeds. Cancel paths (modal, Quick Record) call `recorder.cancel()` before `runPipeline()`, so no orphan files are written on cancel. UI stage label "Saving audio..." added to both modal and Quick Record floater. Setting field lives under "Recording" in the settings tab.
### Address the assistant directly mid-recording with a wake name (default "Scrivener")
Added [src/wake-name.ts](../src/wake-name.ts) exposing `extractAdHocInstructions(transcript, name)`. Regex pattern requires the vocative comma form (`<name>,`), captures up to a sentence terminator or the next name occurrence, drops filler tokens ("never mind", "scratch that", under 2 chars), and post-processes whitespace. Wired into `cleanupTranscript` in [src/pipeline.ts](../src/pipeline.ts): when `GlobalSettings.adHocInstructionsEnabled` is on and `GlobalSettings.assistantName` is non-empty, the transcript is scanned BEFORE the LLM call. Extracted instructions are stripped from the transcript and appended to the template prompt as a numbered `## Ad-hoc instructions` block; both OpenAI and Anthropic adapters route this into the API system slot. A `Notice` ("Heard N ad-hoc instruction(s).") confirms detection to the user. Applies to all four `PipelineSource` variants (audio / paste / webspeech / text) since the scan runs after `collectTranscript()`. Off by default. Global setting (not per-template). v1 is exact-match regex; fuzzy/Levenshtein matching for whisper homophones deferred. Settings UI is a heading + Enabled toggle + (conditional) Assistant name field, re-rendered on toggle change. Originally landed under the label "AI name"; rebranded to "Assistant name" alongside the assistant-prompt-file change.
### Templates as Markdown files in a vault folder
Removed the in-settings template editor (drag-to-reorder list, per-template editor, Add/Delete buttons, `ConfirmModal` for deletion). `GlobalSettings.templates` is gone; in its place is a `templatesFolderPath` string (default `ReWrite/Templates`). Each `.md` file in that folder is one template: YAML frontmatter holds `id` / `name` / `insertMode` / `newFileFolder` / `newFileNameTemplate`, the body is the LLM prompt. [src/templates-folder.ts](../src/templates-folder.ts) handles loading (sorted by basename so users can prefix `01-`, `02-` to order), and `populateDefaultTemplates(app, folderPath)` writes the five built-in defaults into the folder, skipping any whose `id` already exists on disk so the button is safe to re-run. `plugin.templates` is the cache, refreshed in [src/main.ts](../src/main.ts) on `workspace.onLayoutReady` and via vault `create` / `modify` / `delete` / `rename` events scoped to the templates folder. `defaultTemplateId` stayed as a settings field (now a dropdown of loaded templates) and references the frontmatter `id`, so renames don't break it. Used `parseYaml` / `stringifyYaml` from Obsidian rather than the metadata cache (which is async and doesn't populate immediately after `app.vault.create`).
### Remove the "Advanced" disclosure around whisper-host extra args
Unwrapped the `<details>` that hid the `extraArgs` field in [src/settings/tab.ts](../src/settings/tab.ts)' `renderLocalWhisperServer`. The field now renders inline next to Binary path / Model path / Port. No setting-shape change, just one click less to reach a useful knob.
### Plugin-managed local whisper.cpp server (Phase A: on-demand, button-driven)
Added [src/whisper-host.ts](../src/whisper-host.ts) `WhisperHost` class with `start` / `stop` / `status` / `baseUrl` / `getLog`. Lazy-requires `child_process`, `net`, `fs` inside a `Platform.isDesktop` guard (mirrors the [src/secrets.ts](../src/secrets.ts) `safeStorage` pattern). `start()` validates binary and model paths via `fs.existsSync`, probes the port via `net.createServer().listen(port)` to detect conflicts (does NOT kill unknown bound processes), spawns whisper-server, captures stdout/stderr to a 1 MB ring buffer, polls `net.createConnection` every 250ms for up to 5s before declaring `'running'`. `stop()` sends SIGTERM with a 3s SIGKILL fallback. New settings section "Local whisper.cpp server (desktop)" with binary/model/port fields, an Advanced disclosure for extra args, status indicator with Start/Stop button, and a View log disclosure (last ~50k chars of the ring buffer). New transcription provider option `'whisper-local'` filtered out of dropdowns on mobile; thin shim in [src/transcription/whisper-local.ts](../src/transcription/whisper-local.ts) POSTs to `http://127.0.0.1:<port>/v1/audio/transcriptions` (OpenAI-shaped). No API key field for this provider. [src/main.ts](../src/main.ts) instantiates the host in onload and fire-and-forget stops it in onunload. README has setup walkthrough + transparency disclosure. Phase B (auto-start lifecycle + idle timeout) deferred.

View file

@ -45,6 +45,7 @@ const REWRITE_BRANDS = [
const REWRITE_ACRONYMS = [
"LLM", "STT", "TTS",
"OS", "PBKDF2", "AES", "GCM", "KDF",
];
const sentenceCaseOptions = {

View file

@ -0,0 +1,316 @@
#!/usr/bin/env bash
# Build whisper.cpp's whisper-server on Linux for the ReWrite Obsidian plugin.
#
# Supports Debian/Ubuntu/Mint, Fedora/RHEL/CentOS/Rocky/Alma, Arch/Manjaro/EndeavourOS,
# and openSUSE. Falls back to --skip-deps for anything else.
#
# Usage: bash scripts/build-whisper-linux.sh [options]
# --cuda Build with NVIDIA CUDA acceleration (requires CUDA toolkit).
# --source-dir DIR Where to clone whisper.cpp. Default: $HOME/.local/share/whisper.cpp
# --prefix DIR Where to symlink the binary. Default: $HOME/.local/bin
# --no-symlink Skip the symlink step; just print the built binary path.
# --skip-deps Do not run the distro package-install step.
# --jobs N Parallel build jobs. Default: nproc.
# -h, --help Show this help.
set -euo pipefail
REPO_URL="https://github.com/ggml-org/whisper.cpp.git"
SOURCE_DIR="${HOME}/.local/share/whisper.cpp"
PREFIX="${HOME}/.local/bin"
USE_CUDA=0
DO_SYMLINK=1
SKIP_DEPS=0
JOBS=""
log() { printf '\033[1;34m[build-whisper]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[build-whisper]\033[0m %s\n' "$*" >&2; }
err() { printf '\033[1;31m[build-whisper]\033[0m %s\n' "$*" >&2; }
# Normalize a path: expand a leading ~ and resolve to an absolute path.
# We don't require the path to exist yet (it may be the install target).
normalize_path() {
local p="$1"
# Expand a leading tilde manually. We avoid `eval` and avoid relying on
# tilde expansion (which doesn't happen inside double quotes or after
# assignment from a function argument).
if [ "$p" = "~" ]; then
p="$HOME"
elif [ "${p#\~/}" != "$p" ]; then
p="${HOME}/${p#\~/}"
fi
case "$p" in
/*) printf '%s\n' "$p" ;;
*) printf '%s/%s\n' "$PWD" "$p" ;;
esac
}
usage() {
# Try to print the header comment block from the script file. When the script
# is piped (curl | bash), $0 is "bash" or "-" and reading it won't work, so
# fall back to an inline help string.
if [ -f "$0" ] && [ -r "$0" ]; then
awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"
else
cat <<'HELP'
Build whisper.cpp's whisper-server on Linux for the ReWrite Obsidian plugin.
Usage: bash build-whisper-linux.sh [options]
--cuda Build with NVIDIA CUDA acceleration (requires CUDA toolkit).
--source-dir DIR Where to clone whisper.cpp. Default: $HOME/.local/share/whisper.cpp
--prefix DIR Where to symlink the binary. Default: $HOME/.local/bin
--no-symlink Skip the symlink step; just print the built binary path.
--skip-deps Do not run the distro package-install step.
--jobs N Parallel build jobs. Default: nproc.
-h, --help Show this help.
HELP
fi
exit "${1:-0}"
}
while [ $# -gt 0 ]; do
case "$1" in
--cuda) USE_CUDA=1; shift ;;
--source-dir) SOURCE_DIR="${2:?--source-dir needs a path}"; shift 2 ;;
--prefix) PREFIX="${2:?--prefix needs a path}"; shift 2 ;;
--no-symlink) DO_SYMLINK=0; shift ;;
--skip-deps) SKIP_DEPS=1; shift ;;
--jobs) JOBS="${2:?--jobs needs a number}"; shift 2 ;;
-h|--help) usage 0 ;;
*) err "Unknown option: $1"; usage 1 ;;
esac
done
if [ -z "$JOBS" ]; then
if command -v nproc >/dev/null 2>&1; then
JOBS="$(nproc)"
else
JOBS=4
fi
fi
# Normalize user-supplied paths now that argument parsing is done.
# This handles `~`, `~/foo`, and relative paths like `./build`.
SOURCE_DIR="$(normalize_path "$SOURCE_DIR")"
PREFIX="$(normalize_path "$PREFIX")"
# ---------- distro detection ----------
detect_distro() {
if [ ! -r /etc/os-release ]; then
echo "unknown"
return
fi
# shellcheck disable=SC1091
. /etc/os-release
local id="${ID:-}"
local id_like="${ID_LIKE:-}"
local all=" ${id} ${id_like} "
case "$all" in
*" debian "*|*" ubuntu "*|*" linuxmint "*|*" pop "*|*" raspbian "*) echo "debian" ;;
*" fedora "*|*" rhel "*|*" centos "*|*" rocky "*|*" almalinux "*) echo "fedora" ;;
*" arch "*|*" manjaro "*|*" endeavouros "*|*" cachyos "*) echo "arch" ;;
*" opensuse "*|*" opensuse-tumbleweed "*|*" opensuse-leap "*|*" suse "*) echo "suse" ;;
*) echo "unknown" ;;
esac
}
sudo_cmd() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
err "Need root or sudo to install packages. Re-run as root, install sudo, or use --skip-deps."
exit 1
fi
}
install_deps() {
local distro="$1"
case "$distro" in
debian)
log "Installing build deps via apt..."
sudo_cmd apt-get update
sudo_cmd apt-get install -y build-essential cmake git
;;
fedora)
log "Installing build deps via dnf..."
sudo_cmd dnf install -y gcc-c++ make cmake git
;;
arch)
log "Installing build deps via pacman..."
# -Sy refreshes the package database so install doesn't fail on stale mirror data.
# We deliberately avoid -Syu (full system upgrade) since partial upgrades are
# unsupported on Arch and a script shouldn't surprise the user with one.
sudo_cmd pacman -Sy --needed --noconfirm base-devel cmake git
;;
suse)
log "Installing build deps via zypper..."
sudo_cmd zypper install -y gcc-c++ make cmake git
;;
unknown|*)
warn "Could not identify distro from /etc/os-release."
warn "Install these yourself, then rerun with --skip-deps:"
warn " a C++17 compiler (g++ 9 or newer), make, cmake (>= 3.10), git"
exit 1
;;
esac
}
check_tools() {
local missing=()
for tool in git cmake make; do
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
# Need either g++ or clang++.
if ! command -v g++ >/dev/null 2>&1 && ! command -v clang++ >/dev/null 2>&1; then
missing+=("g++ or clang++")
fi
if [ "${#missing[@]}" -gt 0 ]; then
err "Missing required tools: ${missing[*]}"
err "Rerun without --skip-deps, or install them with your package manager."
exit 1
fi
}
# Fail early and clearly if --cuda was passed but no CUDA toolkit is visible.
# We can't install CUDA from a generic script, so the best we can do is bail
# before cmake spends time configuring and then failing with a noisy error.
check_cuda() {
if command -v nvcc >/dev/null 2>&1; then
return 0
fi
# Common install locations CUDA puts itself in but doesn't always add to PATH.
for candidate in /usr/local/cuda/bin/nvcc /opt/cuda/bin/nvcc; do
if [ -x "$candidate" ]; then
warn "Found nvcc at ${candidate} but it's not on PATH. Add its directory to PATH and rerun."
exit 1
fi
done
err "--cuda was requested but no CUDA toolkit (nvcc) was found."
err "Install the NVIDIA CUDA toolkit, ensure nvcc is on PATH, then rerun."
exit 1
}
# Check g++ is recent enough for whisper.cpp's std::filesystem usage.
# Warn rather than abort, since clang++ may still be picked up by cmake.
check_gcc_version() {
command -v g++ >/dev/null 2>&1 || return 0
local ver
ver="$(g++ -dumpfullversion -dumpversion 2>/dev/null | cut -d. -f1)"
if [ -n "$ver" ] && [ "$ver" -lt 9 ] 2>/dev/null; then
warn "g++ ${ver} is older than 9; whisper.cpp needs C++17. If the build fails on"
warn "std::filesystem or similar, install g++-12 (or newer) and rerun."
fi
}
# ---------- main flow ----------
DISTRO="$(detect_distro)"
log "Detected distro: ${DISTRO}"
if [ "$SKIP_DEPS" -eq 0 ]; then
install_deps "$DISTRO"
else
log "Skipping package install (--skip-deps). Verifying required tools are present..."
check_tools
fi
check_gcc_version
if [ "$USE_CUDA" -eq 1 ]; then
check_cuda
fi
# Clone or update whisper.cpp.
mkdir -p "$(dirname "$SOURCE_DIR")"
if [ -d "$SOURCE_DIR/.git" ]; then
log "Existing clone at ${SOURCE_DIR}; pulling latest..."
if [ -n "$(git -C "$SOURCE_DIR" status --porcelain)" ]; then
warn "Local changes detected in ${SOURCE_DIR}; skipping update."
else
# Resolve the default branch. symbolic-ref may be unset on some clones;
# fall back to master, which is what whisper.cpp currently uses.
DEFAULT_BRANCH="$(git -C "$SOURCE_DIR" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || true)"
DEFAULT_BRANCH="${DEFAULT_BRANCH:-master}"
git -C "$SOURCE_DIR" fetch --quiet origin "$DEFAULT_BRANCH"
# Use FETCH_HEAD so this works regardless of whether the local repo was
# originally a shallow clone or had its tracking branch set up.
git -C "$SOURCE_DIR" checkout --quiet "$DEFAULT_BRANCH" 2>/dev/null || \
git -C "$SOURCE_DIR" checkout --quiet -b "$DEFAULT_BRANCH" FETCH_HEAD
git -C "$SOURCE_DIR" reset --quiet --hard FETCH_HEAD
fi
elif [ -d "$SOURCE_DIR" ] && [ -z "$(ls -A "$SOURCE_DIR" 2>/dev/null)" ]; then
# An empty directory is fine; clone directly into it.
log "Cloning whisper.cpp into existing empty ${SOURCE_DIR}..."
git clone "$REPO_URL" "$SOURCE_DIR"
elif [ -e "$SOURCE_DIR" ]; then
err "${SOURCE_DIR} exists but is not a git clone of whisper.cpp."
err "Move it aside or pass --source-dir DIR to use a different location."
exit 1
else
log "Cloning whisper.cpp into ${SOURCE_DIR}..."
# Full clone (not --depth 1) so subsequent updates and branch switches work cleanly.
git clone "$REPO_URL" "$SOURCE_DIR"
fi
# Configure + build.
CMAKE_ARGS=(-B build -DCMAKE_BUILD_TYPE=Release)
if [ "$USE_CUDA" -eq 1 ]; then
log "CUDA build requested (-DGGML_CUDA=ON). Expect a longer build."
CMAKE_ARGS+=(-DGGML_CUDA=ON)
fi
log "Configuring (cmake ${CMAKE_ARGS[*]})..."
( cd "$SOURCE_DIR" && cmake "${CMAKE_ARGS[@]}" )
log "Building (jobs=${JOBS})..."
( cd "$SOURCE_DIR" && cmake --build build -j "$JOBS" --config Release )
BINARY="${SOURCE_DIR}/build/bin/whisper-server"
if [ ! -x "$BINARY" ]; then
err "Build finished but ${BINARY} is missing or not executable."
# Help the user see whether the binary was renamed upstream rather than just
# saying "missing" and leaving them to dig through the build tree.
if [ -d "${SOURCE_DIR}/build/bin" ]; then
err "Binaries actually present in ${SOURCE_DIR}/build/bin:"
# Use find rather than ls so this works even if there are no matches.
find "${SOURCE_DIR}/build/bin" -maxdepth 1 -type f -executable -printf ' %f\n' >&2 || true
err "If whisper-server was renamed upstream, update this script's BINARY path."
fi
err "Check the output above for errors. If you saw std::filesystem errors, install g++-12 or newer and rerun."
exit 1
fi
log "Built: ${BINARY}"
FINAL_PATH="$BINARY"
if [ "$DO_SYMLINK" -eq 1 ]; then
mkdir -p "$PREFIX"
LINK="${PREFIX}/whisper-server"
# Replace existing symlink, but refuse to overwrite a real file.
if [ -L "$LINK" ] || [ ! -e "$LINK" ]; then
ln -sf "$BINARY" "$LINK"
log "Symlinked ${LINK} -> ${BINARY}"
FINAL_PATH="$LINK"
else
warn "${LINK} exists and is not a symlink; leaving it alone."
warn "Use the built binary path directly, or remove ${LINK} and rerun."
fi
fi
cat <<EOF
Done. In Obsidian: Settings, ReWrite, "Local whisper.cpp server (desktop)",
set Binary path to:
${FINAL_PATH}
You still need a GGML model file. Grab one from
https://huggingface.co/ggerganov/whisper.cpp/tree/main (e.g. ggml-base.en.bin)
and point the Model path field at it.
Sanity-check outside the plugin first:
${FINAL_PATH} -m /path/to/model.bin --port 8080
You should see: whisper server listening at http://127.0.0.1:8080
EOF

79
src/assistant-prompt.ts Normal file
View file

@ -0,0 +1,79 @@
import { App, normalizePath, TFile } from 'obsidian';
export const DEFAULT_ASSISTANT_PROMPT = 'The user spoke these instructions mid-dictation. Apply each one as you produce the final text:';
const DEFAULT_FILE_BODY = `${DEFAULT_ASSISTANT_PROMPT}\n`;
export async function loadAssistantPromptFromFile(app: App, path: string): Promise<string | null> {
const normalized = normalizeFilePath(path);
if (!normalized) return null;
const file = app.vault.getAbstractFileByPath(normalized);
if (!(file instanceof TFile)) return null;
try {
const content = await app.vault.read(file);
const { body } = splitFrontmatter(content);
const trimmed = body.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
}
export async function populateDefaultAssistantPrompt(app: App, path: string): Promise<boolean> {
const normalized = normalizeFilePath(path);
if (!normalized) throw new Error('Assistant prompt path is empty.');
if (app.vault.getAbstractFileByPath(normalized)) return false;
const parent = parentFolder(normalized);
if (parent) await ensureFolder(app, parent);
await app.vault.create(normalized, DEFAULT_FILE_BODY);
return true;
}
export function isPathAssistantPrompt(path: string, configuredPath: string): boolean {
const normalizedConfigured = normalizeFilePath(configuredPath);
if (!normalizedConfigured) return false;
return normalizePath(path) === normalizedConfigured;
}
function splitFrontmatter(content: string): { frontmatter: string | null; body: string } {
if (!content.startsWith('---')) return { frontmatter: null, body: content };
const lines = content.split(/\r?\n/);
if (lines[0]?.trim() !== '---') return { frontmatter: null, body: content };
for (let i = 1; i < lines.length; i++) {
if (lines[i]?.trim() === '---') {
return {
frontmatter: lines.slice(1, i).join('\n'),
body: lines.slice(i + 1).join('\n'),
};
}
}
return { frontmatter: null, body: content };
}
function normalizeFilePath(path: string): string {
const trimmed = path.trim();
if (!trimmed) return '';
const normalized = normalizePath(trimmed);
if (!normalized || normalized === '/' || normalized === '.') return '';
return normalized;
}
function parentFolder(path: string): string {
const idx = path.lastIndexOf('/');
if (idx <= 0) return '';
return path.slice(0, idx);
}
async function ensureFolder(app: App, folder: string): Promise<void> {
const normalized = normalizePath(folder);
if (app.vault.getAbstractFileByPath(normalized)) return;
const parts = normalized.split('/');
let current = '';
for (const part of parts) {
if (!part) continue;
current = current ? `${current}/${part}` : part;
if (!app.vault.getAbstractFileByPath(current)) {
await app.vault.createFolder(current);
}
}
}

104
src/audio-persist.ts Normal file
View file

@ -0,0 +1,104 @@
import { App, moment, normalizePath } from 'obsidian';
import { GlobalSettings } from './types';
interface AttachmentFileManager {
getAvailablePathForAttachment(filename: string, sourcePath?: string): Promise<string>;
}
export const AUDIO_EXTENSIONS = ['mp3', 'wav', 'm4a', 'webm', 'ogg', 'flac', 'mp4'] as const;
export function extensionToMime(ext: string): string {
const clean = ext.toLowerCase().replace(/^\./, '');
switch (clean) {
case 'webm':
return 'audio/webm';
case 'ogg':
return 'audio/ogg';
case 'm4a':
case 'mp4':
return 'audio/mp4';
case 'wav':
return 'audio/wav';
case 'mp3':
return 'audio/mpeg';
case 'flac':
return 'audio/flac';
default:
return 'application/octet-stream';
}
}
export function mimeToExtension(mime: string): string {
const base = (mime.split(';')[0] ?? '').trim().toLowerCase();
switch (base) {
case 'audio/webm':
return 'webm';
case 'audio/ogg':
return 'ogg';
case 'audio/mp4':
return 'm4a';
case 'audio/wav':
case 'audio/wave':
case 'audio/x-wav':
return 'wav';
case 'audio/mpeg':
case 'audio/mp3':
return 'mp3';
default:
return 'webm';
}
}
export function buildAudioFilename(now: Date, ext: string): string {
const stamp = moment(now).format('YYYY-MM-DD-HHmmss');
return `ReWrite-${stamp}.${ext}`;
}
export async function persistAudio(app: App, blob: Blob, settings: GlobalSettings): Promise<string> {
const ext = mimeToExtension(blob.type);
const filename = buildAudioFilename(new Date(), ext);
const path = await resolveTargetPath(app, filename, settings.attachmentsFolderPath);
const buffer = await blob.arrayBuffer();
await app.vault.createBinary(path, buffer);
return path;
}
async function resolveTargetPath(app: App, filename: string, configuredFolder: string): Promise<string> {
const folder = configuredFolder.trim();
if (folder) {
await ensureFolder(app, folder);
const normalized = normalizePath(`${folder}/${filename}`);
return await deCollide(app, normalized);
}
const fm = (app as unknown as { fileManager?: AttachmentFileManager }).fileManager;
if (fm?.getAvailablePathForAttachment) {
return await fm.getAvailablePathForAttachment(filename);
}
return await deCollide(app, normalizePath(filename));
}
async function ensureFolder(app: App, folder: string): Promise<void> {
const normalized = normalizePath(folder);
if (app.vault.getAbstractFileByPath(normalized)) return;
const parts = normalized.split('/');
let current = '';
for (const part of parts) {
if (!part) continue;
current = current ? `${current}/${part}` : part;
if (!app.vault.getAbstractFileByPath(current)) {
await app.vault.createFolder(current);
}
}
}
async function deCollide(app: App, path: string): Promise<string> {
if (!app.vault.getAbstractFileByPath(path)) return path;
const dot = path.lastIndexOf('.');
const stem = dot > path.lastIndexOf('/') ? path.slice(0, dot) : path;
const ext = dot > path.lastIndexOf('/') ? path.slice(dot) : '';
for (let n = 1; n < 1000; n++) {
const candidate = `${stem}-${n}${ext}`;
if (!app.vault.getAbstractFileByPath(candidate)) return candidate;
}
throw new Error(`Could not find a free filename near ${path}.`);
}

127
src/known-nouns.ts Normal file
View file

@ -0,0 +1,127 @@
import { App, normalizePath, TFile } from 'obsidian';
import { KnownNoun } from './types';
const DEFAULT_FILE_BODY = `---
guidance: |
Keep this list focused on nouns the LLM keeps mangling, not your whole personal
glossary. Every entry rides along on every cleanup call, so an unbounded list
inflates token usage on every recording. Add words only when the LLM has
actually misheard or rewritten them; remove entries that have stopped being
a problem.
Format: one noun per line. Optionally add a colon followed by comma-separated
misheard variants so the LLM knows what to correct from.
---
Hoxhunt: hawks hunt, hocks hunt
Tofugu: toe fugue
`;
export async function loadKnownNounsFromFile(app: App, path: string): Promise<KnownNoun[]> {
const normalized = normalizeFilePath(path);
if (!normalized) return [];
const file = app.vault.getAbstractFileByPath(normalized);
if (!(file instanceof TFile)) return [];
try {
const content = await app.vault.read(file);
const { body } = splitFrontmatter(content);
return parseKnownNounsBody(body);
} catch {
return [];
}
}
export async function populateDefaultKnownNouns(app: App, path: string): Promise<boolean> {
const normalized = normalizeFilePath(path);
if (!normalized) throw new Error('Known nouns path is empty.');
if (app.vault.getAbstractFileByPath(normalized)) return false;
const parent = parentFolder(normalized);
if (parent) await ensureFolder(app, parent);
await app.vault.create(normalized, DEFAULT_FILE_BODY);
return true;
}
export function isPathKnownNouns(path: string, configuredPath: string): boolean {
const normalizedConfigured = normalizeFilePath(configuredPath);
if (!normalizedConfigured) return false;
return normalizePath(path) === normalizedConfigured;
}
export function buildKnownNounsSystemPromptSection(nouns: KnownNoun[]): string {
if (nouns.length === 0) return '';
const lines = nouns.map((n) => {
if (n.alternates.length === 0) return `- ${n.canonical}`;
return `- ${n.canonical} (also: ${n.alternates.join(', ')})`;
});
return [
'## Known nouns',
"The following proper nouns appear in the user's vocabulary. Preserve them verbatim; if you see a likely-misheard variant, correct it to the canonical form.",
...lines,
].join('\n');
}
function parseKnownNounsBody(body: string): KnownNoun[] {
const out: KnownNoun[] = [];
for (const raw of body.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
if (line.startsWith('#')) continue;
const colonIdx = line.indexOf(':');
if (colonIdx === -1) {
out.push({ canonical: line, alternates: [] });
continue;
}
const canonical = line.slice(0, colonIdx).trim();
if (!canonical) continue;
const altsRaw = line.slice(colonIdx + 1);
const alternates = altsRaw
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
out.push({ canonical, alternates });
}
return out;
}
function splitFrontmatter(content: string): { frontmatter: string | null; body: string } {
if (!content.startsWith('---')) return { frontmatter: null, body: content };
const lines = content.split(/\r?\n/);
if (lines[0]?.trim() !== '---') return { frontmatter: null, body: content };
for (let i = 1; i < lines.length; i++) {
if (lines[i]?.trim() === '---') {
return {
frontmatter: lines.slice(1, i).join('\n'),
body: lines.slice(i + 1).join('\n'),
};
}
}
return { frontmatter: null, body: content };
}
function normalizeFilePath(path: string): string {
const trimmed = path.trim();
if (!trimmed) return '';
const normalized = normalizePath(trimmed);
if (!normalized || normalized === '/' || normalized === '.') return '';
return normalized;
}
function parentFolder(path: string): string {
const idx = path.lastIndexOf('/');
if (idx <= 0) return '';
return path.slice(0, idx);
}
async function ensureFolder(app: App, folder: string): Promise<void> {
const normalized = normalizePath(folder);
if (app.vault.getAbstractFileByPath(normalized)) return;
const parts = normalized.split('/');
let current = '';
for (const part of parts) {
if (!part) continue;
current = current ? `${current}/${part}` : part;
if (!app.vault.getAbstractFileByPath(current)) {
await app.vault.createFolder(current);
}
}
}

View file

@ -1,23 +1,47 @@
import { Notice, Plugin } from 'obsidian';
import { loadSettings, saveSettings } from './settings';
import { Editor, MarkdownFileInfo, MarkdownView, Notice, Platform, Plugin, TFile } from 'obsidian';
import { hydrateSecrets, loadSettings, saveSettings } from './settings';
import { ReWriteSettingTab } from './settings/tab';
import { ReWriteModal } from './ui/modal';
import { PassphraseModal } from './ui/passphrase-modal';
import { QuickRecordController, startQuickRecord } from './ui/quick-record';
import { resolveActiveTextSource, resolveTextFromEditor, runTextPipeline, TextResolution } from './ui/text-source';
import { TemplatePickerModal } from './ui/template-picker';
import { GlobalSettings } from './types';
import { AudioFilePickerModal } from './ui/audio-file-picker';
import { collectAudioFiles, isAudioFile, runAudioFilePipeline } from './ui/audio-source';
import { WhisperStatusBar } from './ui/whisper-status-bar';
import { GlobalSettings, KnownNoun, NoteTemplate, PipelineHost } from './types';
import { WhisperHost } from './whisper-host';
import { bindWhisperHost } from './transcription/whisper-local';
import { resolveActiveProfile } from './platform';
import { isPathInTemplatesFolder, loadTemplatesFromFolder } from './templates-folder';
import { isPathAssistantPrompt, loadAssistantPromptFromFile } from './assistant-prompt';
import { isPathKnownNouns, loadKnownNounsFromFile } from './known-nouns';
import { EncryptionStatus, getEncryptionStatus, unlockSecrets } from './secrets';
export default class ReWritePlugin extends Plugin {
export default class ReWritePlugin extends Plugin implements PipelineHost {
settings!: GlobalSettings;
whisperHost!: WhisperHost;
templates: NoteTemplate[] = [];
assistantPrompt: string | null = null;
knownNouns: KnownNoun[] = [];
encryptionStatus!: EncryptionStatus;
private activeQuickRecord: QuickRecordController | null = null;
private unlockListeners = new Set<() => void>();
async onload(): Promise<void> {
this.settings = await loadSettings(this);
this.whisperHost = new WhisperHost();
this.encryptionStatus = await getEncryptionStatus(this);
this.whisperHost = new WhisperHost(this);
bindWhisperHost(this.whisperHost);
if (Platform.isDesktop) {
void this.whisperHost.probe(this.settings.localWhisper).then((snap) => {
if (snap.status === 'running' && snap.ownership === 'adopted') {
new Notice(`ReWrite: adopted whisper-server from previous session (pid ${snap.pid ?? '?'}).`);
} else if (snap.status === 'external') {
new Notice(`ReWrite: detected external whisper-server on ${snap.baseUrl}. Transcription will use it; ReWrite won't stop it.`);
}
}).catch(() => { /* non-fatal */ });
}
this.addSettingTab(new ReWriteSettingTab(this.app, this));
this.addRibbonIcon('mic', 'ReWrite', () => {
@ -48,7 +72,50 @@ export default class ReWritePlugin extends Plugin {
},
});
this.registerEvent(this.app.workspace.on('editor-menu', (menu, editor) => {
this.addCommand({
id: 'reprocess-audio',
name: 'Reprocess audio file with template',
callback: () => {
this.reprocessAudioFile();
},
});
this.addCommand({
id: 'start-whisper-host',
name: 'Start local whisper.cpp server',
checkCallback: (checking) => {
if (!Platform.isDesktop) return false;
if (resolveActiveProfile(this.settings).profile.transcriptionProvider !== 'whisper-local') return false;
const status = this.whisperHost.status();
if (status !== 'stopped' && status !== 'crashed') return false;
if (!checking) {
void this.startWhisperHost();
}
return true;
},
});
this.addCommand({
id: 'stop-whisper-host',
name: 'Stop local whisper.cpp server',
checkCallback: (checking) => {
if (!Platform.isDesktop) return false;
const status = this.whisperHost.status();
// Don't offer Stop for 'external'; ReWrite never kills processes it didn't start.
if (status !== 'running' && status !== 'starting') return false;
if (!checking) {
void this.stopWhisperHost();
}
return true;
},
});
if (Platform.isDesktop) {
const statusBar = new WhisperStatusBar(this, this.addStatusBarItem());
statusBar.start();
}
this.registerEvent(this.app.workspace.on('editor-menu', (menu, editor, info) => {
menu.addItem((item) => {
item.setTitle('ReWrite with template...');
item.setIcon('mic');
@ -56,7 +123,42 @@ export default class ReWritePlugin extends Plugin {
this.processTextWithTemplate(resolveTextFromEditor(editor));
});
});
const audioUnderCursor = this.findAudioEmbedUnderCursor(editor, info);
if (audioUnderCursor) {
menu.addItem((item) => {
item.setTitle('Reprocess audio with template...');
item.setIcon('mic');
item.onClick(() => {
this.openTemplatePickerForAudio(audioUnderCursor);
});
});
}
}));
this.registerEvent(this.app.workspace.on('file-menu', (menu, file) => {
if (!isAudioFile(file)) return;
menu.addItem((item) => {
item.setTitle('Reprocess audio with template...');
item.setIcon('mic');
item.onClick(() => {
this.openTemplatePickerForAudio(file);
});
});
}));
this.registerEvent(this.app.vault.on('create', (file) => this.onVaultFileChanged(file.path)));
this.registerEvent(this.app.vault.on('modify', (file) => this.onVaultFileChanged(file.path)));
this.registerEvent(this.app.vault.on('delete', (file) => this.onVaultFileChanged(file.path)));
this.registerEvent(this.app.vault.on('rename', (file, oldPath) => {
this.onVaultFileChanged(file.path);
this.onVaultFileChanged(oldPath);
}));
this.app.workspace.onLayoutReady(() => {
void this.refreshTemplates();
void this.refreshAssistantPrompt();
void this.refreshKnownNouns();
});
}
onunload(): void {
@ -69,10 +171,88 @@ export default class ReWritePlugin extends Plugin {
await saveSettings(this, this.settings);
}
async refreshEncryptionStatus(): Promise<EncryptionStatus> {
this.encryptionStatus = await getEncryptionStatus(this);
return this.encryptionStatus;
}
onSecretsUnlocked(cb: () => void): () => void {
this.unlockListeners.add(cb);
return () => this.unlockListeners.delete(cb);
}
notifySecretsUnlocked(): void {
for (const cb of this.unlockListeners) {
try { cb(); } catch { /* swallow */ }
}
}
promptUnlock(onUnlocked?: () => void): void {
new PassphraseModal({
app: this.app,
title: 'Unlock API keys',
description: 'Enter your passphrase to decrypt the API keys stored on this device.',
confirmLabel: 'Unlock',
onSubmit: async (pass) => {
const ok = await unlockSecrets(this, pass);
if (!ok) throw new Error('Incorrect passphrase.');
await hydrateSecrets(this, this.settings);
await this.refreshEncryptionStatus();
this.notifySecretsUnlocked();
onUnlocked?.();
new Notice('ReWrite: API keys unlocked.');
},
}).open();
}
async refreshTemplates(): Promise<void> {
this.templates = await loadTemplatesFromFolder(this.app, this.settings.templatesFolderPath);
}
async refreshAssistantPrompt(): Promise<void> {
this.assistantPrompt = await loadAssistantPromptFromFile(this.app, this.settings.assistantPromptPath);
}
async refreshKnownNouns(): Promise<void> {
this.knownNouns = await loadKnownNounsFromFile(this.app, this.settings.knownNounsPath);
}
private onVaultFileChanged(path: string): void {
if (this.isInTemplatesFolder(path)) {
void this.refreshTemplates();
}
if (isPathAssistantPrompt(path, this.settings.assistantPromptPath)) {
void this.refreshAssistantPrompt();
}
if (isPathKnownNouns(path, this.settings.knownNounsPath)) {
void this.refreshKnownNouns();
}
}
private isInTemplatesFolder(path: string): boolean {
return isPathInTemplatesFolder(path, this.settings.templatesFolderPath);
}
private openModal(): void {
new ReWriteModal(this.app, this).open();
}
private async startWhisperHost(): Promise<void> {
try {
await this.whisperHost.start(this.settings.localWhisper);
} catch (e) {
new Notice(e instanceof Error ? e.message : String(e));
}
}
private async stopWhisperHost(): Promise<void> {
try {
await this.whisperHost.stop();
} catch (e) {
new Notice(e instanceof Error ? e.message : String(e));
}
}
private async toggleQuickRecord(): Promise<void> {
if (this.activeQuickRecord) {
await this.activeQuickRecord.finish();
@ -93,7 +273,7 @@ export default class ReWritePlugin extends Plugin {
new Notice('Source text is empty.');
return;
}
if (this.settings.templates.length === 0) {
if (this.templates.length === 0) {
new Notice('Add a template in settings first.');
return;
}
@ -102,7 +282,7 @@ export default class ReWritePlugin extends Plugin {
: `Whole note: ${source.text.length.toLocaleString()} chars`;
new TemplatePickerModal({
app: this.app,
templates: this.settings.templates,
templates: this.templates,
defaultTemplateId: this.pickDefaultTemplateId(),
previewText,
onPick: (template) => {
@ -111,14 +291,71 @@ export default class ReWritePlugin extends Plugin {
}).open();
}
private reprocessAudioFile(preSelected?: TFile): void {
if (this.templates.length === 0) {
new Notice('Add a template in settings first.');
return;
}
if (preSelected) {
this.openTemplatePickerForAudio(preSelected);
return;
}
const files = collectAudioFiles(this.app);
if (files.length === 0) {
new Notice('No audio files found in this vault.');
return;
}
new AudioFilePickerModal({
app: this.app,
files,
onPick: (file) => {
this.openTemplatePickerForAudio(file);
},
}).open();
}
private openTemplatePickerForAudio(file: TFile): void {
if (this.templates.length === 0) {
new Notice('Add a template in settings first.');
return;
}
new TemplatePickerModal({
app: this.app,
templates: this.templates,
defaultTemplateId: this.pickDefaultTemplateId(),
previewText: `Audio: ${file.path}`,
onPick: (template) => {
void runAudioFilePipeline(this, template, file);
},
}).open();
}
private findAudioEmbedUnderCursor(editor: Editor, info: MarkdownView | MarkdownFileInfo): TFile | null {
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
const re = /!\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g;
const sourcePath = info instanceof MarkdownView ? info.file?.path ?? '' : info.file?.path ?? '';
let match: RegExpExecArray | null;
while ((match = re.exec(line)) !== null) {
const start = match.index;
const end = start + match[0].length;
if (cursor.ch < start || cursor.ch > end) continue;
const linkpath = match[1]?.trim();
if (!linkpath) continue;
const resolved = this.app.metadataCache.getFirstLinkpathDest(linkpath, sourcePath);
if (isAudioFile(resolved)) return resolved;
}
return null;
}
private pickDefaultTemplateId(): string {
const s = this.settings;
if (s.lastUsedTemplateId && s.templates.some((t) => t.id === s.lastUsedTemplateId)) {
if (s.lastUsedTemplateId && this.templates.some((t) => t.id === s.lastUsedTemplateId)) {
return s.lastUsedTemplateId;
}
if (s.defaultTemplateId && s.templates.some((t) => t.id === s.defaultTemplateId)) {
if (s.defaultTemplateId && this.templates.some((t) => t.id === s.defaultTemplateId)) {
return s.defaultTemplateId;
}
return s.templates[0]?.id ?? '';
return this.templates[0]?.id ?? '';
}
}

View file

@ -1,13 +1,17 @@
import { App } from 'obsidian';
import { EnvironmentProfile, GlobalSettings, NoteTemplate } from './types';
import { App, Notice } from 'obsidian';
import { DestinationOverride, EnvironmentProfile, GlobalSettings, NoteTemplate, PipelineHost } from './types';
import { createTranscriptionProvider } from './transcription';
import { createLLMProvider } from './llm';
import { insertOutput, InsertResult } from './insert';
import { persistAudio } from './audio-persist';
import { extractAdHocInstructions } from './wake-name';
import { DEFAULT_ASSISTANT_PROMPT } from './assistant-prompt';
import { buildKnownNounsSystemPromptSection } from './known-nouns';
export type PipelineStage = 'transcribe' | 'cleanup' | 'insert';
export type PipelineStage = 'persist-audio' | 'transcribe' | 'cleanup' | 'insert';
export type PipelineSource =
| { kind: 'audio'; audio: Blob }
| { kind: 'audio'; audio: Blob; sourcePath?: string }
| { kind: 'paste'; text: string }
| { kind: 'webspeech'; transcript: string }
| { kind: 'text'; text: string };
@ -15,9 +19,11 @@ export type PipelineSource =
export interface PipelineParams {
app: App;
settings: GlobalSettings;
host: PipelineHost;
profile: EnvironmentProfile;
template: NoteTemplate;
source: PipelineSource;
destinationOverride?: DestinationOverride;
onStage?: (stage: PipelineStage) => void;
signal?: AbortSignal;
}
@ -29,6 +35,21 @@ export interface PipelineResult {
}
export async function runPipeline(params: PipelineParams): Promise<PipelineResult> {
let audioPath: string | undefined;
if (params.source.kind === 'audio') {
if (params.source.sourcePath) {
audioPath = params.source.sourcePath;
} else {
params.onStage?.('persist-audio');
try {
audioPath = await persistAudio(params.app, params.source.audio, params.settings);
} catch (e) {
console.error('ReWrite: persist audio failed', e);
new Notice('Could not save audio file; continuing with transcription.');
}
}
}
const transcript = (await collectTranscript(params)).trim();
if (!transcript) {
throw new Error('Transcript is empty; nothing to clean up.');
@ -36,15 +57,26 @@ export async function runPipeline(params: PipelineParams): Promise<PipelineResul
params.onStage?.('cleanup');
const cleaned = await cleanupTranscript(params, transcript);
const finalContent = audioPath ? `![[${audioPath}]]\n\n${cleaned}` : cleaned;
params.onStage?.('insert');
const insert = await insertOutput({
app: params.app,
template: params.template,
content: cleaned,
template: applyDestinationOverride(params.template, params.destinationOverride),
content: finalContent,
});
return { transcript, cleaned, insert };
return { transcript, cleaned: finalContent, insert };
}
function applyDestinationOverride(template: NoteTemplate, override: DestinationOverride | undefined): NoteTemplate {
if (!override) return template;
return {
...template,
insertMode: override.insertMode ?? template.insertMode,
newFileFolder: override.newFileFolder ?? template.newFileFolder,
newFileNameTemplate: override.newFileNameTemplate ?? template.newFileNameTemplate,
};
}
async function collectTranscript(params: PipelineParams): Promise<string> {
@ -64,13 +96,31 @@ async function collectTranscript(params: PipelineParams): Promise<string> {
}
async function cleanupTranscript(params: PipelineParams, transcript: string): Promise<string> {
let systemPrompt = params.template.prompt;
let workingTranscript = transcript;
if (params.settings.adHocInstructionsEnabled && params.settings.assistantName.trim().length > 0) {
const { transcript: stripped, instructions } = extractAdHocInstructions(transcript, params.settings.assistantName);
if (instructions.length > 0) {
workingTranscript = stripped;
const list = instructions.map((i, n) => `${n + 1}. ${i}`).join('\n');
const assistantPrompt = params.host.assistantPrompt ?? DEFAULT_ASSISTANT_PROMPT;
systemPrompt = `${systemPrompt}\n\n## Ad-hoc instructions\n${assistantPrompt}\n${list}`;
new Notice(`Heard ${instructions.length} ad-hoc instruction${instructions.length === 1 ? '' : 's'}.`);
}
}
const knownNounsBlock = buildKnownNounsSystemPromptSection(params.host.knownNouns);
if (knownNounsBlock) {
systemPrompt = `${systemPrompt}\n\n${knownNounsBlock}`;
}
const llm = createLLMProvider(params.profile.llmProvider);
try {
return await llm.complete(params.template.prompt, transcript, params.profile.llmConfig, params.signal);
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(transcript);
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')) {

View file

@ -1,17 +1,45 @@
import { normalizePath, Platform, Plugin } from 'obsidian';
const SECRETS_FILE = 'secrets.json.nosync';
const ENCRYPTED_SUFFIX = '_encrypted';
const SECRETS_VERSION = 2;
const VERIFIER_PLAINTEXT = 'rewrite-passphrase-verifier-v1';
const PBKDF2_ITERATIONS = 600_000;
const PBKDF2_SALT_BYTES = 16;
const AES_IV_BYTES = 12;
const VALUE_SEP = '.';
type SecretsFile = Record<string, string | boolean>;
export type EncryptionMode = 'safeStorage' | 'plaintext' | 'passphrase';
export interface EncryptionStatus {
mode: EncryptionMode;
locked: boolean;
safeStorageAvailable: boolean;
safeStorageBackend: string | null;
}
interface PassphraseKdf {
iterations: number;
salt: string; // base64
}
interface SecretsEnvelope {
version: number;
mode: EncryptionMode;
kdf?: PassphraseKdf;
verifier?: string; // "<iv-b64>.<ct-b64>"
keys: Record<string, string>;
}
interface SafeStorageAPI {
isEncryptionAvailable(): boolean;
encryptString(plain: string): { toString(encoding: string): string };
decryptString(buf: unknown): string;
getSelectedStorageBackend?(): string;
}
let safeStorageCache: SafeStorageAPI | null | undefined;
let cachedEnvelope: SecretsEnvelope | null = null;
let unlockedKey: CryptoKey | null = null;
function getSafeStorage(): SafeStorageAPI | null {
if (safeStorageCache !== undefined) return safeStorageCache;
@ -40,90 +68,275 @@ function getSafeStorage(): SafeStorageAPI | null {
return null;
}
function getSafeStorageBackend(): string | null {
const ss = getSafeStorage();
if (!ss || typeof ss.getSelectedStorageBackend !== 'function') return null;
try {
return ss.getSelectedStorageBackend();
} catch {
return null;
}
}
function secretsPath(plugin: Plugin): string {
const dir = plugin.manifest.dir;
if (!dir) throw new Error('Plugin manifest.dir is missing');
return normalizePath(`${dir}/${SECRETS_FILE}`);
}
async function readSecretsFile(plugin: Plugin): Promise<SecretsFile> {
function defaultEnvelope(): SecretsEnvelope {
return {
version: SECRETS_VERSION,
mode: getSafeStorage() ? 'safeStorage' : 'plaintext',
keys: {},
};
}
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function parseEnvelope(raw: string): SecretsEnvelope {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return defaultEnvelope();
}
if (!isObject(parsed)) return defaultEnvelope();
const version = typeof parsed.version === 'number' ? parsed.version : 1;
if (version !== SECRETS_VERSION) {
// Pre-release: no migrations. Treat unknown shapes as a fresh start.
// Existing v1 dev installs will need to re-enter their API keys.
return defaultEnvelope();
}
const mode = parsed.mode;
if (mode !== 'safeStorage' && mode !== 'plaintext' && mode !== 'passphrase') {
return defaultEnvelope();
}
const keys = isObject(parsed.keys) ? parsed.keys as Record<string, string> : {};
const envelope: SecretsEnvelope = { version, mode, keys };
if (mode === 'passphrase') {
const kdf = parsed.kdf;
if (isObject(kdf) && typeof kdf.iterations === 'number' && typeof kdf.salt === 'string') {
envelope.kdf = { iterations: kdf.iterations, salt: kdf.salt };
}
if (typeof parsed.verifier === 'string') {
envelope.verifier = parsed.verifier;
}
if (!envelope.kdf || !envelope.verifier) {
// Malformed passphrase envelope; treat as fresh start.
return defaultEnvelope();
}
}
return envelope;
}
async function readEnvelopeFromDisk(plugin: Plugin): Promise<SecretsEnvelope> {
const path = secretsPath(plugin);
const exists = await plugin.app.vault.adapter.exists(path);
if (!exists) return {};
if (!exists) return defaultEnvelope();
try {
const raw = await plugin.app.vault.adapter.read(path);
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object') return parsed as SecretsFile;
return {};
return parseEnvelope(raw);
} catch {
return {};
return defaultEnvelope();
}
}
async function writeSecretsFile(plugin: Plugin, secrets: SecretsFile): Promise<void> {
const path = secretsPath(plugin);
await plugin.app.vault.adapter.write(path, JSON.stringify(secrets));
async function ensureEnvelope(plugin: Plugin): Promise<SecretsEnvelope> {
if (cachedEnvelope) return cachedEnvelope;
cachedEnvelope = await readEnvelopeFromDisk(plugin);
return cachedEnvelope;
}
function base64ToNodeBuffer(b64: string): unknown {
const Buf = (globalThis as unknown as { Buffer?: { from(s: string, enc: string): unknown } }).Buffer;
if (Buf && typeof Buf.from === 'function') return Buf.from(b64, 'base64');
// safeStorage exists only when Electron is present, which always provides Buffer.
// This fallback is defensive: decode to Uint8Array so the call doesn't crash if the host is unusual.
async function writeEnvelope(plugin: Plugin, envelope: SecretsEnvelope): Promise<void> {
const path = secretsPath(plugin);
await plugin.app.vault.adapter.write(path, JSON.stringify(envelope));
cachedEnvelope = envelope;
}
// ---------- base64 / buffer helpers ----------
function bytesToBase64(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] ?? 0);
return btoa(s);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
function applyKeyToSecrets(secrets: SecretsFile, id: string, key: string): void {
if (key === '') {
delete secrets[id];
delete secrets[id + ENCRYPTED_SUFFIX];
return;
function base64ToNodeBuffer(b64: string): unknown {
const Buf = (globalThis as unknown as { Buffer?: { from(s: string, enc: string): unknown } }).Buffer;
if (Buf && typeof Buf.from === 'function') return Buf.from(b64, 'base64');
return base64ToBytes(b64);
}
function randomBytes(n: number): Uint8Array {
const out = new Uint8Array(n);
crypto.getRandomValues(out);
return out;
}
// ---------- WebCrypto passphrase helpers ----------
async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
const passBytes = new TextEncoder().encode(passphrase);
const baseKey = await crypto.subtle.importKey(
'raw',
passBytes,
{ name: 'PBKDF2' },
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
async function aesGcmEncrypt(key: CryptoKey, plaintext: string): Promise<string> {
const iv = randomBytes(AES_IV_BYTES);
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv as BufferSource },
key,
new TextEncoder().encode(plaintext),
);
return `${bytesToBase64(iv)}${VALUE_SEP}${bytesToBase64(new Uint8Array(ct))}`;
}
async function aesGcmDecrypt(key: CryptoKey, payload: string): Promise<string> {
const sepIdx = payload.indexOf(VALUE_SEP);
if (sepIdx <= 0) throw new Error('Malformed encrypted value');
const iv = base64ToBytes(payload.slice(0, sepIdx));
const ct = base64ToBytes(payload.slice(sepIdx + 1));
const pt = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv as BufferSource },
key,
ct as BufferSource,
);
return new TextDecoder().decode(pt);
}
// ---------- per-mode encrypt/decrypt of a single value ----------
async function encryptValue(envelope: SecretsEnvelope, plaintext: string): Promise<string> {
if (envelope.mode === 'plaintext') return plaintext;
if (envelope.mode === 'safeStorage') {
const ss = getSafeStorage();
if (!ss) throw new Error('safeStorage is unavailable on this device.');
return ss.encryptString(plaintext).toString('base64');
}
const ss = getSafeStorage();
if (ss) {
const encrypted = ss.encryptString(key);
secrets[id] = encrypted.toString('base64');
secrets[id + ENCRYPTED_SUFFIX] = true;
} else {
secrets[id] = key;
secrets[id + ENCRYPTED_SUFFIX] = false;
if (envelope.mode === 'passphrase') {
if (!unlockedKey) throw new Error('Secrets are locked. Unlock with your passphrase first.');
return aesGcmEncrypt(unlockedKey, plaintext);
}
throw new Error(`Unknown encryption mode: ${envelope.mode as string}`);
}
async function decryptValue(envelope: SecretsEnvelope, stored: string): Promise<string> {
if (stored === '') return '';
if (envelope.mode === 'plaintext') return stored;
if (envelope.mode === 'safeStorage') {
const ss = getSafeStorage();
if (!ss) return '';
try {
return ss.decryptString(base64ToNodeBuffer(stored));
} catch {
return '';
}
}
if (envelope.mode === 'passphrase') {
if (!unlockedKey) return '';
try {
return await aesGcmDecrypt(unlockedKey, stored);
} catch {
return '';
}
}
return '';
}
// ---------- public API ----------
export async function getEncryptionStatus(plugin: Plugin): Promise<EncryptionStatus> {
const envelope = await ensureEnvelope(plugin);
return {
mode: envelope.mode,
locked: envelope.mode === 'passphrase' && unlockedKey === null,
safeStorageAvailable: getSafeStorage() !== null,
safeStorageBackend: getSafeStorageBackend(),
};
}
export function isEncryptionAvailable(): boolean {
return getSafeStorage() !== null;
}
export function lockSecrets(): void {
unlockedKey = null;
}
export async function unlockSecrets(plugin: Plugin, passphrase: string): Promise<boolean> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode !== 'passphrase') return true;
if (!envelope.kdf || !envelope.verifier) return false;
const salt = base64ToBytes(envelope.kdf.salt);
const candidate = await deriveKeyFromPassphrase(passphrase, salt, envelope.kdf.iterations);
try {
const decoded = await aesGcmDecrypt(candidate, envelope.verifier);
if (decoded !== VERIFIER_PLAINTEXT) return false;
} catch {
return false;
}
unlockedKey = candidate;
return true;
}
export async function saveKey(plugin: Plugin, id: string, key: string): Promise<void> {
const secrets = await readSecretsFile(plugin);
applyKeyToSecrets(secrets, id, key);
await writeSecretsFile(plugin, secrets);
const envelope = await ensureEnvelope(plugin);
if (envelope.mode === 'passphrase' && unlockedKey === null) {
throw new Error('Secrets are locked. Unlock with your passphrase to save keys.');
}
if (key === '') {
delete envelope.keys[id];
} else {
envelope.keys[id] = await encryptValue(envelope, key);
}
await writeEnvelope(plugin, envelope);
}
export async function saveManyKeys(
plugin: Plugin,
updates: Record<string, string>,
): Promise<void> {
const secrets = await readSecretsFile(plugin);
export async function saveManyKeys(plugin: Plugin, updates: Record<string, string>): Promise<void> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode === 'passphrase' && unlockedKey === null) {
// Caller (settings save) may run while locked. Don't blow up; just skip writing
// secrets so we don't clobber the on-disk encrypted values with empties.
return;
}
for (const id of Object.keys(updates)) {
const value = updates[id] ?? '';
applyKeyToSecrets(secrets, id, value);
if (value === '') {
delete envelope.keys[id];
} else {
envelope.keys[id] = await encryptValue(envelope, value);
}
}
await writeSecretsFile(plugin, secrets);
await writeEnvelope(plugin, envelope);
}
export async function loadKey(plugin: Plugin, id: string): Promise<string> {
const secrets = await readSecretsFile(plugin);
const value = secrets[id];
if (typeof value !== 'string' || value === '') return '';
const encrypted = secrets[id + ENCRYPTED_SUFFIX] === true;
if (!encrypted) return value;
const ss = getSafeStorage();
if (!ss) return '';
try {
return ss.decryptString(base64ToNodeBuffer(value));
} catch {
return '';
}
const envelope = await ensureEnvelope(plugin);
const stored = envelope.keys[id];
if (typeof stored !== 'string' || stored === '') return '';
return decryptValue(envelope, stored);
}
export async function deleteKey(plugin: Plugin, id: string): Promise<void> {
@ -131,16 +344,74 @@ export async function deleteKey(plugin: Plugin, id: string): Promise<void> {
}
export async function loadAllKeys(plugin: Plugin): Promise<Record<string, string>> {
const secrets = await readSecretsFile(plugin);
const envelope = await ensureEnvelope(plugin);
const out: Record<string, string> = {};
for (const id of Object.keys(secrets)) {
if (id.endsWith(ENCRYPTED_SUFFIX)) continue;
const value = await loadKey(plugin, id);
if (envelope.mode === 'passphrase' && unlockedKey === null) return out;
for (const id of Object.keys(envelope.keys)) {
const value = await decryptValue(envelope, envelope.keys[id] ?? '');
if (value) out[id] = value;
}
return out;
}
export function isEncryptionAvailable(): boolean {
return getSafeStorage() !== null;
// ---------- mode transitions ----------
export async function changeEncryptionMode(
plugin: Plugin,
newMode: EncryptionMode,
newPassphrase?: string,
): Promise<void> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode === newMode && newMode !== 'passphrase') return;
if (envelope.mode === 'passphrase' && unlockedKey === null) {
throw new Error('Unlock secrets with the current passphrase before changing modes.');
}
if (newMode === 'safeStorage' && !getSafeStorage()) {
throw new Error('OS keychain encryption is not available on this device.');
}
if (newMode === 'passphrase' && (!newPassphrase || newPassphrase.length === 0)) {
throw new Error('A passphrase is required to switch to passphrase mode.');
}
const plain: Record<string, string> = {};
for (const id of Object.keys(envelope.keys)) {
const v = await decryptValue(envelope, envelope.keys[id] ?? '');
if (v) plain[id] = v;
}
const next: SecretsEnvelope = {
version: SECRETS_VERSION,
mode: newMode,
keys: {},
};
if (newMode === 'passphrase') {
const salt = randomBytes(PBKDF2_SALT_BYTES);
const newKey = await deriveKeyFromPassphrase(newPassphrase ?? '', salt, PBKDF2_ITERATIONS);
next.kdf = { iterations: PBKDF2_ITERATIONS, salt: bytesToBase64(salt) };
next.verifier = await aesGcmEncrypt(newKey, VERIFIER_PLAINTEXT);
unlockedKey = newKey;
} else {
unlockedKey = null;
}
cachedEnvelope = next;
for (const id of Object.keys(plain)) {
next.keys[id] = await encryptValue(next, plain[id] ?? '');
}
await writeEnvelope(plugin, next);
}
export async function changePassphrase(plugin: Plugin, newPassphrase: string): Promise<void> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode !== 'passphrase') {
throw new Error('Not in passphrase mode.');
}
if (unlockedKey === null) {
throw new Error('Unlock with the current passphrase first.');
}
if (newPassphrase.length === 0) {
throw new Error('Passphrase cannot be empty.');
}
await changeEncryptionMode(plugin, 'passphrase', newPassphrase);
}

View file

@ -8,7 +8,6 @@ import {
TranscriptionConfig,
} from '../types';
import { loadAllKeys, saveManyKeys } from '../secrets';
import { freshDefaultTemplates } from './default-templates';
const EMPTY_TRANSCRIPTION_CONFIG: TranscriptionConfig = {
apiKey: '',
@ -54,7 +53,12 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
defaultTemplateId: '',
lastUsedTemplateId: '',
recordingFormat: 'webm',
templates: [],
templatesFolderPath: 'ReWrite/Templates',
attachmentsFolderPath: '',
adHocInstructionsEnabled: false,
assistantName: 'Scrivener',
assistantPromptPath: 'ReWrite/AssistantPrompt.md',
knownNounsPath: 'ReWrite/KnownNouns.md',
modelCache: { transcription: {}, llm: {} },
localWhisper: DEFAULT_LOCAL_WHISPER,
};
@ -72,12 +76,6 @@ function profileLLMKeyId(kind: ActiveProfileKind): string {
export async function loadSettings(plugin: Plugin): Promise<GlobalSettings> {
const stored = (await plugin.loadData()) as Partial<GlobalSettings> | null;
const merged = mergeSettings(DEFAULT_SETTINGS, stored ?? {});
if (merged.templates.length === 0) {
merged.templates = freshDefaultTemplates();
if (!merged.defaultTemplateId) {
merged.defaultTemplateId = merged.templates[0]?.id ?? '';
}
}
await hydrateSecrets(plugin, merged);
return merged;
}
@ -88,14 +86,14 @@ export async function saveSettings(plugin: Plugin, settings: GlobalSettings): Pr
await plugin.saveData(stripped);
}
async function hydrateSecrets(plugin: Plugin, settings: GlobalSettings): Promise<void> {
export async function hydrateSecrets(plugin: Plugin, settings: GlobalSettings): Promise<void> {
const all = await loadAllKeys(plugin);
for (const kind of PROFILE_KINDS) {
const profile = profileFor(settings, kind);
const trKey = all[profileTranscriptionKeyId(kind)];
if (trKey) profile.transcriptionConfig.apiKey = trKey;
profile.transcriptionConfig.apiKey = trKey ?? '';
const llmKey = all[profileLLMKeyId(kind)];
if (llmKey) profile.llmConfig.apiKey = llmKey;
profile.llmConfig.apiKey = llmKey ?? '';
}
}
@ -138,7 +136,6 @@ function mergeSettings(
...partial,
desktopProfile: mergeProfile(base.desktopProfile, partial.desktopProfile),
mobileProfile: mergeProfile(base.mobileProfile, partial.mobileProfile),
templates: partial.templates ?? base.templates,
modelCache: {
transcription: { ...base.modelCache.transcription, ...(partial.modelCache?.transcription ?? {}) },
llm: { ...base.modelCache.llm, ...(partial.modelCache?.llm ?? {}) },

View file

@ -1,13 +1,11 @@
import { App, Modal, Notice, Platform, PluginSettingTab, Setting } from 'obsidian';
import { App, Notice, Platform, PluginSettingTab, Setting } from 'obsidian';
import type ReWritePlugin from '../main';
import {
ActiveProfileKind,
ActiveProfileOverride,
EnvironmentProfile,
InsertMode,
LLMConfig,
LLMProviderID,
NoteTemplate,
RecordingFormatPreference,
TranscriptionConfig,
TranscriptionProviderID,
@ -15,7 +13,13 @@ import {
import { detectActiveProfileKind } from '../platform';
import { createTranscriptionProvider } from '../transcription';
import { createLLMProvider } from '../llm';
import { WhisperStatus } from '../whisper-host';
import { formatWhisperStatus } from '../whisper-host';
import { populateDefaultTemplates } from '../templates-folder';
import { populateDefaultAssistantPrompt } from '../assistant-prompt';
import { populateDefaultKnownNouns } from '../known-nouns';
import { changeEncryptionMode, EncryptionMode, lockSecrets } from '../secrets';
import { hydrateSecrets } from '.';
import { PassphraseModal } from '../ui/passphrase-modal';
const TRANSCRIPTION_OPTIONS: Array<{ id: TranscriptionProviderID; label: string; desktopOnly?: boolean }> = [
{ id: 'openai', label: 'OpenAI Whisper' },
@ -36,21 +40,12 @@ const LLM_OPTIONS: Array<{ id: LLMProviderID; label: string }> = [
{ id: 'mistral', label: 'Mistral' },
];
const INSERT_MODE_OPTIONS: Array<{ id: InsertMode; label: string }> = [
{ id: 'cursor', label: 'Insert at cursor' },
{ id: 'newFile', label: 'Create new file' },
{ id: 'append', label: 'Append to active note' },
];
const RECORDING_FORMAT_OPTIONS: Array<{ id: RecordingFormatPreference; label: string }> = [
{ id: 'webm', label: 'webm (best on Chromium/Electron)' },
{ id: 'mp4', label: 'mp4 (best on mobile/Safari)' },
];
export class ReWriteSettingTab extends PluginSettingTab {
private editingTemplateId: string | null = null;
private dragSourceIndex: number | null = null;
constructor(app: App, private readonly plugin: ReWritePlugin) {
super(app, plugin);
}
@ -60,18 +55,164 @@ export class ReWriteSettingTab extends PluginSettingTab {
containerEl.empty();
containerEl.addClass('rewrite-settings');
this.renderEncryption(containerEl);
this.renderActiveProfile(containerEl);
this.renderProfile(containerEl, 'desktop');
this.renderProfile(containerEl, 'mobile');
this.renderLocalWhisperServer(containerEl);
this.renderTemplates(containerEl);
this.renderRecording(containerEl);
this.renderAdHocInstructions(containerEl);
this.renderKnownNouns(containerEl);
}
private async commit(): Promise<void> {
await this.plugin.saveSettings();
}
private apiKeyPlaceholder(): string {
if (this.plugin.encryptionStatus.locked) return 'Locked. Unlock to view or edit.';
return 'Saved securely on this device';
}
private applyApiKeyFieldState(input: HTMLInputElement): void {
if (this.plugin.encryptionStatus.locked) {
input.disabled = true;
}
}
private renderEncryption(parent: HTMLElement): void {
const status = this.plugin.encryptionStatus;
const banner = parent.createDiv({ cls: 'rewrite-encryption-banner' });
if (status.locked) {
banner.addClass('is-locked');
banner.createEl('strong', { text: 'API keys are locked.' });
banner.createEl('span', {
text: ' Enter your passphrase to decrypt them. Until then, recording and processing are disabled.',
});
const unlockBtn = banner.createEl('button', { text: 'Unlock', cls: 'mod-cta' });
unlockBtn.addEventListener('click', () => {
this.plugin.promptUnlock(() => this.display());
});
} else if (status.mode === 'plaintext') {
banner.addClass('is-warning');
banner.createEl('strong', { text: 'Plaintext storage.' });
banner.createEl('span', {
text: ' Your API keys are stored unencrypted on this device. Any process running as your user account can read them. Switch to a passphrase below to encrypt them.',
});
} else if (status.mode === 'safeStorage') {
banner.addClass('is-ok');
const backend = status.safeStorageBackend ? ` (${status.safeStorageBackend})` : '';
banner.createEl('span', { text: `Encrypted via OS keychain${backend}.` });
} else if (status.mode === 'passphrase') {
banner.addClass('is-ok');
banner.createEl('span', { text: 'Encrypted with passphrase. Unlocked for this session.' });
}
new Setting(parent).setName('API key encryption').setHeading();
parent.createEl('p', {
text: 'Choose how your API keys are protected on disk. Keys are stored in secrets.json.nosync in the plugin folder; this setting controls how they are encrypted.',
cls: 'rewrite-section-desc',
});
new Setting(parent)
.setName('Encryption mode')
.setDesc(this.encryptionModeDescription(status))
.addDropdown((dd) => {
if (status.safeStorageAvailable) dd.addOption('safeStorage', 'OS keychain (recommended)');
dd.addOption('passphrase', 'Passphrase (cross-platform)');
dd.addOption('plaintext', 'Plaintext (not recommended)');
dd.setValue(status.mode);
dd.onChange((v) => {
const next = v as EncryptionMode;
if (next === status.mode) return;
void this.handleModeChange(next);
});
});
if (status.mode === 'passphrase' && !status.locked) {
new Setting(parent)
.setName('Change passphrase')
.setDesc('Re-encrypts all stored keys with a new passphrase.')
.addButton((b) => {
b.setButtonText('Change').onClick(() => {
new PassphraseModal({
app: this.app,
title: 'Set a new passphrase',
description: 'Replaces the current passphrase. Stored API keys will be re-encrypted.',
confirmLabel: 'Save',
requireConfirm: true,
onSubmit: async (pass) => {
await changeEncryptionMode(this.plugin, 'passphrase', pass);
await this.plugin.refreshEncryptionStatus();
new Notice('ReWrite: passphrase updated.');
this.display();
},
}).open();
});
});
new Setting(parent)
.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 () => {
lockSecrets();
await hydrateSecrets(this.plugin, this.plugin.settings);
await this.plugin.refreshEncryptionStatus();
this.display();
});
});
}
}
private encryptionModeDescription(status: { mode: EncryptionMode; safeStorageAvailable: boolean; safeStorageBackend: string | null }): string {
const lines: string[] = [];
if (status.safeStorageAvailable) {
lines.push(`OS keychain: encrypted by your operating system (${status.safeStorageBackend ?? 'detected'}). Strongest, but only works on this machine.`);
} else {
lines.push('OS keychain: not available on this device (no working keyring detected).');
}
lines.push('Passphrase: AES-GCM with PBKDF2-derived key. You enter a passphrase once per session. Works on every platform, including mobile.');
lines.push('Plaintext: no encryption. Any process running as your user can read your keys.');
return lines.join(' ');
}
private async handleModeChange(next: EncryptionMode): Promise<void> {
try {
if (next === 'passphrase') {
new PassphraseModal({
app: this.app,
title: 'Set a passphrase',
description: 'A passphrase will be used to encrypt your API keys. Store it in your password manager; there is no recovery if you forget it.',
confirmLabel: 'Save',
requireConfirm: true,
onSubmit: async (pass) => {
await changeEncryptionMode(this.plugin, 'passphrase', pass);
await this.plugin.refreshEncryptionStatus();
new Notice('ReWrite: passphrase encryption enabled.');
this.display();
},
}).open();
// Modal cancel or completion handles re-render; re-render now so the dropdown
// doesn't appear "applied" until the user confirms.
this.display();
return;
}
await changeEncryptionMode(this.plugin, next);
await this.plugin.refreshEncryptionStatus();
const label = next === 'safeStorage' ? 'OS keychain' : 'plaintext';
new Notice(`ReWrite: switched to ${label} storage.`);
this.display();
} catch (e) {
new Notice(`ReWrite: ${e instanceof Error ? e.message : String(e)}`);
await this.plugin.refreshEncryptionStatus();
this.display();
}
}
private renderActiveProfile(parent: HTMLElement): void {
new Setting(parent).setName('Active profile').setHeading();
const s = this.plugin.settings;
@ -151,9 +292,11 @@ export class ReWriteSettingTab extends PluginSettingTab {
.setName('Transcription API key')
.addText((t) => {
t.inputEl.type = 'password';
t.setPlaceholder('Saved securely on this device');
this.applyApiKeyFieldState(t.inputEl);
t.setPlaceholder(this.apiKeyPlaceholder());
t.setValue(profile.transcriptionConfig.apiKey);
t.onChange(async (v) => {
if (this.plugin.encryptionStatus.locked) return;
profile.transcriptionConfig.apiKey = v;
await this.commit();
});
@ -192,9 +335,11 @@ export class ReWriteSettingTab extends PluginSettingTab {
.setName('LLM API key')
.addText((t) => {
t.inputEl.type = 'password';
t.setPlaceholder('Saved securely on this device');
this.applyApiKeyFieldState(t.inputEl);
t.setPlaceholder(this.apiKeyPlaceholder());
t.setValue(profile.llmConfig.apiKey);
t.onChange(async (v) => {
if (this.plugin.encryptionStatus.locked) return;
profile.llmConfig.apiKey = v;
await this.commit();
});
@ -406,9 +551,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
});
const advanced = parent.createEl('details', { cls: 'rewrite-advanced' });
advanced.createEl('summary', { text: 'Advanced' });
new Setting(advanced)
new Setting(parent)
.setName('Extra args')
.setDesc('Space-separated CLI args appended after -m, --port.')
.addText((t) => {
@ -420,16 +563,21 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
const host = this.plugin.whisperHost;
const status = host.status();
const baseUrl = host.baseUrl();
const snap = host.snapshot();
const statusSetting = new Setting(parent).setName('Status').setDesc(formatWhisperStatus(status, baseUrl));
const statusSetting = new Setting(parent).setName('Status').setDesc(formatWhisperStatus(snap));
statusSetting.addButton((b) => {
if (status === 'running' || status === 'starting') {
if (snap.status === 'running' || snap.status === 'starting') {
b.setButtonText('Stop').onClick(async () => {
await host.stop();
try {
await host.stop();
} catch (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 () => {
try {
@ -441,6 +589,16 @@ export class ReWriteSettingTab extends PluginSettingTab {
});
}
});
statusSetting.addExtraButton((b) => {
b.setIcon('refresh-cw').setTooltip('Probe the configured port for an existing server').onClick(async () => {
try {
await host.probe(cfg);
} catch (e) {
new Notice(e instanceof Error ? e.message : String(e));
}
this.display();
});
});
const log = host.getLog();
if (log) {
@ -454,194 +612,62 @@ export class ReWriteSettingTab extends PluginSettingTab {
private renderTemplates(parent: HTMLElement): void {
new Setting(parent).setName('Templates').setHeading();
parent.createEl('p', {
text: 'Drag the handle to reorder. Templates appear in the modal dropdown in this order.',
text: 'Templates live as Markdown files in a vault folder. The file body is the LLM prompt; frontmatter holds the metadata. Files are sorted by filename, so prefix with a number to control order.',
cls: 'rewrite-section-desc',
});
const templates = this.plugin.settings.templates;
const list = parent.createDiv({ cls: 'rewrite-templates-list' });
const s = this.plugin.settings;
if (templates.length === 0) {
list.createEl('p', {
text: 'No templates configured. Add one to get started.',
cls: 'rewrite-templates-empty',
});
} else {
for (let i = 0; i < templates.length; i++) {
const template = templates[i];
if (!template) continue;
this.renderTemplateItem(list, template, i);
}
}
const actions = parent.createDiv({ cls: 'rewrite-templates-actions' });
const addBtn = actions.createEl('button', { text: 'Add template', cls: 'mod-cta' });
addBtn.addEventListener('click', () => {
void this.addTemplate();
});
}
private renderTemplateItem(parent: HTMLElement, template: NoteTemplate, index: number): void {
const item = parent.createDiv({ cls: 'rewrite-template-item' });
item.draggable = true;
item.dataset.index = String(index);
item.addEventListener('dragstart', (ev) => {
this.dragSourceIndex = index;
item.addClass('is-dragging');
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData('text/plain', String(index));
}
});
item.addEventListener('dragend', () => {
item.removeClass('is-dragging');
this.dragSourceIndex = null;
});
item.addEventListener('dragover', (ev) => {
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
item.addClass('is-drop-target');
});
item.addEventListener('dragleave', () => {
item.removeClass('is-drop-target');
});
item.addEventListener('drop', (ev) => {
ev.preventDefault();
item.removeClass('is-drop-target');
const from = this.dragSourceIndex;
this.dragSourceIndex = null;
void this.reorderTemplate(from, index);
});
const header = item.createDiv({ cls: 'rewrite-template-header' });
const handle = header.createSpan({ cls: 'rewrite-drag-handle', text: '⋮⋮' });
handle.setAttr('aria-label', 'Drag to reorder');
header.createSpan({
cls: 'rewrite-template-name',
text: template.name || '(unnamed)',
});
const isEditing = this.editingTemplateId === template.id;
const actions = header.createDiv({ cls: 'rewrite-template-actions' });
const editBtn = actions.createEl('button', { text: isEditing ? 'Close' : 'Edit' });
editBtn.addEventListener('click', () => {
this.editingTemplateId = isEditing ? null : template.id;
this.display();
});
const deleteBtn = actions.createEl('button', { text: 'Delete' });
deleteBtn.addEventListener('click', () => {
this.deleteTemplate(template);
});
if (isEditing) {
this.renderTemplateEditor(item, template);
}
}
private renderTemplateEditor(parent: HTMLElement, template: NoteTemplate): void {
const editor = parent.createDiv({ cls: 'rewrite-template-editor' });
new Setting(editor)
.setName('Name')
new Setting(parent)
.setName('Templates folder')
.setDesc('Vault-relative path. Created by the populate button if it does not exist.')
.addText((t) => {
t.setValue(template.name);
t.setValue(s.templatesFolderPath);
t.onChange(async (v) => {
template.name = v;
s.templatesFolderPath = v;
await this.commit();
await this.plugin.refreshTemplates();
});
});
new Setting(editor)
.setName('Prompt')
.setDesc('System prompt sent to the LLM. The raw transcript is passed as the user message.')
.addTextArea((t) => {
t.setValue(template.prompt);
t.onChange(async (v) => {
template.prompt = v;
await this.commit();
});
t.inputEl.rows = 6;
t.inputEl.addClass('rewrite-prompt-textarea');
});
new Setting(editor)
.setName('Insert mode')
.addDropdown((dd) => {
for (const opt of INSERT_MODE_OPTIONS) dd.addOption(opt.id, opt.label);
dd.setValue(template.insertMode);
dd.onChange(async (v) => {
template.insertMode = v as InsertMode;
await this.commit();
this.display();
new Setting(parent)
.setName('Populate with default templates')
.setDesc('Writes the five built-in templates into the folder above. Skips any whose ID already exists, so re-running tops up after a deletion.')
.addButton((b) => {
b.setButtonText('Populate').setCta().onClick(async () => {
try {
const result = await populateDefaultTemplates(this.app, s.templatesFolderPath);
await this.plugin.refreshTemplates();
new Notice(`ReWrite: populated ${result.folder}. Created ${result.created}, skipped ${result.skipped}.`);
this.display();
} catch (e) {
new Notice(`ReWrite: populate failed. ${e instanceof Error ? e.message : String(e)}`);
}
});
});
if (template.insertMode === 'newFile') {
new Setting(editor)
.setName('New file folder')
.setDesc('Vault-relative folder. Leave blank for vault root.')
.addText((t) => {
t.setValue(template.newFileFolder);
t.onChange(async (v) => {
template.newFileFolder = v;
await this.commit();
});
});
const loaded = this.plugin.templates;
const listDesc = loaded.length === 0
? 'No templates loaded. Set a folder path and click Populate, or add your own Markdown files there.'
: `Loaded ${loaded.length} template${loaded.length === 1 ? '' : 's'}: ${loaded.map((t) => t.name).join(', ')}.`;
parent.createEl('p', { text: listDesc, cls: 'rewrite-section-desc' });
new Setting(editor)
.setName('New file name template')
.setDesc('Supports {{date}} (YYYY-MM-DD) and {{time}} (HHmmss).')
.addText((t) => {
t.setValue(template.newFileNameTemplate);
t.setPlaceholder('ReWrite {{date}} {{time}}');
t.onChange(async (v) => {
template.newFileNameTemplate = v;
if (loaded.length > 0) {
new Setting(parent)
.setName('Default template')
.setDesc('Used by quick record and pre-selected in the modal.')
.addDropdown((dd) => {
dd.addOption('', '(first loaded)');
for (const tpl of loaded) dd.addOption(tpl.id, tpl.name);
dd.setValue(loaded.some((t) => t.id === s.defaultTemplateId) ? s.defaultTemplateId : '');
dd.onChange(async (v) => {
s.defaultTemplateId = v;
await this.commit();
});
});
}
}
private async addTemplate(): Promise<void> {
const newTemplate: NoteTemplate = {
id: generateTemplateId(),
name: 'Untitled template',
prompt: 'Clean up the transcript while preserving the original meaning.',
insertMode: 'cursor',
newFileFolder: '',
newFileNameTemplate: 'ReWrite {{date}} {{time}}',
};
this.plugin.settings.templates.push(newTemplate);
this.editingTemplateId = newTemplate.id;
await this.commit();
this.display();
}
private async reorderTemplate(from: number | null, to: number): Promise<void> {
if (from === null || from === to) return;
const templates = this.plugin.settings.templates;
if (from < 0 || from >= templates.length || to < 0 || to >= templates.length) return;
const [moved] = templates.splice(from, 1);
if (!moved) return;
templates.splice(to, 0, moved);
await this.commit();
this.display();
}
private deleteTemplate(template: NoteTemplate): void {
new ConfirmModal(this.app, `Delete template "${template.name}"?`, 'Delete', async () => {
const s = this.plugin.settings;
s.templates = s.templates.filter((t) => t.id !== template.id);
if (s.defaultTemplateId === template.id) s.defaultTemplateId = '';
if (s.lastUsedTemplateId === template.id) s.lastUsedTemplateId = '';
if (this.editingTemplateId === template.id) this.editingTemplateId = null;
await this.commit();
this.display();
new Notice('Template deleted.');
}).open();
}
private renderRecording(parent: HTMLElement): void {
new Setting(parent).setName('Recording').setHeading();
new Setting(parent)
@ -655,50 +681,158 @@ export class ReWriteSettingTab extends PluginSettingTab {
await this.commit();
});
});
}
}
class ConfirmModal extends Modal {
constructor(
app: App,
private readonly message: string,
private readonly confirmLabel: string,
private readonly onConfirm: () => void | Promise<void>,
) {
super(app);
new Setting(parent)
.setName('Attachments folder')
.setDesc('Vault-relative folder for saved recordings. Leave empty to use the vault\'s attachments setting. Each recording is embedded at the top of the cleaned output.')
.addText((t) => {
t.setValue(this.plugin.settings.attachmentsFolderPath);
t.setPlaceholder('Attachments');
t.onChange(async (v) => {
this.plugin.settings.attachmentsFolderPath = v;
await this.commit();
});
});
}
onOpen(): void {
this.contentEl.createEl('p', { text: this.message });
const actions = this.contentEl.createDiv({ cls: 'rewrite-modal-actions' });
const cancel = actions.createEl('button', { text: 'Cancel' });
cancel.addEventListener('click', () => this.close());
const confirm = actions.createEl('button', { text: this.confirmLabel, cls: 'mod-warning' });
confirm.addEventListener('click', () => {
this.close();
void this.onConfirm();
private renderAdHocInstructions(parent: HTMLElement): void {
new Setting(parent).setName('Ad-hoc instructions').setHeading();
parent.createEl('p', {
text: 'Address the assistant by name mid-dictation, then a comma, then a directive. Matches are stripped from the transcript and appended to the cleanup prompt as numbered instructions. Off by default. Pick an uncommon word; common everyday words will misfire.',
cls: 'rewrite-section-desc',
});
new Setting(parent)
.setName('Enabled')
.setDesc('Scan transcripts (all sources) for the assistant name and extract impromptu instructions.')
.addToggle((t) => {
t.setValue(this.plugin.settings.adHocInstructionsEnabled);
t.onChange(async (v) => {
this.plugin.settings.adHocInstructionsEnabled = v;
await this.commit();
this.display();
});
});
if (this.plugin.settings.adHocInstructionsEnabled) {
new Setting(parent)
.setName('Assistant name')
.setDesc('The wake word the assistant listens for. Speech recognition may mangle uncommon names; expect occasional misses.')
.addText((t) => {
t.setValue(this.plugin.settings.assistantName);
t.setPlaceholder('Scrivener');
t.onChange(async (v) => {
this.plugin.settings.assistantName = v;
await this.commit();
});
});
new Setting(parent)
.setName('Assistant prompt file')
.setDesc('Vault-relative path to a Markdown file whose body is inserted above the numbered list of interjections in the system prompt. Edit it like a normal note to tell the LLM how to weight and apply ad-hoc directives.')
.addText((t) => {
t.setValue(this.plugin.settings.assistantPromptPath);
t.setPlaceholder('ReWrite/AssistantPrompt.md');
t.onChange(async (v) => {
this.plugin.settings.assistantPromptPath = v;
await this.commit();
await this.plugin.refreshAssistantPrompt();
});
});
new Setting(parent)
.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 () => {
try {
const created = await populateDefaultAssistantPrompt(this.app, this.plugin.settings.assistantPromptPath);
await this.plugin.refreshAssistantPrompt();
new Notice(created
? `ReWrite: created ${this.plugin.settings.assistantPromptPath}.`
: `ReWrite: ${this.plugin.settings.assistantPromptPath} already exists.`);
this.display();
} catch (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(() => {
const path = this.plugin.settings.assistantPromptPath.trim();
if (!path) {
new Notice('Set an assistant prompt path first.');
return;
}
void this.app.workspace.openLinkText(path, '', true);
});
});
const loaded = this.plugin.assistantPrompt;
parent.createEl('p', {
text: loaded
? `Loaded ${loaded.length.toLocaleString()} characters from ${this.plugin.settings.assistantPromptPath}.`
: 'No assistant prompt loaded. The built-in default is used until you populate or write the file.',
cls: 'rewrite-section-desc',
});
}
}
onClose(): void {
this.contentEl.empty();
}
}
private renderKnownNouns(parent: HTMLElement): void {
new Setting(parent).setName('Known nouns').setHeading();
parent.createEl('p', {
text: 'A vault file listing proper nouns the LLM should preserve verbatim, with optional misheard alternates. The list is appended to every cleanup system prompt, so keep it focused on nouns the LLM actually mangles; an unbounded list inflates token cost on every recording.',
cls: 'rewrite-section-desc',
});
function generateTemplateId(): string {
return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
new Setting(parent)
.setName('Known nouns file')
.setDesc('Vault-relative path. Frontmatter is for human-readable guidance only; the body is one noun per line, with an optional ": alt1, alt2" suffix for misheard variants.')
.addText((t) => {
t.setValue(this.plugin.settings.knownNounsPath);
t.setPlaceholder('ReWrite/KnownNouns.md');
t.onChange(async (v) => {
this.plugin.settings.knownNounsPath = v;
await this.commit();
await this.plugin.refreshKnownNouns();
});
});
function formatWhisperStatus(status: WhisperStatus, baseUrl: string | null): string {
switch (status) {
case 'stopped':
return 'Stopped.';
case 'starting':
return 'Starting...';
case 'running':
return baseUrl ? `Running on ${baseUrl}.` : 'Running.';
case 'crashed':
return 'Crashed. See log for details.';
new Setting(parent)
.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 () => {
try {
const created = await populateDefaultKnownNouns(this.app, this.plugin.settings.knownNounsPath);
await this.plugin.refreshKnownNouns();
new Notice(created
? `ReWrite: created ${this.plugin.settings.knownNounsPath}.`
: `ReWrite: ${this.plugin.settings.knownNounsPath} already exists.`);
this.display();
} catch (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(() => {
const path = this.plugin.settings.knownNounsPath.trim();
if (!path) {
new Notice('Set a known nouns path first.');
return;
}
void this.app.workspace.openLinkText(path, '', true);
});
});
const loaded = this.plugin.knownNouns;
parent.createEl('p', {
text: loaded.length === 0
? 'No known nouns loaded. The "Known nouns" section is omitted from the system prompt.'
: `Loaded ${loaded.length} noun${loaded.length === 1 ? '' : 's'}: ${loaded.map((n) => n.canonical).join(', ')}.`,
cls: 'rewrite-section-desc',
});
}
}

168
src/templates-folder.ts Normal file
View file

@ -0,0 +1,168 @@
import { App, normalizePath, parseYaml, stringifyYaml, TFile, TFolder } from 'obsidian';
import { InsertMode, NoteTemplate } from './types';
import { freshDefaultTemplates } from './settings/default-templates';
const VALID_INSERT_MODES: ReadonlySet<string> = new Set(['cursor', 'newFile', 'append']);
export async function loadTemplatesFromFolder(app: App, folderPath: string): Promise<NoteTemplate[]> {
const normalized = normalizeFolderPath(folderPath);
if (!normalized) return [];
const folder = app.vault.getAbstractFileByPath(normalized);
if (!(folder instanceof TFolder)) return [];
const items: Array<{ template: NoteTemplate; basename: string }> = [];
for (const child of folder.children) {
if (!(child instanceof TFile)) continue;
if (child.extension !== 'md') continue;
try {
const template = await parseTemplateFile(app, child);
if (template) items.push({ template, basename: child.basename });
} catch {
// Skip files with invalid frontmatter so a bad file doesn't hide the rest.
}
}
items.sort((a, b) => a.basename.localeCompare(b.basename));
return items.map((i) => i.template);
}
export interface PopulateResult {
created: number;
skipped: number;
folder: string;
}
export async function populateDefaultTemplates(app: App, folderPath: string): Promise<PopulateResult> {
const normalized = normalizeFolderPath(folderPath);
if (!normalized) throw new Error('Templates folder path is empty.');
const folder = await ensureFolder(app, normalized);
const existingIds = await collectExistingIds(app, folder);
let created = 0;
let skipped = 0;
for (const template of freshDefaultTemplates()) {
if (existingIds.has(template.id)) {
skipped++;
continue;
}
const filename = `${sanitizeFilename(template.name)}.md`;
const path = normalizePath(`${normalized}/${filename}`);
if (app.vault.getAbstractFileByPath(path)) {
skipped++;
continue;
}
await app.vault.create(path, renderTemplateFile(template));
created++;
}
return { created, skipped, folder: normalized };
}
export function isPathInTemplatesFolder(path: string, folderPath: string): boolean {
const normalizedFolder = normalizeFolderPath(folderPath);
if (!normalizedFolder) return false;
const normalizedPath = normalizePath(path);
return normalizedPath === normalizedFolder
|| normalizedPath.startsWith(`${normalizedFolder}/`);
}
async function parseTemplateFile(app: App, file: TFile): Promise<NoteTemplate | null> {
const content = await app.vault.read(file);
const { frontmatter, body } = splitFrontmatter(content);
if (!frontmatter) return null;
const parsed: unknown = parseYaml(frontmatter);
if (!parsed || typeof parsed !== 'object') return null;
const obj = parsed as Record<string, unknown>;
const id = typeof obj.id === 'string' ? obj.id.trim() : '';
if (!id) return null;
const nameRaw = typeof obj.name === 'string' ? obj.name.trim() : '';
const name = nameRaw || file.basename;
const insertMode: InsertMode = typeof obj.insertMode === 'string' && VALID_INSERT_MODES.has(obj.insertMode)
? (obj.insertMode as InsertMode)
: 'cursor';
const newFileFolder = typeof obj.newFileFolder === 'string' ? obj.newFileFolder : '';
const newFileNameTemplateRaw = typeof obj.newFileNameTemplate === 'string' ? obj.newFileNameTemplate : '';
const newFileNameTemplate = newFileNameTemplateRaw || 'ReWrite {{date}} {{time}}';
return {
id,
name,
prompt: body.trim(),
insertMode,
newFileFolder,
newFileNameTemplate,
};
}
async function collectExistingIds(app: App, folder: TFolder): Promise<Set<string>> {
const ids = new Set<string>();
for (const child of folder.children) {
if (!(child instanceof TFile)) continue;
if (child.extension !== 'md') continue;
try {
const content = await app.vault.read(child);
const { frontmatter } = splitFrontmatter(content);
if (!frontmatter) continue;
const parsed: unknown = parseYaml(frontmatter);
if (!parsed || typeof parsed !== 'object') continue;
const id = (parsed as Record<string, unknown>).id;
if (typeof id === 'string' && id.trim()) ids.add(id.trim());
} catch {
// Skip unreadable files.
}
}
return ids;
}
async function ensureFolder(app: App, path: string): Promise<TFolder> {
const existing = app.vault.getAbstractFileByPath(path);
if (existing instanceof TFolder) return existing;
if (existing) throw new Error(`${path} exists but is not a folder.`);
const created = await app.vault.createFolder(path);
if (created instanceof TFolder) return created;
const resolved = app.vault.getAbstractFileByPath(path);
if (resolved instanceof TFolder) return resolved;
throw new Error(`Failed to create folder ${path}.`);
}
function splitFrontmatter(content: string): { frontmatter: string | null; body: string } {
if (!content.startsWith('---')) return { frontmatter: null, body: content };
const lines = content.split(/\r?\n/);
if (lines[0]?.trim() !== '---') return { frontmatter: null, body: content };
for (let i = 1; i < lines.length; i++) {
if (lines[i]?.trim() === '---') {
return {
frontmatter: lines.slice(1, i).join('\n'),
body: lines.slice(i + 1).join('\n'),
};
}
}
return { frontmatter: null, body: content };
}
function renderTemplateFile(template: NoteTemplate): string {
const fm = stringifyYaml({
id: template.id,
name: template.name,
insertMode: template.insertMode,
newFileFolder: template.newFileFolder,
newFileNameTemplate: template.newFileNameTemplate,
}).replace(/\n+$/, '');
return `---\n${fm}\n---\n${template.prompt}\n`;
}
function sanitizeFilename(name: string): string {
const cleaned = name.replace(/[\\/:*?"<>|]/g, '-').trim();
return cleaned || 'Untitled';
}
function normalizeFolderPath(folderPath: string): string {
const trimmed = folderPath.trim();
if (!trimmed) return '';
const normalized = normalizePath(trimmed);
if (!normalized || normalized === '/' || normalized === '.') return '';
return normalized;
}

View file

@ -1,6 +1,6 @@
import { TranscriptionConfig } from '../types';
import { MultipartPart, multipartPost, ProviderError } from '../http';
import { audioFilename, TranscriptionProvider } from './index';
import { TranscriptionProvider } from './index';
import type { WhisperHost } from '../whisper-host';
let host: WhisperHost | null = null;
@ -9,6 +9,8 @@ export function bindWhisperHost(h: WhisperHost): void {
host = h;
}
const TARGET_SAMPLE_RATE = 16000;
export function createWhisperLocalTranscription(): TranscriptionProvider {
return {
id: 'whisper-local',
@ -21,23 +23,25 @@ export function createWhisperLocalTranscription(): TranscriptionProvider {
if (!host) {
throw new ProviderError('whisper-local', 0, '', 'Local whisper.cpp server is not initialized (desktop only).');
}
if (host.status() !== 'running') {
throw new ProviderError('whisper-local', 0, '', 'Local whisper.cpp server is not running. Start it from settings.');
}
const baseUrl = host.baseUrl();
if (!baseUrl) {
throw new ProviderError('whisper-local', 0, '', 'Local whisper.cpp server has no base URL.');
throw new ProviderError('whisper-local', 0, '', 'Local whisper.cpp server is not reachable. Start it from settings, or check whether the configured port is bound.');
}
let wavBuffer: ArrayBuffer;
try {
wavBuffer = await transcodeToWavPcm(audio, TARGET_SAMPLE_RATE);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new ProviderError('whisper-local', 0, '', `Failed to transcode audio to WAV for whisper.cpp: ${msg}`);
}
const data = await audio.arrayBuffer();
const parts: MultipartPart[] = [
{
type: 'file',
name: 'file',
filename: audioFilename(audio),
contentType: audio.type || 'application/octet-stream',
data,
filename: 'audio.wav',
contentType: 'audio/wav',
data: wavBuffer,
},
{ type: 'text', name: 'model', value: config.model || 'whisper-1' },
{ type: 'text', name: 'response_format', value: 'text' },
];
if (config.language) {
@ -45,7 +49,7 @@ export function createWhisperLocalTranscription(): TranscriptionProvider {
}
const res = await multipartPost(
'whisper-local',
`${baseUrl}/v1/audio/transcriptions`,
`${baseUrl}/inference`,
parts,
{},
signal,
@ -54,3 +58,68 @@ export function createWhisperLocalTranscription(): TranscriptionProvider {
},
};
}
async function transcodeToWavPcm(audio: Blob, targetSampleRate: number): Promise<ArrayBuffer> {
const input = await audio.arrayBuffer();
const decoded = await decodeAudio(input);
const resampled = await resampleToMono(decoded, targetSampleRate);
return encodeWav16(resampled, targetSampleRate);
}
async function decodeAudio(buffer: ArrayBuffer): Promise<AudioBuffer> {
const Ctx = (window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext);
if (!Ctx) throw new Error('Web Audio API is unavailable in this environment.');
const ctx = new Ctx();
try {
return await ctx.decodeAudioData(buffer.slice(0));
} finally {
try { await ctx.close(); } catch { /* best effort */ }
}
}
async function resampleToMono(input: AudioBuffer, targetSampleRate: number): Promise<Float32Array> {
const targetLength = Math.max(1, Math.ceil(input.duration * targetSampleRate));
const offline = new OfflineAudioContext(1, targetLength, targetSampleRate);
const source = offline.createBufferSource();
source.buffer = input;
source.connect(offline.destination);
source.start();
const rendered = await offline.startRendering();
return rendered.getChannelData(0);
}
function encodeWav16(samples: Float32Array, sampleRate: number): ArrayBuffer {
const numChannels = 1;
const bytesPerSample = 2;
const blockAlign = numChannels * bytesPerSample;
const byteRate = sampleRate * blockAlign;
const dataSize = samples.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
writeAscii(view, 0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bytesPerSample * 8, true);
writeAscii(view, 36, 'data');
view.setUint32(40, dataSize, true);
let offset = 44;
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i] ?? 0));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
offset += 2;
}
return buffer;
}
function writeAscii(view: DataView, offset: number, value: string): void {
for (let i = 0; i < value.length; i++) {
view.setUint8(offset + i, value.charCodeAt(i));
}
}

View file

@ -40,6 +40,22 @@ export interface NoteTemplate {
newFileNameTemplate: string;
}
export interface DestinationOverride {
insertMode?: InsertMode;
newFileFolder?: string;
newFileNameTemplate?: string;
}
export interface KnownNoun {
canonical: string;
alternates: string[];
}
export interface PipelineHost {
assistantPrompt: string | null;
knownNouns: KnownNoun[];
}
export interface EnvironmentProfile {
name: string;
transcriptionProvider: TranscriptionProviderID;
@ -76,7 +92,12 @@ export interface GlobalSettings {
defaultTemplateId: string;
lastUsedTemplateId: string;
recordingFormat: RecordingFormatPreference;
templates: NoteTemplate[];
templatesFolderPath: string;
attachmentsFolderPath: string;
adHocInstructionsEnabled: boolean;
assistantName: string;
assistantPromptPath: string;
knownNounsPath: string;
modelCache: ModelCache;
localWhisper: LocalWhisperSettings;
}

View file

@ -0,0 +1,26 @@
import { App, FuzzySuggestModal, TFile } from 'obsidian';
export interface AudioFilePickerParams {
app: App;
files: TFile[];
onPick: (file: TFile) => void;
}
export class AudioFilePickerModal extends FuzzySuggestModal<TFile> {
constructor(private readonly params: AudioFilePickerParams) {
super(params.app);
this.setPlaceholder('Search audio files in your vault...');
}
getItems(): TFile[] {
return this.params.files;
}
getItemText(file: TFile): string {
return file.path;
}
onChooseItem(file: TFile): void {
this.params.onPick(file);
}
}

63
src/ui/audio-source.ts Normal file
View file

@ -0,0 +1,63 @@
import { App, Notice, TAbstractFile, TFile } from 'obsidian';
import type ReWritePlugin from '../main';
import { NoteTemplate } from '../types';
import { resolveActiveProfile } from '../platform';
import { runPipeline } from '../pipeline';
import { AUDIO_EXTENSIONS, extensionToMime } from '../audio-persist';
import { isProfileConfigured } from './setup-card';
import { ReWriteModal } from './modal';
const AUDIO_EXT_SET: ReadonlySet<string> = new Set<string>(AUDIO_EXTENSIONS);
export function isAudioFile(file: TAbstractFile | null | undefined): file is TFile {
if (!file || !(file instanceof TFile)) return false;
return AUDIO_EXT_SET.has(file.extension.toLowerCase());
}
export function collectAudioFiles(app: App): TFile[] {
return app.vault.getFiles().filter((f) => isAudioFile(f));
}
export async function readAudioFileAsBlob(app: App, file: TFile): Promise<Blob> {
const buffer = await app.vault.readBinary(file);
return new Blob([buffer], { type: extensionToMime(file.extension) });
}
export async function runAudioFilePipeline(
plugin: ReWritePlugin,
template: NoteTemplate,
file: TFile,
): Promise<void> {
const settings = plugin.settings;
const { profile } = resolveActiveProfile(settings);
if (plugin.encryptionStatus.locked) {
new Notice('ReWrite: API keys are locked. Unlock to reprocess audio.');
plugin.promptUnlock();
return;
}
if (!isProfileConfigured(profile)) {
new Notice('ReWrite: configure a transcription and LLM provider before reprocessing audio.');
new ReWriteModal(plugin.app, plugin).open();
return;
}
const progress = new Notice('ReWrite: reprocessing audio...', 0);
try {
const blob = await readAudioFileAsBlob(plugin.app, file);
await runPipeline({
app: plugin.app,
settings,
host: plugin,
profile,
template,
source: { kind: 'audio', audio: blob, sourcePath: file.path },
});
progress.hide();
plugin.settings.lastUsedTemplateId = template.id;
await plugin.saveSettings();
new Notice('ReWrite complete.');
} catch (e) {
progress.hide();
const message = e instanceof Error ? e.message : String(e);
new Notice(`ReWrite: ${message}`);
}
}

View file

@ -4,7 +4,7 @@ import { runPipeline, PipelineSource, PipelineStage } from '../pipeline';
import { isMediaRecorderAvailable, isWebSpeechAvailable, resolveActiveProfile } from '../platform';
import { Recorder } from '../recorder';
import { startWebSpeech, WebSpeechSession } from '../webspeech';
import { EnvironmentProfile } from '../types';
import { DestinationOverride, EnvironmentProfile, InsertMode, NoteTemplate } from '../types';
import { isProfileConfigured, isProfileConfiguredForText, renderSetupCard } from './setup-card';
import { resolveActiveTextSource } from './text-source';
@ -16,6 +16,7 @@ export class ReWriteModal extends Modal {
private timerHandle: number | null = null;
private running = false;
private currentSource: PipelineSource | null = null;
private destinationOverride: DestinationOverride | null = null;
constructor(
app: App,
@ -38,13 +39,14 @@ export class ReWriteModal extends Modal {
private pickDefaultTemplateId(): string {
const s = this.plugin.settings;
if (s.lastUsedTemplateId && s.templates.some((t) => t.id === s.lastUsedTemplateId)) {
const templates = this.plugin.templates;
if (s.lastUsedTemplateId && templates.some((t) => t.id === s.lastUsedTemplateId)) {
return s.lastUsedTemplateId;
}
if (s.defaultTemplateId && s.templates.some((t) => t.id === s.defaultTemplateId)) {
if (s.defaultTemplateId && templates.some((t) => t.id === s.defaultTemplateId)) {
return s.defaultTemplateId;
}
return s.templates[0]?.id ?? '';
return templates[0]?.id ?? '';
}
private render(): void {
@ -55,9 +57,9 @@ export class ReWriteModal extends Modal {
const { kind, profile } = resolveActiveProfile(this.plugin.settings);
const profileLabel = kind === 'desktop' ? 'Desktop' : 'Mobile';
if (this.plugin.settings.templates.length === 0) {
if (this.plugin.templates.length === 0) {
contentEl.createEl('p', {
text: 'No templates are configured. Open settings to add one.',
text: 'No templates are configured. Open settings to set up your templates folder.',
});
const btn = contentEl.createEl('button', { text: 'Open settings' });
btn.addEventListener('click', () => {
@ -68,9 +70,15 @@ export class ReWriteModal extends Modal {
}
this.renderTemplateSelector(contentEl);
this.renderDestinationSelector(contentEl);
this.renderTabBar(contentEl);
const tabBody = contentEl.createDiv({ cls: 'rewrite-tab-body' });
if (this.plugin.encryptionStatus.locked) {
this.renderLockedBanner(tabBody);
return;
}
if (this.activeTab === 'fromNote') {
if (!isProfileConfiguredForText(profile)) {
this.renderSetupCardInTab(tabBody, profile, profileLabel, 'text');
@ -114,24 +122,115 @@ export class ReWriteModal extends Modal {
});
}
private renderLockedBanner(parent: HTMLElement): void {
const card = parent.createDiv({ cls: 'rewrite-setup-card rewrite-locked-card' });
card.createEl('h3', { text: 'API keys are locked' });
card.createEl('p', {
text: 'Your API keys are encrypted with a passphrase. Unlock them to record or process text.',
});
const actions = card.createDiv({ cls: 'rewrite-setup-actions' });
const unlockBtn = actions.createEl('button', { text: 'Unlock', cls: 'mod-cta' });
unlockBtn.addEventListener('click', () => {
this.plugin.promptUnlock(() => this.render());
});
const settingsBtn = actions.createEl('button', { text: 'Open settings' });
settingsBtn.addEventListener('click', () => {
this.close();
this.openSettingsTab();
});
}
private renderTemplateSelector(parent: HTMLElement): void {
const wrap = parent.createDiv({ cls: 'rewrite-template-row' });
wrap.createEl('label', { text: 'Template' });
const select = wrap.createEl('select');
const ids = this.plugin.settings.templates.map((t) => t.id);
const ids = this.plugin.templates.map((t) => t.id);
if (!ids.includes(this.templateId)) {
this.templateId = ids[0] ?? '';
}
for (const t of this.plugin.settings.templates) {
for (const t of this.plugin.templates) {
const opt = select.createEl('option', { text: t.name });
opt.value = t.id;
if (t.id === this.templateId) opt.selected = true;
}
select.addEventListener('change', () => {
this.templateId = select.value;
this.destinationOverride = null;
this.render();
});
}
private renderDestinationSelector(parent: HTMLElement): void {
const template = this.activeTemplate();
if (!template) return;
const effectiveMode = this.destinationOverride?.insertMode ?? template.insertMode;
const effectiveFolder = this.destinationOverride?.newFileFolder ?? template.newFileFolder;
const effectiveName = this.destinationOverride?.newFileNameTemplate ?? template.newFileNameTemplate;
const wrap = parent.createDiv({ cls: 'rewrite-destination-row' });
wrap.createEl('label', { text: 'Destination' });
const select = wrap.createEl('select');
const modes: Array<{ id: InsertMode; label: string }> = [
{ id: 'cursor', label: 'Cursor (active editor)' },
{ id: 'newFile', label: 'New file' },
{ id: 'append', label: 'Append to active note' },
];
for (const m of modes) {
const opt = select.createEl('option', { text: m.label });
opt.value = m.id;
if (m.id === effectiveMode) opt.selected = true;
}
select.addEventListener('change', () => {
this.setDestinationOverride({ insertMode: select.value as InsertMode });
this.render();
});
if (effectiveMode === 'newFile') {
const folderLabel = wrap.createEl('label', { text: 'Folder', cls: 'rewrite-destination-sublabel' });
const folderInput = folderLabel.createEl('input', { type: 'text' });
folderInput.value = effectiveFolder;
folderInput.placeholder = '(vault root)';
folderInput.addEventListener('change', () => {
this.setDestinationOverride({ newFileFolder: folderInput.value });
});
const nameLabel = wrap.createEl('label', { text: 'Filename template', cls: 'rewrite-destination-sublabel' });
const nameInput = nameLabel.createEl('input', { type: 'text' });
nameInput.value = effectiveName;
nameInput.placeholder = 'ReWrite {{date}} {{time}}';
nameInput.addEventListener('change', () => {
this.setDestinationOverride({ newFileNameTemplate: nameInput.value });
});
}
if (this.destinationOverride) {
const reset = wrap.createEl('button', { text: 'Reset to template default', cls: 'rewrite-destination-reset' });
reset.addEventListener('click', () => {
this.destinationOverride = null;
this.render();
});
}
}
private activeTemplate(): NoteTemplate | undefined {
return this.plugin.templates.find((t) => t.id === this.templateId);
}
private setDestinationOverride(patch: DestinationOverride): void {
const template = this.activeTemplate();
if (!template) return;
const current: DestinationOverride = this.destinationOverride ?? {
insertMode: template.insertMode,
newFileFolder: template.newFileFolder,
newFileNameTemplate: template.newFileNameTemplate,
};
this.destinationOverride = {
insertMode: patch.insertMode ?? current.insertMode,
newFileFolder: patch.newFileFolder ?? current.newFileFolder,
newFileNameTemplate: patch.newFileNameTemplate ?? current.newFileNameTemplate,
};
}
private renderTabBar(parent: HTMLElement): void {
const tabs = parent.createDiv({ cls: 'rewrite-tabs' });
const record = tabs.createEl('button', { text: 'Record', cls: 'rewrite-tab' });
@ -314,7 +413,7 @@ export class ReWriteModal extends Modal {
}
private async execute(source: PipelineSource): Promise<void> {
const template = this.plugin.settings.templates.find((t) => t.id === this.templateId);
const template = this.plugin.templates.find((t) => t.id === this.templateId);
if (!template) {
new Notice('Please pick a template.');
return;
@ -328,9 +427,11 @@ export class ReWriteModal extends Modal {
await runPipeline({
app: this.app,
settings: this.plugin.settings,
host: this.plugin,
profile,
template,
source,
destinationOverride: this.destinationOverride ?? undefined,
onStage: (stage) => progress.setText(stageLabel(stage)),
});
this.plugin.settings.lastUsedTemplateId = template.id;
@ -371,6 +472,8 @@ export class ReWriteModal extends Modal {
function stageLabel(stage: PipelineStage): string {
switch (stage) {
case 'persist-audio':
return 'Saving audio...';
case 'transcribe':
return 'Transcribing...';
case 'cleanup':

156
src/ui/passphrase-modal.ts Normal file
View file

@ -0,0 +1,156 @@
import { App, Modal, Notice, Setting } from 'obsidian';
export interface PassphrasePromptParams {
app: App;
title: string;
description?: string;
confirmLabel?: string;
// When true, render a second "Confirm passphrase" field that must match.
requireConfirm?: boolean;
// Called with the entered passphrase. Throw to keep the modal open and surface an error.
onSubmit: (passphrase: string) => Promise<void>;
}
export class PassphraseModal extends Modal {
private passphrase = '';
private confirm = '';
private busy = false;
private errorEl: HTMLElement | null = null;
constructor(private readonly params: PassphrasePromptParams) {
super(params.app);
}
onOpen(): void {
this.modalEl.addClass('rewrite-modal');
this.modalEl.addClass('rewrite-passphrase-modal');
const { contentEl } = this;
contentEl.createEl('h2', { text: this.params.title });
if (this.params.description) {
contentEl.createEl('p', { text: this.params.description, cls: 'rewrite-passphrase-desc' });
}
if (this.params.requireConfirm) {
this.renderPassphraseTips(contentEl);
}
new Setting(contentEl)
.setName('Passphrase')
.addText((t) => {
t.inputEl.type = 'password';
t.inputEl.addClass('rewrite-passphrase-input');
t.inputEl.autofocus = true;
t.onChange((v) => { this.passphrase = v; });
t.inputEl.addEventListener('keydown', (e) => this.onKeydown(e));
});
if (this.params.requireConfirm) {
new Setting(contentEl)
.setName('Confirm passphrase')
.addText((t) => {
t.inputEl.type = 'password';
t.inputEl.addClass('rewrite-passphrase-input');
t.onChange((v) => { this.confirm = v; });
t.inputEl.addEventListener('keydown', (e) => this.onKeydown(e));
});
contentEl.createEl('p', {
text: 'If you lose this passphrase, you will need to re-enter every API key. There is no recovery.',
cls: 'rewrite-passphrase-warning',
});
}
this.errorEl = contentEl.createEl('p', { cls: 'rewrite-passphrase-error rewrite-hidden' });
const actions = contentEl.createDiv({ cls: 'rewrite-passphrase-actions' });
const submit = actions.createEl('button', { text: this.params.confirmLabel ?? 'Unlock', cls: 'mod-cta' });
submit.addEventListener('click', () => { void this.submit(); });
const cancel = actions.createEl('button', { text: 'Cancel' });
cancel.addEventListener('click', () => this.close());
}
onClose(): void {
this.passphrase = '';
this.confirm = '';
this.contentEl.empty();
}
private renderPassphraseTips(parent: HTMLElement): void {
const tips = parent.createDiv({ cls: 'rewrite-passphrase-tips' });
tips.createEl('strong', { text: 'Picking a strong passphrase' });
const list = tips.createEl('ul');
const li1 = list.createEl('li');
li1.createSpan({ text: 'Length beats complexity. A 5-6 word diceware-style password (like one you can generate ' });
appendExternalLink(li1, 'here', 'https://www.keepersecurity.com/features/passphrase-generator/');
li1.createSpan({ text: ') is far stronger than ' });
li1.createEl('code', { text: 'P@ssw0rd!' });
li1.createSpan({ text: ' and much easier to remember than ' });
// eslint-disable-next-line obsidianmd/ui/sentence-case
li1.createEl('code', { text: 'xv^02>lWP6nm2gR' });
li1.createSpan({ text: '.' });
const li2 = list.createEl('li');
li2.createEl('strong', { text: 'Never reuse a password from elsewhere.' });
li2.createSpan({ text: ' If it appears in a breach corpus, it can be cracked instantly no matter how complex it looks.' });
const li3 = list.createEl('li');
li3.createSpan({ text: 'Check candidates against ' });
appendExternalLink(li3, 'haveibeenpwned.com/Passwords', 'https://haveibeenpwned.com/Passwords');
li3.createSpan({ text: ' before using them. See ' });
appendExternalLink(li3, 'hivesystems.com/password', 'https://www.hivesystems.com/password');
li3.createSpan({ text: ' for brute-force time estimates by length and character class.' });
}
private onKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter') {
e.preventDefault();
void this.submit();
}
}
private setError(msg: string): void {
if (!this.errorEl) return;
this.errorEl.setText(msg);
this.errorEl.removeClass('rewrite-hidden');
}
private clearError(): void {
if (!this.errorEl) return;
this.errorEl.setText('');
this.errorEl.addClass('rewrite-hidden');
}
private async submit(): Promise<void> {
if (this.busy) return;
this.clearError();
if (this.passphrase.length === 0) {
this.setError('Enter a passphrase.');
return;
}
if (this.params.requireConfirm && this.passphrase !== this.confirm) {
this.setError('Passphrases do not match.');
return;
}
this.busy = true;
try {
await this.params.onSubmit(this.passphrase);
this.close();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.setError(msg);
new Notice(msg);
} finally {
this.busy = false;
}
}
}
function appendExternalLink(parent: HTMLElement, label: string, href: string): void {
const a = parent.createEl('a', { text: label, href });
a.target = '_blank';
a.rel = 'noopener noreferrer';
}

View file

@ -15,13 +15,16 @@ export class QuickRecordController {
private floater: QuickRecordFloater | null = null;
private settled = false;
private startedAt = 0;
private template: NoteTemplate;
constructor(
private readonly plugin: ReWritePlugin,
private readonly template: NoteTemplate,
template: NoteTemplate,
private readonly isWebSpeech: boolean,
private readonly onDispose: () => void,
) {}
) {
this.template = template;
}
async begin(): Promise<void> {
const { profile } = resolveActiveProfile(this.plugin.settings);
@ -34,12 +37,19 @@ export class QuickRecordController {
await this.recorder.start(this.plugin.settings.recordingFormat);
}
this.startedAt = Date.now();
this.floater = new QuickRecordFloater(
() => {
this.floater = new QuickRecordFloater({
onStop: () => {
void this.finish();
},
() => this.cancel(),
);
onCancel: () => this.cancel(),
getTemplates: () => this.plugin.templates,
getActiveTemplateId: () => this.template.id,
onPickTemplate: (t) => {
this.template = t;
this.floater?.setTemplateName(t.name);
},
initialTemplateName: this.template.name,
});
this.timerHandle = window.setInterval(() => {
const ms = this.isWebSpeech
? Date.now() - this.startedAt
@ -72,6 +82,7 @@ export class QuickRecordController {
await runPipeline({
app: this.plugin.app,
settings: this.plugin.settings,
host: this.plugin,
profile,
template: this.template,
source,
@ -120,6 +131,12 @@ export async function startQuickRecord(
const settings = plugin.settings;
const { profile } = resolveActiveProfile(settings);
if (plugin.encryptionStatus.locked) {
new Notice('ReWrite: API keys are locked. Unlock to record.');
plugin.promptUnlock();
return null;
}
if (!isProfileConfigured(profile)) {
new Notice('ReWrite: profile is not configured. Finish setup to use quick record.');
new ReWriteModal(plugin.app, plugin).open();
@ -159,30 +176,60 @@ export async function startQuickRecord(
function pickQuickRecordTemplate(plugin: ReWritePlugin): NoteTemplate | undefined {
const s = plugin.settings;
const templates = plugin.templates;
return (
s.templates.find((t) => t.id === s.defaultTemplateId)
?? s.templates.find((t) => t.id === s.lastUsedTemplateId)
?? s.templates[0]
templates.find((t) => t.id === s.defaultTemplateId)
?? templates.find((t) => t.id === s.lastUsedTemplateId)
?? templates[0]
);
}
interface QuickRecordFloaterOptions {
onStop: () => void;
onCancel: () => void;
getTemplates: () => NoteTemplate[];
getActiveTemplateId: () => string;
onPickTemplate: (t: NoteTemplate) => void;
initialTemplateName: string;
}
class QuickRecordFloater {
private readonly el: HTMLElement;
private readonly timerEl: HTMLElement;
private readonly templateBtn: HTMLButtonElement;
private readonly templateLabel: HTMLElement;
private popover: HTMLElement | null = null;
private outsideClickHandler: ((e: MouseEvent) => void) | null = null;
private keyHandler: ((e: KeyboardEvent) => void) | null = null;
private busy = false;
constructor(onStop: () => void, onCancel: () => void) {
constructor(private readonly options: QuickRecordFloaterOptions) {
this.el = document.body.createDiv({ cls: 'rewrite-quick-floater' });
this.el.createSpan({ cls: 'rewrite-quick-dot' });
this.el.createSpan({ cls: 'rewrite-quick-label', text: 'Recording' });
this.timerEl = this.el.createSpan({ cls: 'rewrite-quick-timer', text: '0:00' });
this.templateBtn = this.el.createEl('button', {
cls: 'rewrite-quick-template',
});
this.templateLabel = this.templateBtn.createSpan({
cls: 'rewrite-quick-template-label',
text: options.initialTemplateName,
});
this.templateBtn.title = 'Change template';
this.templateBtn.addEventListener('click', (e) => {
if (this.busy) return;
e.stopPropagation();
this.togglePopover();
});
const stopBtn = this.el.createEl('button', {
text: 'Stop',
cls: 'mod-cta rewrite-quick-stop',
});
stopBtn.addEventListener('click', () => {
if (this.busy) return;
onStop();
options.onStop();
});
const cancelBtn = this.el.createEl('button', {
text: 'Cancel',
@ -190,7 +237,7 @@ class QuickRecordFloater {
});
cancelBtn.addEventListener('click', () => {
if (this.busy) return;
onCancel();
options.onCancel();
});
}
@ -202,15 +249,77 @@ class QuickRecordFloater {
this.busy = true;
this.el.addClass('is-busy');
this.timerEl.setText(label);
this.templateBtn.disabled = true;
this.closePopover();
}
setTemplateName(name: string): void {
this.templateLabel.setText(name);
}
dispose(): void {
this.closePopover();
this.el.remove();
}
private togglePopover(): void {
if (this.popover) {
this.closePopover();
return;
}
this.openPopover();
}
private openPopover(): void {
const templates = this.options.getTemplates();
if (templates.length === 0) return;
const activeId = this.options.getActiveTemplateId();
const popover = this.el.createDiv({ cls: 'rewrite-quick-popover' });
for (const t of templates) {
const item = popover.createEl('button', {
cls: 'rewrite-quick-popover-item',
text: t.name,
});
if (t.id === activeId) item.addClass('is-active');
item.addEventListener('click', (e) => {
e.stopPropagation();
this.options.onPickTemplate(t);
this.closePopover();
});
}
this.popover = popover;
this.outsideClickHandler = (e: MouseEvent) => {
if (!this.popover) return;
const target = e.target as Node | null;
if (target && (this.popover.contains(target) || this.templateBtn.contains(target))) return;
this.closePopover();
};
this.keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') this.closePopover();
};
document.addEventListener('click', this.outsideClickHandler, true);
document.addEventListener('keydown', this.keyHandler, true);
}
private closePopover(): void {
if (this.outsideClickHandler) {
document.removeEventListener('click', this.outsideClickHandler, true);
this.outsideClickHandler = null;
}
if (this.keyHandler) {
document.removeEventListener('keydown', this.keyHandler, true);
this.keyHandler = null;
}
this.popover?.remove();
this.popover = null;
}
}
function stageLabel(stage: PipelineStage): string {
switch (stage) {
case 'persist-audio':
return 'Saving audio...';
case 'transcribe':
return 'Transcribing...';
case 'cleanup':

View file

@ -30,6 +30,11 @@ export async function runTextPipeline(
): Promise<void> {
const settings = plugin.settings;
const { profile } = resolveActiveProfile(settings);
if (plugin.encryptionStatus.locked) {
new Notice('ReWrite: API keys are locked. Unlock to process text.');
plugin.promptUnlock();
return;
}
if (!isProfileConfiguredForText(profile)) {
new Notice('ReWrite: configure an LLM provider before processing text.');
new ReWriteModal(plugin.app, plugin).open();
@ -40,6 +45,7 @@ export async function runTextPipeline(
await runPipeline({
app: plugin.app,
settings,
host: plugin,
profile,
template,
source: { kind: 'text', text },

View file

@ -0,0 +1,100 @@
import { Notice, Platform } from 'obsidian';
import type ReWritePlugin from '../main';
import { formatWhisperStatus, WhisperOwnership, WhisperSnapshot, WhisperStatus } from '../whisper-host';
import { resolveActiveProfile } from '../platform';
const POLL_MS = 1000;
const DOT_VARIANTS = ['is-stopped', 'is-starting', 'is-running', 'is-external', 'is-crashed'];
export class WhisperStatusBar {
private readonly el: HTMLElement;
private readonly dot: HTMLElement;
private readonly label: HTMLElement;
private lastKey: string | null = null;
private lastHidden: boolean | null = null;
constructor(private readonly plugin: ReWritePlugin, host: HTMLElement) {
this.el = host;
this.el.addClass('rewrite-status-bar');
this.dot = this.el.createSpan({ cls: 'rewrite-status-dot' });
this.label = this.el.createSpan({ cls: 'rewrite-status-label' });
this.el.addEventListener('click', () => {
void this.toggle();
});
}
start(): void {
this.refresh();
this.plugin.registerInterval(window.setInterval(() => this.refresh(), POLL_MS));
}
private refresh(): void {
const hidden = this.shouldHide();
if (hidden !== this.lastHidden) {
this.el.toggleClass('rewrite-hidden', hidden);
this.lastHidden = hidden;
}
if (hidden) return;
const snap = this.plugin.whisperHost.snapshot();
const key = snapshotKey(snap);
if (key === this.lastKey) return;
this.lastKey = key;
for (const variant of DOT_VARIANTS) {
this.dot.removeClass(variant);
}
this.dot.addClass(`is-${snap.status}`);
this.label.setText(statusLabel(snap.status, snap.ownership));
const long = formatWhisperStatus(snap);
this.el.setAttr('aria-label', long);
this.el.setAttr('title', long);
}
private shouldHide(): boolean {
if (!Platform.isDesktop) return true;
const { profile } = resolveActiveProfile(this.plugin.settings);
return profile.transcriptionProvider !== 'whisper-local';
}
private async toggle(): Promise<void> {
const host = this.plugin.whisperHost;
const status = host.status();
if (status === 'external') {
const pid = host.pid();
const where = host.baseUrl() ?? `port ${this.plugin.settings.localWhisper.port}`;
new Notice(`External whisper-server on ${where}${pid !== null ? ` (pid ${pid})` : ''}. ReWrite won't stop a process it didn't start.`);
return;
}
try {
if (status === 'running' || status === 'starting') {
await host.stop();
} else {
await host.start(this.plugin.settings.localWhisper);
}
} catch (e) {
new Notice(e instanceof Error ? e.message : String(e));
}
this.lastKey = null;
this.refresh();
}
}
function snapshotKey(snap: WhisperSnapshot): string {
return `${snap.status}|${snap.ownership ?? ''}|${snap.pid ?? ''}|${snap.baseUrl ?? ''}`;
}
function statusLabel(status: WhisperStatus, ownership: WhisperOwnership | null): string {
switch (status) {
case 'stopped':
return 'Whisper: stopped';
case 'starting':
return 'Whisper: starting';
case 'running':
return ownership === 'adopted' ? 'Whisper: running (adopted)' : 'Whisper: running';
case 'external':
return 'Whisper: external';
case 'crashed':
return 'Whisper: crashed';
}
}

38
src/wake-name.ts Normal file
View file

@ -0,0 +1,38 @@
export interface AdHocExtraction {
transcript: string;
instructions: string[];
}
const FILLER = /^(?:uh|um|er|okay|ok|never\s*mind|scratch\s*that|cancel\s*that|forget\s*that|nothing)\s*[.!?]?\s*$/i;
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function extractAdHocInstructions(transcript: string, name: string): AdHocExtraction {
const trimmedName = name.trim();
if (!trimmedName) return { transcript, instructions: [] };
const safe = escapeRegex(trimmedName);
const pattern = new RegExp(
`\\b${safe}\\s*,\\s*([^.!?]*?)(?:([.!?])(?=\\s|$)|(?=\\b${safe}\\b)|$)`,
'gi',
);
const instructions: string[] = [];
const stripped = transcript.replace(pattern, (_full: string, body: string) => {
const cleaned = body.trim();
if (cleaned.length >= 2 && !FILLER.test(cleaned)) {
instructions.push(cleaned);
}
return '';
});
const finalText = stripped
.replace(/[ \t]+/g, ' ')
.replace(/ +([.!?,;:])/g, '$1')
.replace(/[ \t]*\n[ \t]*/g, '\n')
.trim();
return { transcript: finalText, instructions };
}

View file

@ -1,9 +1,27 @@
import { Platform } from 'obsidian';
import { normalizePath, Platform, Plugin } from 'obsidian';
import { LocalWhisperSettings } from './types';
export type WhisperStatus = 'stopped' | 'starting' | 'running' | 'crashed';
export type WhisperStatus = 'stopped' | 'starting' | 'running' | 'external' | 'crashed';
export type WhisperOwnership = 'spawned' | 'adopted' | 'external';
export interface WhisperSnapshot {
status: WhisperStatus;
baseUrl: string | null;
ownership: WhisperOwnership | null;
pid: number | null;
}
const PID_FILE = 'whisper-host.pid.json';
interface PidFileContents {
pid: number;
port: number;
binaryPath: string;
startedAt: number;
}
interface SpawnedChild {
pid?: number;
stdout: { on(event: 'data', cb: (chunk: { toString(): string }) => void): void } | null;
stderr: { on(event: 'data', cb: (chunk: { toString(): string }) => void): void } | null;
on(event: 'exit', cb: (code: number | null, signal: string | null) => void): void;
@ -41,10 +59,15 @@ interface FsAPI {
existsSync(path: string): boolean;
}
interface ProcessAPI {
kill(pid: number, signal?: number | string): void;
}
interface NodeAPI {
cp: ChildProcessAPI;
net: NetAPI;
fs: FsAPI;
process: ProcessAPI;
}
let nodeApiCache: NodeAPI | null | undefined;
@ -66,7 +89,12 @@ function getNodeApi(): NodeAPI | null {
const cp = req('child_process') as ChildProcessAPI;
const net = req('net') as NetAPI;
const fs = req('fs') as FsAPI;
nodeApiCache = { cp, net, fs };
const proc = (globalThis as unknown as { process?: ProcessAPI }).process;
if (!proc || typeof proc.kill !== 'function') {
nodeApiCache = null;
return null;
}
nodeApiCache = { cp, net, fs, process: proc };
return nodeApiCache;
} catch {
nodeApiCache = null;
@ -78,6 +106,31 @@ export function isWhisperHostAvailable(): boolean {
return getNodeApi() !== null;
}
export function formatWhisperStatus(snap: WhisperSnapshot): string {
switch (snap.status) {
case 'stopped':
return 'Stopped.';
case 'starting':
return 'Starting...';
case 'running': {
const where = snap.baseUrl ? ` on ${snap.baseUrl}` : '';
if (snap.ownership === 'adopted' && snap.pid !== null) {
return `Running${where} (adopted from previous session, pid ${snap.pid}).`;
}
if (snap.pid !== null) {
return `Running${where} (ReWrite, pid ${snap.pid}).`;
}
return `Running${where}.`;
}
case 'external': {
const where = snap.baseUrl ? ` on ${snap.baseUrl}` : '';
return `External whisper-server${where} (not started by ReWrite).`;
}
case 'crashed':
return 'Crashed. See log for details.';
}
}
const MAX_LOG_BYTES = 1_000_000;
const READY_TIMEOUT_MS = 5_000;
const READY_POLL_MS = 250;
@ -87,18 +140,40 @@ export class WhisperHost {
private statusValue: WhisperStatus = 'stopped';
private child: SpawnedChild | null = null;
private currentPort: number | null = null;
private currentPid: number | null = null;
private ownershipValue: WhisperOwnership | null = null;
private logBuffer = '';
private stoppingDeliberately = false;
constructor(private plugin: Plugin) {}
status(): WhisperStatus {
return this.statusValue;
}
baseUrl(): string | null {
if (this.statusValue !== 'running' || this.currentPort === null) return null;
if (this.currentPort === null) return null;
if (this.statusValue !== 'running' && this.statusValue !== 'external') return null;
return `http://127.0.0.1:${this.currentPort}`;
}
ownership(): WhisperOwnership | null {
return this.ownershipValue;
}
pid(): number | null {
return this.currentPid;
}
snapshot(): WhisperSnapshot {
return {
status: this.statusValue,
baseUrl: this.baseUrl(),
ownership: this.ownershipValue,
pid: this.currentPid,
};
}
getLog(): string {
return this.logBuffer;
}
@ -120,8 +195,17 @@ export class WhisperHost {
throw new Error(`Model not found: ${config.modelPath}`);
}
const port = Number.isFinite(config.port) && config.port > 0 ? config.port : 8080;
// Discover any existing server on the configured port before spawning.
const probed = await this.probe(config);
if (probed.status === 'running') {
// Adopted an orphan from a previous session; nothing to start.
return;
}
if (probed.status === 'external') {
throw new Error(`Port ${port} is bound by an external whisper-server (not started by ReWrite). Stop it via OS tools before starting one here.`);
}
if (await isPortInUse(api.net, port)) {
throw new Error(`Port ${port} is already in use. Another whisper-server may be bound to it; check Activity Monitor or Task Manager.`);
throw new Error(`Port ${port} is already in use. Another process may be bound to it; check Activity Monitor or Task Manager.`);
}
this.statusValue = 'starting';
@ -138,6 +222,8 @@ export class WhisperHost {
});
this.child = child;
this.currentPort = port;
this.currentPid = child.pid ?? null;
this.ownershipValue = 'spawned';
const append = (s: string): void => {
this.logBuffer += s;
@ -151,9 +237,12 @@ export class WhisperHost {
append(`\n[process exited code=${code ?? 'null'} signal=${signal ?? 'null'}]\n`);
if (this.child === child) {
this.child = null;
this.currentPid = null;
this.ownershipValue = null;
if (!this.stoppingDeliberately) {
this.statusValue = 'crashed';
}
void this.clearPidFile();
}
});
@ -166,6 +255,14 @@ export class WhisperHost {
}
if (await isPortReachable(api.net, port)) {
this.statusValue = 'running';
if (child.pid !== undefined) {
await this.writePidFile({
pid: child.pid,
port,
binaryPath: config.binaryPath,
startedAt: Date.now(),
});
}
return;
}
await delay(READY_POLL_MS);
@ -175,37 +272,165 @@ export class WhisperHost {
try { child.kill(); } catch { /* best effort */ }
this.child = null;
this.currentPort = null;
this.currentPid = null;
this.ownershipValue = null;
this.statusValue = 'crashed';
const tail = this.logBuffer.slice(-500);
throw new Error(`whisper-server did not become ready within ${READY_TIMEOUT_MS / 1000}s. Log tail: ${tail || '(empty)'}`);
}
async stop(): Promise<void> {
if (this.statusValue === 'external') {
throw new Error('This whisper-server was not started by ReWrite. Stop the process from your task manager.');
}
const child = this.child;
if (!child) {
if (child) {
this.stoppingDeliberately = true;
this.statusValue = 'stopped';
this.child = null;
this.currentPort = null;
this.currentPid = null;
this.ownershipValue = null;
await new Promise<void>((resolve) => {
let settled = false;
const finish = (): void => {
if (settled) return;
settled = true;
resolve();
};
child.once('exit', finish);
try { child.kill(); } catch { /* best effort */ }
setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* best effort */ }
finish();
}, STOP_KILL_GRACE_MS);
});
await this.clearPidFile();
return;
}
this.stoppingDeliberately = true;
this.statusValue = 'stopped';
this.child = null;
this.currentPort = null;
// Adopted (no live child handle) or stopped: kill via PID if we have one.
const api = getNodeApi();
const pid = this.currentPid;
if (pid !== null && api) {
this.stoppingDeliberately = true;
this.statusValue = 'stopped';
this.currentPort = null;
this.currentPid = null;
this.ownershipValue = null;
try { api.process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
const deadline = Date.now() + STOP_KILL_GRACE_MS;
while (Date.now() < deadline) {
if (!isPidAlive(api.process, pid)) break;
await delay(100);
}
if (isPidAlive(api.process, pid)) {
try { api.process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
}
} else {
this.statusValue = 'stopped';
this.currentPort = null;
this.currentPid = null;
this.ownershipValue = null;
}
await this.clearPidFile();
}
await new Promise<void>((resolve) => {
let settled = false;
const finish = (): void => {
if (settled) return;
settled = true;
resolve();
};
child.once('exit', finish);
try { child.kill(); } catch { /* best effort */ }
setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* best effort */ }
finish();
}, STOP_KILL_GRACE_MS);
});
// Probe the configured port to detect existing servers (adopt our own
// orphans, observe external ones). Safe to call any time. Will not
// disturb state when we already hold a live spawned child.
async probe(config: LocalWhisperSettings): Promise<WhisperSnapshot> {
const api = getNodeApi();
if (!api) return this.snapshot();
// If we already own a spawned child, leave state alone.
if (this.child && this.statusValue === 'running') return this.snapshot();
const port = Number.isFinite(config.port) && config.port > 0 ? config.port : 8080;
const reachable = await isPortReachable(api.net, port);
if (!reachable) {
// Nothing bound. Clear any stale sidecar and reset to stopped if we
// were tracking an external/adopted server that has since gone away.
await this.clearPidFile();
if (this.statusValue === 'external' || (this.statusValue === 'running' && this.ownershipValue === 'adopted')) {
this.statusValue = 'stopped';
this.currentPort = null;
this.currentPid = null;
this.ownershipValue = null;
}
return this.snapshot();
}
// Port is bound. Check the sidecar for ownership.
const record = await this.readPidFile();
const ownedByUs = record !== null
&& record.port === port
&& isPidAlive(api.process, record.pid);
if (ownedByUs && record) {
this.statusValue = 'running';
this.ownershipValue = 'adopted';
this.currentPort = port;
this.currentPid = record.pid;
return this.snapshot();
}
// Bound by someone else. Clear stale sidecar if present.
if (record) await this.clearPidFile();
this.statusValue = 'external';
this.ownershipValue = 'external';
this.currentPort = port;
this.currentPid = null;
return this.snapshot();
}
private pidFilePath(): string {
const dir = this.plugin.manifest.dir;
if (!dir) throw new Error('Plugin manifest.dir is missing');
return normalizePath(`${dir}/${PID_FILE}`);
}
private async writePidFile(contents: PidFileContents): Promise<void> {
try {
await this.plugin.app.vault.adapter.write(this.pidFilePath(), JSON.stringify(contents));
} catch {
// best effort; recovery just won't fire next session
}
}
private async readPidFile(): Promise<PidFileContents | null> {
const path = this.pidFilePath();
try {
if (!(await this.plugin.app.vault.adapter.exists(path))) return null;
const raw = await this.plugin.app.vault.adapter.read(path);
const parsed = JSON.parse(raw) as Partial<PidFileContents>;
if (
typeof parsed.pid === 'number'
&& typeof parsed.port === 'number'
&& typeof parsed.binaryPath === 'string'
&& typeof parsed.startedAt === 'number'
) {
return parsed as PidFileContents;
}
return null;
} catch {
return null;
}
}
private async clearPidFile(): Promise<void> {
try {
const path = this.pidFilePath();
if (await this.plugin.app.vault.adapter.exists(path)) {
await this.plugin.app.vault.adapter.remove(path);
}
} catch {
// best effort
}
}
}
function isPidAlive(proc: ProcessAPI, pid: number): boolean {
try {
proc.kill(pid, 0);
return true;
} catch {
return false;
}
}

View file

@ -7,6 +7,38 @@
margin-bottom: 12px;
}
.rewrite-modal .rewrite-destination-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
padding: 8px;
border: 1px dashed var(--background-modifier-border);
border-radius: 4px;
}
.rewrite-modal .rewrite-destination-row .rewrite-destination-sublabel {
display: flex;
flex-direction: column;
gap: 2px;
font-size: var(--font-ui-small);
color: var(--text-muted);
}
.rewrite-modal .rewrite-destination-row input[type="text"] {
width: 100%;
}
.rewrite-modal .rewrite-destination-reset {
align-self: flex-start;
font-size: var(--font-ui-small);
background: transparent;
border: none;
color: var(--text-accent);
cursor: pointer;
padding: 0;
}
.rewrite-modal .rewrite-tabs {
display: flex;
gap: 8px;
@ -57,6 +89,13 @@
font-family: var(--font-text);
}
.rewrite-settings-textarea {
width: 100%;
min-height: 80px;
font-family: var(--font-text);
resize: vertical;
}
.rewrite-modal .rewrite-progress {
margin-top: 12px;
font-style: italic;
@ -91,77 +130,6 @@
margin-bottom: 8px;
}
.rewrite-settings .rewrite-templates-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.rewrite-settings .rewrite-templates-empty {
color: var(--text-muted);
font-style: italic;
}
.rewrite-settings .rewrite-template-item {
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 8px;
background-color: var(--background-secondary);
}
.rewrite-settings .rewrite-template-item.is-dragging {
opacity: 0.4;
}
.rewrite-settings .rewrite-template-item.is-drop-target {
border-color: var(--interactive-accent);
box-shadow: 0 0 0 1px var(--interactive-accent);
}
.rewrite-settings .rewrite-template-header {
display: flex;
align-items: center;
gap: 8px;
}
.rewrite-settings .rewrite-drag-handle {
cursor: grab;
user-select: none;
color: var(--text-muted);
padding: 0 4px;
}
.rewrite-settings .rewrite-drag-handle:active {
cursor: grabbing;
}
.rewrite-settings .rewrite-template-name {
flex: 1;
font-weight: var(--font-semibold);
}
.rewrite-settings .rewrite-template-actions {
display: flex;
gap: 4px;
}
.rewrite-settings .rewrite-template-editor {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--background-modifier-border);
}
.rewrite-settings .rewrite-prompt-textarea {
width: 100%;
min-height: 120px;
font-family: var(--font-text);
}
.rewrite-settings .rewrite-templates-actions {
margin-top: 8px;
}
/* Quick record floating mini-UI */
.rewrite-quick-floater {
@ -200,6 +168,192 @@
}
.rewrite-quick-floater .rewrite-quick-stop,
.rewrite-quick-floater .rewrite-quick-cancel {
.rewrite-quick-floater .rewrite-quick-cancel,
.rewrite-quick-floater .rewrite-quick-template {
padding: 2px 8px;
}
.rewrite-quick-floater .rewrite-quick-template {
max-width: 14ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rewrite-quick-floater .rewrite-quick-popover {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
display: flex;
flex-direction: column;
min-width: 180px;
max-height: 240px;
overflow-y: auto;
padding: 4px;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
box-shadow: var(--shadow-s);
z-index: 10000;
}
.rewrite-quick-floater .rewrite-quick-popover-item {
text-align: left;
padding: 4px 8px;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-normal);
font-size: var(--font-ui-small);
border-radius: 4px;
}
.rewrite-quick-floater .rewrite-quick-popover-item:hover {
background-color: var(--background-modifier-hover);
}
.rewrite-quick-floater .rewrite-quick-popover-item.is-active {
color: var(--text-on-accent);
background-color: var(--interactive-accent);
}
/* Status bar */
.rewrite-status-bar {
display: inline-flex;
gap: 6px;
align-items: center;
cursor: pointer;
}
.rewrite-status-bar .rewrite-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--text-muted);
}
.rewrite-status-bar .rewrite-status-dot.is-stopped {
background-color: var(--text-muted);
}
.rewrite-status-bar .rewrite-status-dot.is-starting {
background-color: var(--color-yellow);
}
.rewrite-status-bar .rewrite-status-dot.is-running {
background-color: var(--color-green);
}
.rewrite-status-bar .rewrite-status-dot.is-external {
background-color: var(--color-orange);
}
.rewrite-status-bar .rewrite-status-dot.is-crashed {
background-color: var(--color-red);
}
.rewrite-hidden {
display: none !important;
}
/* Encryption banner (settings tab) */
.rewrite-encryption-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 16px;
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
font-size: var(--font-ui-small);
}
.rewrite-encryption-banner.is-ok {
border-color: var(--color-green);
}
.rewrite-encryption-banner.is-warning {
border-color: var(--color-yellow);
background-color: rgba(255, 196, 0, 0.08);
}
.rewrite-encryption-banner.is-locked {
border-color: var(--color-red);
background-color: rgba(255, 80, 80, 0.08);
}
.rewrite-encryption-banner button {
margin-left: auto;
}
/* Passphrase modal */
.rewrite-passphrase-modal .rewrite-passphrase-desc {
color: var(--text-muted);
margin-bottom: 12px;
}
.rewrite-passphrase-modal .rewrite-passphrase-input {
width: 100%;
font-family: var(--font-monospace);
}
.rewrite-passphrase-modal .rewrite-passphrase-warning {
color: var(--text-warning, var(--color-yellow));
font-size: var(--font-ui-small);
margin-top: 8px;
}
.rewrite-passphrase-modal .rewrite-passphrase-tips {
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 12px;
font-size: var(--font-ui-small);
}
.rewrite-passphrase-modal .rewrite-passphrase-tips strong {
display: block;
margin-bottom: 6px;
}
.rewrite-passphrase-modal .rewrite-passphrase-tips ul {
margin: 0;
padding-left: 18px;
}
.rewrite-passphrase-modal .rewrite-passphrase-tips li {
margin-bottom: 4px;
}
.rewrite-passphrase-modal .rewrite-passphrase-tips code {
font-size: 0.9em;
padding: 1px 4px;
background-color: var(--background-primary);
border-radius: 3px;
}
.rewrite-passphrase-modal .rewrite-passphrase-error {
color: var(--text-error, var(--color-red));
font-size: var(--font-ui-small);
margin-top: 8px;
}
.rewrite-passphrase-modal .rewrite-passphrase-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
/* Locked card in the main modal */
.rewrite-locked-card {
border-color: var(--color-red);
}