Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .memory/dictation-parakeet-modes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Dictation Parakeet lifecycle, modes, and dictionary — 2026-09-27

- Branch `feature/dictation-parakeet-modes`. Plan: `docs/plans/dictation-parakeet-modes-plan.md`.
- **Shared, pure modules:**
- `renderer/shared/dictation-preferences.ts` holds the mode resolution, idle-minute validation, and `parseDictationPreferencePatch`, which `settings:set` uses.
- `renderer/shared/dictation-dictionary.ts` holds parse, apply, and add-entry.
- **Settings keys:**
- `dictationActivationMode` (`toggle|hold|hybrid`);
- `dictationDictionary` (`{from,to}[]`);
- `localVoiceIdleUnloadMinutes` (integer 0–1440, 0 = never; default 10).
- `dictationHoldToTalk` stays the release-capable flag. The Linux `linuxHoldSettings.apply` path is keyed on it, so choosing a mode also sets it.
- `runtimeSettingsFrom` drops invalid shapes of all three keys.
- **Coordinator:**
- Hybrid starts the key watch at press. `pressedAt` and `releasedAt` are stamped at call time.
- A release shorter than `HYBRID_TAP_THRESHOLD_MS` (300) latches toggle.
- If the watch fails, the recording latches toggle with a hint.
- `warmUp` runs once per idle press. `applyDictionary` runs after cleanup.
- **Parakeet:**
- Leases wrap status, transcribe, and warm.
- Idle unload kills the worker process, since that is the only reliable way to free native memory.
- The exit listener calls `forget()`.
- The `warm` protocol message was added without a version bump, because the parent and worker ship together.
- **Composer:** the mic calls `localVoiceApi.warm` at start. The composer applies the dictionary in its transcript callback.
- **Not done:**
- Remote (mobile) transcription through Mac Parakeet does not apply the dictionary.
- VAD, history, and mute are deferred.
- Physical-hardware acceptance is pending.

## Review fixes
- Unmappable hybrid shortcuts latch toggle and announce the fallback at recorder readiness.
- Dictionary deduplication uses locale-independent lowercase; matching preserves original Unicode spelling and uses regex capture groups to select replacements. Dotted Turkish I, long s, and Greek sigma regressions cover regex case-fold equivalence.
- Recognizer release runs in the transcription lane under an idle lease, so idle disposal cannot reject model deletion.
- CLI speech worker handles the shared warm request explicitly.
- Added shortcut/dictionary regressions; 44 focused tests, CI policy suite, desktop typecheck, and CLI build/typecheck pass. New suites are assigned to CI lanes.

Independent review corrected the dictionary settings row keys to use the same locale-independent lowercase identity as parser deduplication; a Turkish-casing regression verifies distinct I/dotless-ı entries keep distinct React keys.
1 change: 1 addition & 0 deletions docs/plans/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi
| -------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Tool approval scopes](tool-approval-scopes-plan.md) | Implemented for review | Allow once / Allow for this chat / Always allow for exact parent workspace commands and file writes; persisted rules revocable in Settings → Tool approvals; Aiden Remote contract revision 17 with iOS and Android menus. Assistant dock, subagent and Bot scopes are out of scope. |
| [Aiden CLI](aiden-cli-plan.md) | Active | Phases 0–5 implementation complete; macOS/Linux CLI (57 tests each), Linux native helpers, complete shared subagent suites, root TypeScript/lint, and Android client checks pass. Physical iPhone acceptance is pending an unlocked device. Phase 6 adds QR remote pairing, scheduled-run notifications (mobile push + desktop), shared desktop memory, daemon autostart, and prebuilt binaries; see the [checklist](aiden-cli-parity-checklist.md). |
| [Dictation: Parakeet lifecycle, modes, dictionary](dictation-parakeet-modes-plan.md) | Implemented for review | Handy P1 slice: configurable Parakeet idle unload with warm-up on hotkey/mic start, toggle/hold/tap-or-hold shortcut modes, and a custom dictionary applied to every transcript. VAD, history, and mute remain later; real-hardware acceptance pending. |
| Chat width setting (T3 #11594) | Implemented for review | Appearance → **Chat width** (Narrow 44rem / Default 52rem / Wide 64rem / Full) persists as `AppearanceConfig.chatWidth` and drives `--chat-content-max-width`, so the transcript, approvals, and composer resize together. The main chat footer matches the scrollport's usable width when a classic scrollbar reserves space, so Full remains aligned without horizontal overflow. Desktop only; older settings migrate to Default. No plan doc. |
| [Web Search API key pool](web-search-key-pool-plan.md) | Implemented for review | Tavily accepts up to 8 encrypted keys with ordered or round-robin use. Rejected keys (401/403) and quota-limited keys (429/432/433) cool down in memory with backoff and fail over to the next key. When every key is cooling, no request is sent and automatic routing falls back. Settings can add, remove, reorder and retry keys, and the renderer never sees a key. Other providers and CLI parity remain. |
| [Timed ask-user waits](timed-ask-user-plan.md) | Implemented for review | Optional `timeoutSeconds` on `ask_user_question`; unattended (Remote) runs always expire within 5 min and resolve with an explicit best-judgement result. Late desktop answers become a Send/Queue follow-up offer; Remote `expiresAt` carries the real deadline; iOS/Android say when a question expired. PR CI pending. |
Expand Down
52 changes: 52 additions & 0 deletions docs/plans/dictation-parakeet-modes-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Dictation: Parakeet lifecycle, activation modes, and custom dictionary

Status: Implemented for review (branch `feature/dictation-parakeet-modes`).

This is the first slice of the Handy-inspired P1 dictation work. VAD, dictation history, and mute-while-recording come later.

## Parakeet idle unload and warm-up

- `ParakeetIdleUnloader` (`main/services/parakeet-idle-unload.ts`) counts in-flight model work as leases. After the last lease ends, it starts a countdown using **Settings → Voice → On-Device Engine → Free memory when idle**. The choices are Never, 2, 5, 10 (the default), 15, 30, or 60 minutes.
- Unloading kills the Parakeet utility process. That is the only reliable way to return sherpa-onnx native memory. In-process fallback recognizers are released instead.
- Status probes also take a lease, so a worker spawned only for a status check still gets reaped.
- An async idle-period read that races a new lease cannot arm the timer. Changing the setting re-arms the countdown immediately.
- Warm-up uses a new `warm` worker message (`{kind, requestId, modelId, modelDirectory}`). It runs through the same transcription lane, so it never overlaps a transcription.
- The global shortcut warms the model when a press starts from idle. The composer microphone warms it when capture starts, through `localVoice:warm`.
- Warm-up is best effort. The transcription that follows reports any actionable error.
- The parent and worker ship in the same bundle, so the protocol version stays at 1.

## Activation modes

- `dictationActivationMode` is one of `toggle`, `hold`, or `hybrid`. The Settings label for `hybrid` is "Tap or hold".
- `dictationHoldToTalk` stays the persisted "must report key releases" flag, which the Linux portal binding already uses. Choosing a mode sets that flag. A legacy boolean alone leaves the stored mode unchanged.
- In hybrid mode the key watch starts at press time. A release within 300 ms of key-down counts as a tap and latches recording on, so the next press stops it. A longer hold behaves as push-to-talk. Timestamps are taken when the call happens, not when it is dequeued, so a slow pill does not turn a hold into a tap.
- If the release watch fails in hybrid mode, recording latches as toggle and the pill shows "Press the shortcut again to stop."
- Hosts that cannot report releases resolve every mode to toggle.

## Custom dictionary

- Each entry is `{from, to}`, where an empty `to` means "write `from` as typed". Limits are 200 entries and 100 characters per term. Entries are de-duplicated case-insensitively, and re-adding a word updates its replacement.
- The dictionary is applied in one pass after optional LLM cleanup:
- matching is case-insensitive, with Unicode whole-word boundaries;
- longest phrases win;
- whitespace inside a phrase matches any run of whitespace;
- replacements are never re-scanned.
- It applies to global dictation (main process) and composer microphone transcripts (renderer). A failing dictionary keeps the original transcript.
- The editor is **Settings → Voice → Custom Dictionary**.

## Verification

- `npm run test:voice` covers:
- the idle-unload timer;
- hybrid, hold, and toggle state machines;
- warm-up;
- dictionary application and parsing;
- settings patch validation;
- the dictionary editor view.
- `config-store-core.test.ts` covers hand-edited preference normalization.
- Real-hardware acceptance is still open: macOS hold timing, the Linux portal, and memory reclaimed after unload.

## Later

- VAD trimming, dictation history, and muting other audio while recording.
- Applying the dictionary to Remote (iOS/Android) Mac-side Parakeet transcription, and syncing it to the native clients' on-device dictation.
17 changes: 16 additions & 1 deletion main/handlers/local-voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
// services/parakeet.ts and services/local-models.ts.

import { ipcMain } from "../platform.js";
import { engineStatus, transcribePcmBase64, releaseRecognizer } from "../services/parakeet.js";
import {
engineStatus,
transcribePcmBase64,
releaseRecognizer,
warmLocalVoice,
} from "../services/parakeet.js";
import {
listModels,
downloadModel,
Expand Down Expand Up @@ -35,6 +40,16 @@ export { asString, pcmToFloat32 };
export function registerLocalVoiceHandlers(): void {
// ── Engine ───────────────────────────────────────────────────────────
ipcMain.handle("localVoice:status", async () => engineStatus());
// Preload the recognizer when the composer mic starts. Best effort: a failed
// warm-up is reported by the transcription that follows, not here.
ipcMain.handle("localVoice:warm", async (_event, id: unknown) => {
const modelId = asString(id, "id");
try {
await warmLocalVoice(modelId);
} catch {
// Ignored: transcription surfaces the actionable error.
}
});

// ── Model management ─────────────────────────────────────────────────
ipcMain.handle("localModels:list", async () => listModels());
Expand Down
11 changes: 9 additions & 2 deletions main/handlers/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ import {
import { isGenerationThinkingLevel } from "../../renderer/shared/generation-thinking.js";
import { isGeminiUsageScope } from "../../renderer/shared/gemini-usage-scope.js";
import { isGeminiTranscriptionModel } from "../../renderer/shared/voice-models.js";
import { parseDictationDictionary } from "../../renderer/shared/dictation-dictionary.js";
import { parseDictationPreferencePatch } from "../../renderer/shared/dictation-preferences.js";

const appearancePreview = new AppearancePreviewState();

Expand Down Expand Up @@ -518,8 +520,9 @@ export function registerProviderHandlers(): void {
if (typeof p.shortcutEnabled === "boolean") next.shortcutEnabled = p.shortcutEnabled;
if (typeof p.shortcutAccelerator === "string") next.shortcutAccelerator = p.shortcutAccelerator;
if (typeof p.dictationEnabled === "boolean") next.dictationEnabled = p.dictationEnabled;
if (typeof p.dictationHoldToTalk === "boolean")
next.dictationHoldToTalk = p.dictationHoldToTalk;
Object.assign(next, parseDictationPreferencePatch(p));
if (p.dictationDictionary !== undefined)
next.dictationDictionary = parseDictationDictionary(p.dictationDictionary);
if (typeof p.dictationSilenceStop === "boolean")
next.dictationSilenceStop = p.dictationSilenceStop;
if (typeof p.dictationCleanup === "boolean") next.dictationCleanup = p.dictationCleanup;
Expand Down Expand Up @@ -548,6 +551,10 @@ export function registerProviderHandlers(): void {
const saved = process.platform === "linux" && next.dictationHoldToTalk !== undefined
? await linuxHoldSettings.apply(next.dictationHoldToTalk, (isCurrent) => configStore.setSettings(next, isCurrent))
: await configStore.setSettings(next);
if (next.localVoiceIdleUnloadMinutes !== undefined) {
const { reconfigureParakeetIdleUnload } = await import("../services/parakeet.js");
void reconfigureParakeetIdleUnload();
}
if (next.skillsEnabled !== undefined) {
skillRegistry.invalidate();
invalidateBotRuntimeInventoryAuthority("skill_configuration");
Expand Down
33 changes: 33 additions & 0 deletions main/services/config-store-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,39 @@ test("reads and writes survive a restart of the whole store", async (t) => {
});
});

test("hand-edited dictation preferences reach consumers only in supported shapes", async (t) => {
const h = await harness(t);
await h.store.setSettings({ exaEnabled: true });
const file = await readJson<Record<string, unknown>>(h.settingsFile);
const settings = (file.settings ?? file) as Record<string, unknown>;
Object.assign(settings, {
dictationActivationMode: "double-tap",
localVoiceIdleUnloadMinutes: -5,
dictationDictionary: [
{ from: " aiden ", to: "Aiden" },
{ from: 42, to: "nope" },
{ from: "AIDEN", to: "duplicate" },
{ from: "pie", to: "Pi" },
],
});
await fs.writeFile(h.settingsFile, JSON.stringify(file, null, 2), "utf-8");

const restarted = createConfigStore(
createPortableConfigStores(
() => path.dirname(h.portableFile),
() => path.dirname(h.localFile),
),
fakeSecrets().port,
);
const runtime = await restarted.getSettings();
assert.equal(runtime.dictationActivationMode, undefined);
assert.equal(runtime.localVoiceIdleUnloadMinutes, undefined);
assert.deepEqual(runtime.dictationDictionary, [
{ from: "aiden", to: "Aiden" },
{ from: "pie", to: "Pi" },
]);
});

test("every install ends up with at least one workspace", async (t) => {
const h = await harness(t);
const workspaces = await h.store.listWorkspaces();
Expand Down
Loading
Loading