pi-voicekit 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -9
- package/extensions/voice/config.ts +155 -14
- package/extensions/voice/post-process-context.ts +185 -0
- package/extensions/voice/post-process-prompt.ts +95 -0
- package/extensions/voice/post-process.ts +308 -0
- package/extensions/voice/settings-panel.ts +265 -4
- package/extensions/voice.ts +703 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ speaks the agent's replies (Kitten, Kokoro, Piper, or Deepgram Aura).
|
|
|
21
21
|
> `PULSE_SERVER` is set (SSH audio tunnel / remote PulseAudio), so remote
|
|
22
22
|
> microphones record reliably. Voice in **and** voice out: 21 offline STT models,
|
|
23
23
|
> 20 local TTS voices plus Deepgram Aura, driven by one `/voice-settings` panel
|
|
24
|
-
> with
|
|
24
|
+
> with 6 tabs. The 0.1.x line is documented in the [changelog](CHANGELOG.md).
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
@@ -50,14 +50,14 @@ pi install npm:pi-voicekit
|
|
|
50
50
|
|
|
51
51
|
pi-voicekit supports two transcription backends:
|
|
52
52
|
|
|
53
|
-
| | Deepgram (cloud) | Local models (offline)
|
|
53
|
+
| | Deepgram (cloud) | Local models (offline recognition) |
|
|
54
54
|
| ---------------- | -------------------------------------------------------- | --------------------------------------------------- |
|
|
55
55
|
| **How it works** | Live streaming — text appears as you speak | Batch mode — transcribes after you finish recording |
|
|
56
56
|
| **Setup** | API key required | No API key, models auto-download on first use |
|
|
57
|
-
| **Internet** | Required | Not required after model download
|
|
57
|
+
| **Internet** | Required | Not required after model download for recognition; the polish step may use the network |
|
|
58
58
|
| **Latency** | Real-time interim results | 2–10 seconds after recording stops |
|
|
59
59
|
| **Languages** | 56+ with live streaming | Depends on model (1–57 languages) |
|
|
60
|
-
| **Cost** | $200 free credit (lasts 6–12 months for most developers) |
|
|
60
|
+
| **Cost** | $200 free credit (lasts 6–12 months for most developers) | Recognition is free; the polish step may cost money |
|
|
61
61
|
|
|
62
62
|
Run `/voice-settings` inside Pi to choose your backend and configure everything from one panel.
|
|
63
63
|
|
|
@@ -69,7 +69,7 @@ Sign up at [dpgr.am/pi-voice](https://dpgr.am/pi-voice) — $200 free credit, no
|
|
|
69
69
|
export DEEPGRAM_API_KEY="your-key-here" # add to ~/.zshrc or ~/.bashrc
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
#### Option B: Local models (
|
|
72
|
+
#### Option B: Local models (offline recognition)
|
|
73
73
|
|
|
74
74
|
No setup needed — run `/voice-settings`, switch backend to Local, and select a model. It downloads automatically.
|
|
75
75
|
|
|
@@ -100,7 +100,7 @@ pi-voicekit auto-detects your audio tool. No manual install needed if you alread
|
|
|
100
100
|
|
|
101
101
|
## Settings Panel
|
|
102
102
|
|
|
103
|
-
All configuration lives in one place: `/voice-settings`.
|
|
103
|
+
All configuration lives in one place: `/voice-settings`. Six tabs cover everything you need.
|
|
104
104
|
|
|
105
105
|
### General — backend, language, scope
|
|
106
106
|
|
|
@@ -132,6 +132,18 @@ agent replies is toggled here.
|
|
|
132
132
|
|
|
133
133
|
See your hardware profile (RAM, CPU, GPU), dependency status (sherpa-onnx runtime), available disk space, and total downloaded models. Model recommendations are based on this profile.
|
|
134
134
|
|
|
135
|
+
### Polish — transcript cleanup
|
|
136
|
+
|
|
137
|
+
Optional post-ASR cleanup, on by default. Toggle it, pick the model, set how many
|
|
138
|
+
recent conversation turns accompany the transcript (0–10), and cap how long one
|
|
139
|
+
pass may take (`1000`–`30000` ms). The last row shows the most recent polished
|
|
140
|
+
dictation as a `RAW` / `POLISHED` pair. `/voice-polish last` prints the newest
|
|
141
|
+
dictation a pass ran on — including one whose result was discarded — with its
|
|
142
|
+
`STATUS`, `RAW`, and `WRITTEN` text, or says that nothing was written.
|
|
143
|
+
|
|
144
|
+
`/voice-polish` takes `on`, `off`, `model`, `turns <0-10>`, `last` and `restore`;
|
|
145
|
+
run it with no argument for the current status.
|
|
146
|
+
|
|
135
147
|
---
|
|
136
148
|
|
|
137
149
|
## Usage
|
|
@@ -165,6 +177,7 @@ See your hardware profile (RAM, CPU, GPU), dependency status (sherpa-onnx runtim
|
|
|
165
177
|
| `/voice-stream` | Toggle Deepgram streaming TTS (cloud) |
|
|
166
178
|
| `/voice-speak-stop` | Stop in-flight TTS playback |
|
|
167
179
|
| `/voice-autosubmit` | Toggle: STT text auto-sent to the agent (`on`/`off`) |
|
|
180
|
+
| `/voice-polish [sub]` | Transcript polish: on, off, model, turns, last, restore |
|
|
168
181
|
| `/voice-hold-delay` | Set hold-to-talk delay (200-3000 ms, default 700) |
|
|
169
182
|
| `/voice-speak-models` | Browse / install TTS voice models |
|
|
170
183
|
| `/voice-speak-info` | Diagnose TTS state |
|
|
@@ -261,6 +274,7 @@ Models from [Handy](https://github.com/cjpais/handy) (`~/Library/Application Sup
|
|
|
261
274
|
| **Pre-recording** | Audio capture starts during warmup — you never miss the first word |
|
|
262
275
|
| **Tail recording** | Keeps recording 1.5s after release so your last word isn't clipped |
|
|
263
276
|
| **Live streaming** | Deepgram Nova 3 WebSocket (Nova 2 for Chinese locales) — live interim transcripts |
|
|
277
|
+
| **Transcript polish** | Optional post-ASR cleanup — every dictation makes one extra model call; the last N conversation turns (default 2) are sent with it, and no conversation context at all when the turn count is zero. Disable with `/voice-polish off` |
|
|
264
278
|
| **56+ languages** | Deepgram: 56+ with live streaming. Local: up to 57 depending on model. |
|
|
265
279
|
| **Continuous dictation** | `/voice dictate` for long-form input without holding keys |
|
|
266
280
|
| **Typing cooldown** | Space holds within 400ms of typing are ignored |
|
|
@@ -288,6 +302,11 @@ extensions/voice/sherpa-loader.ts Lazy native module loading
|
|
|
288
302
|
extensions/voice/model-download.ts Download manager — resume, progress, verification, Handy import
|
|
289
303
|
extensions/voice/device.ts Device profiling — RAM, GPU, CPU, container detection
|
|
290
304
|
|
|
305
|
+
# transcript post-processing
|
|
306
|
+
extensions/voice/post-process.ts Polish pass — fail-open guardrails, model resolution, bounded call
|
|
307
|
+
extensions/voice/post-process-context.ts Context assembly — recent turns and character caps
|
|
308
|
+
extensions/voice/post-process-prompt.ts Fixed polish prompt and request shape
|
|
309
|
+
|
|
291
310
|
# text-to-speech
|
|
292
311
|
extensions/voice/speak.ts Speak entry point, auto-speak wiring
|
|
293
312
|
extensions/voice/tts-engine.ts sherpa-onnx TTS synthesis
|
|
@@ -301,7 +320,7 @@ extensions/voice/tts-install-progress.ts Model install progress widget
|
|
|
301
320
|
extensions/voice/tts-playback-indicator.ts Speaking indicator widget
|
|
302
321
|
|
|
303
322
|
# settings and UI
|
|
304
|
-
extensions/voice/settings-panel.ts Settings panel — overlay,
|
|
323
|
+
extensions/voice/settings-panel.ts Settings panel — overlay, 6 tabs
|
|
305
324
|
extensions/voice/ui-picker.ts Generic list picker
|
|
306
325
|
extensions/voice/ui-help-overlay.ts Keyboard and command reference
|
|
307
326
|
extensions/voice/ui-aura.ts Visual primitives (Liquid Braille, Aurora)
|
|
@@ -329,13 +348,13 @@ Settings stored in Pi's settings files under the `voice` key:
|
|
|
329
348
|
```json
|
|
330
349
|
{
|
|
331
350
|
"voice": {
|
|
332
|
-
"version":
|
|
351
|
+
"version": 3,
|
|
333
352
|
"enabled": true,
|
|
334
353
|
"language": "en",
|
|
335
354
|
"backend": "local",
|
|
336
355
|
"localModel": "parakeet-v3",
|
|
337
356
|
"scope": "global",
|
|
338
|
-
"onboarding": { "completed": true, "schemaVersion":
|
|
357
|
+
"onboarding": { "completed": true, "schemaVersion": 3 }
|
|
339
358
|
}
|
|
340
359
|
}
|
|
341
360
|
```
|
|
@@ -346,6 +365,56 @@ an explicit save and it still goes to `~/.env.secrets` or `~/.zshrc`.
|
|
|
346
365
|
|
|
347
366
|
Hold-to-talk delay defaults to **700 ms** (`/voice-hold-delay` accepts 200–3000 ms).
|
|
348
367
|
|
|
368
|
+
### Transcript polish
|
|
369
|
+
|
|
370
|
+
Transcript polish is on by default: every dictation runs one extra model call. When
|
|
371
|
+
the selected model is a cloud provider, the text that leaves your machine is:
|
|
372
|
+
|
|
373
|
+
- the transcript of the dictation;
|
|
374
|
+
- the last N conversation turns of user and assistant text, where N is
|
|
375
|
+
`postProcessContextTurns` (default `2`; `0` sends no conversation context);
|
|
376
|
+
- nothing else. The compaction summary is deliberately not sent: it is a digest built from
|
|
377
|
+
earlier messages, so it can carry residues of thinking and tool output, and it measured no
|
|
378
|
+
gain over the turns alone.
|
|
379
|
+
|
|
380
|
+
One measured behaviour is worth knowing: a model that thinks before it answers may normalise a
|
|
381
|
+
spoken operator into its symbol — `select star` comes back as `select *`. The information is
|
|
382
|
+
unchanged, there is no setting for it, and `/voice-polish off` is the way to keep the words verbatim.
|
|
383
|
+
|
|
384
|
+
A reasoning model used to spend its whole token budget thinking about a long dictation, so the
|
|
385
|
+
answer was truncated and the pass kept the raw transcript — which looked like polish quietly
|
|
386
|
+
doing nothing past roughly half a minute of speech. Dictations longer than 200 characters now
|
|
387
|
+
turn thinking off (the same 309-character input went from 10.2 s to 1.2 s with the same
|
|
388
|
+
punctuation), while shorter ones keep it, because there it costs almost nothing and corrects
|
|
389
|
+
terms and self-corrections better. The field only reaches OpenAI-compatible providers; one that
|
|
390
|
+
ignores it behaves exactly as before.
|
|
391
|
+
|
|
392
|
+
Assistant text can contain anything the conversation contained — file paths,
|
|
393
|
+
identifiers, values the agent echoed. The character limits bound how much is sent,
|
|
394
|
+
not how sensitive it is. With the local backend, nothing else leaves your machine,
|
|
395
|
+
and audio never does: recognition runs on this machine with no API key. Turn the
|
|
396
|
+
feature off with `/voice-polish off` or the Polish tab's Enabled row.
|
|
397
|
+
|
|
398
|
+
Every dictation also writes one `voice-polish` entry into the session file: the raw
|
|
399
|
+
transcript, what reached the editor and why the pass decided that. The model never sees
|
|
400
|
+
these entries — they are not part of the conversation context — so they are there for
|
|
401
|
+
analysis, and they do keep the raw text on disk for as long as the session file exists.
|
|
402
|
+
|
|
403
|
+
| Setting | Scope | Default | Notes |
|
|
404
|
+
| ------------------------- | ------------------ | ----------- | ------------------------------------------------------- |
|
|
405
|
+
| `postProcessEnabled` | global only | `true` | Master switch. A project `voice` block cannot flip it. |
|
|
406
|
+
| `postProcessModel` | global only | `"session"` | Reuses the session model, or `provider/modelId`. |
|
|
407
|
+
| `postProcessContextTurns` | global and project | `2` | Conversation turns sent with the transcript, `0`–`10`. |
|
|
408
|
+
| `postProcessTimeoutMs` | global and project | `12000` | Per-pass timeout in milliseconds, `1000`–`30000`. |
|
|
409
|
+
|
|
410
|
+
The global-only fields resolve from `~/.pi/agent/settings.json` even when a
|
|
411
|
+
repository provides its own `voice` block, so a cloned repo can neither turn the
|
|
412
|
+
feature on nor redirect where dictated text goes. The model is chosen from a
|
|
413
|
+
picker (`/voice-polish model`), never typed: a hand-typed reference is refused,
|
|
414
|
+
and an unavailable or malformed model keeps the raw transcript instead of
|
|
415
|
+
switching provider. `postProcessNoticeShown` is machine-local bookkeeping for the
|
|
416
|
+
one-time notice, not a user setting.
|
|
417
|
+
|
|
349
418
|
---
|
|
350
419
|
|
|
351
420
|
## Troubleshooting
|
|
@@ -7,7 +7,7 @@ function getAgentDir(): string {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export const SETTINGS_KEY = "voice";
|
|
10
|
-
export const VOICE_CONFIG_VERSION =
|
|
10
|
+
export const VOICE_CONFIG_VERSION = 3;
|
|
11
11
|
|
|
12
12
|
export type VoiceSettingsScope = "global" | "project";
|
|
13
13
|
export type VoiceConfigSource = VoiceSettingsScope | "default";
|
|
@@ -40,6 +40,27 @@ export interface VoiceConfig {
|
|
|
40
40
|
/** Global-only shortcut used to toggle recording without hold-to-talk */
|
|
41
41
|
toggleShortcut?: string;
|
|
42
42
|
|
|
43
|
+
// ─── Post-processing (optional transcript polish) — new in v3 ─────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Master switch for the post-ASR polish pass. Global-only — the
|
|
47
|
+
* enablement decides whether dictated text makes an extra model call,
|
|
48
|
+
* so a project file must not be able to flip it.
|
|
49
|
+
*/
|
|
50
|
+
postProcessEnabled?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Model used by the polish pass — "session" reuses the active session
|
|
53
|
+
* model, or "<provider>/<modelId>". Global-only: the value decides
|
|
54
|
+
* where dictated text is sent.
|
|
55
|
+
*/
|
|
56
|
+
postProcessModel?: string;
|
|
57
|
+
/** How many recent conversation turns accompany the transcript. Honoured in both scopes; clamped to [0, 10]. */
|
|
58
|
+
postProcessContextTurns?: number;
|
|
59
|
+
/** Upper bound in milliseconds for one polish pass. Honoured in both scopes; clamped to [1000, 30000]. */
|
|
60
|
+
postProcessTimeoutMs?: number;
|
|
61
|
+
/** Set once the one-time default-on notice has been shown. Global-only — it describes this machine, not the repository. */
|
|
62
|
+
postProcessNoticeShown?: boolean;
|
|
63
|
+
|
|
43
64
|
// ─── TTS (text-to-speech) ─────────────────────────────────────────
|
|
44
65
|
// All TTS fields are opt-in (default: TTS disabled). New in v6.0.0.
|
|
45
66
|
|
|
@@ -125,6 +146,12 @@ export const DEFAULT_CONFIG: VoiceConfig = {
|
|
|
125
146
|
localModel: undefined,
|
|
126
147
|
localEndpoint: undefined,
|
|
127
148
|
toggleShortcut: "ctrl+shift+v",
|
|
149
|
+
// Post-processing defaults — on by default (D5), reusing the session model
|
|
150
|
+
postProcessEnabled: true,
|
|
151
|
+
postProcessModel: "session",
|
|
152
|
+
postProcessContextTurns: 2,
|
|
153
|
+
postProcessTimeoutMs: 12000,
|
|
154
|
+
postProcessNoticeShown: false,
|
|
128
155
|
// TTS defaults — all opt-in
|
|
129
156
|
ttsEnabled: false,
|
|
130
157
|
ttsBackend: "local",
|
|
@@ -176,11 +203,46 @@ function normalizeOnboarding(input: any, fallbackCompleted: boolean): VoiceOnboa
|
|
|
176
203
|
};
|
|
177
204
|
}
|
|
178
205
|
|
|
179
|
-
|
|
206
|
+
/** Clamp an integer config value; non-numeric or non-finite input takes the default. */
|
|
207
|
+
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
|
208
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) return fallback;
|
|
209
|
+
return Math.max(min, Math.min(max, value));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function migrateConfig(rawVoice: any, source: VoiceConfigSource, globalVoice?: unknown): VoiceConfig {
|
|
180
213
|
if (!rawVoice || typeof rawVoice !== "object") {
|
|
181
214
|
return structuredClone(DEFAULT_CONFIG);
|
|
182
215
|
}
|
|
183
216
|
|
|
217
|
+
// D7: model selection, enablement and the notice flag are global-only, and a
|
|
218
|
+
// project file must not be able to inject an API key or point audio at a
|
|
219
|
+
// non-loopback host. These fields resolve from the global block in BOTH scopes —
|
|
220
|
+
// falling back to DEFAULT_CONFIG would let a cloned repository re-enable a
|
|
221
|
+
// feature the maintainer turned off globally (spec §4.2).
|
|
222
|
+
const projectScoped = source === "project";
|
|
223
|
+
const globalRaw: Record<string, unknown> =
|
|
224
|
+
globalVoice && typeof globalVoice === "object" ? (globalVoice as Record<string, unknown>) : {};
|
|
225
|
+
const globalOnly = (key: string): unknown => (projectScoped ? globalRaw[key] : rawVoice[key]);
|
|
226
|
+
const asString = (value: unknown): string | undefined => (typeof value === "string" && value ? value : undefined);
|
|
227
|
+
const asBoolean = (value: unknown, fallbackValue: boolean): boolean =>
|
|
228
|
+
typeof value === "boolean" ? value : fallbackValue;
|
|
229
|
+
|
|
230
|
+
for (const key of ["postProcessEnabled", "postProcessModel", "deepgramApiKey", "localEndpoint"]) {
|
|
231
|
+
if (!projectScoped || rawVoice[key] === undefined) continue;
|
|
232
|
+
// A loopback project endpoint is honoured, not ignored — reporting it would
|
|
233
|
+
// cry wolf on the safe case and weaken the signal for the discarded ones.
|
|
234
|
+
if (
|
|
235
|
+
key === "localEndpoint" &&
|
|
236
|
+
typeof rawVoice.localEndpoint === "string" &&
|
|
237
|
+
isLoopbackEndpoint(rawVoice.localEndpoint)
|
|
238
|
+
) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
// Never print a key, even one that is being ignored.
|
|
242
|
+
const shown = key === "deepgramApiKey" ? "<redacted>" : JSON.stringify(rawVoice[key]);
|
|
243
|
+
process.stderr.write(`[pi-voicekit] Ignoring project-scoped voice.${key} (${shown}); using the global value\n`);
|
|
244
|
+
}
|
|
245
|
+
|
|
184
246
|
// Legacy configs may have backend+model — treat that as completed onboarding
|
|
185
247
|
const hasMeaningfulLegacySetup =
|
|
186
248
|
(typeof rawVoice.backend === "string" && typeof rawVoice.model === "string") ||
|
|
@@ -192,14 +254,26 @@ function migrateConfig(rawVoice: any, source: VoiceConfigSource): VoiceConfig {
|
|
|
192
254
|
enabled: typeof rawVoice.enabled === "boolean" ? rawVoice.enabled : DEFAULT_CONFIG.enabled,
|
|
193
255
|
language: typeof rawVoice.language === "string" ? rawVoice.language : DEFAULT_CONFIG.language,
|
|
194
256
|
scope: (rawVoice.scope as VoiceSettingsScope | undefined) ?? (source === "project" ? "project" : "global"),
|
|
195
|
-
deepgramApiKey:
|
|
257
|
+
deepgramApiKey: asString(globalOnly("deepgramApiKey")),
|
|
196
258
|
backend: rawVoice.backend === "local" ? "local" : undefined,
|
|
197
259
|
localModel: typeof rawVoice.localModel === "string" ? rawVoice.localModel : undefined,
|
|
198
|
-
localEndpoint:
|
|
260
|
+
localEndpoint: projectScoped
|
|
261
|
+
? typeof rawVoice.localEndpoint === "string" && isLoopbackEndpoint(rawVoice.localEndpoint)
|
|
262
|
+
? rawVoice.localEndpoint
|
|
263
|
+
: asString(globalRaw.localEndpoint)
|
|
264
|
+
: asString(rawVoice.localEndpoint),
|
|
199
265
|
toggleShortcut:
|
|
200
266
|
source !== "project" && typeof rawVoice.toggleShortcut === "string"
|
|
201
267
|
? rawVoice.toggleShortcut
|
|
202
268
|
: DEFAULT_CONFIG.toggleShortcut,
|
|
269
|
+
// Post-processing fields (v3). Model selection, enablement and the
|
|
270
|
+
// notice flag resolve through `globalOnly`; the two numeric knobs are
|
|
271
|
+
// honoured in both scopes.
|
|
272
|
+
postProcessEnabled: asBoolean(globalOnly("postProcessEnabled"), DEFAULT_CONFIG.postProcessEnabled ?? true),
|
|
273
|
+
postProcessModel: asString(globalOnly("postProcessModel")) ?? DEFAULT_CONFIG.postProcessModel,
|
|
274
|
+
postProcessContextTurns: clampInt(rawVoice.postProcessContextTurns, 0, 10, DEFAULT_CONFIG.postProcessContextTurns!),
|
|
275
|
+
postProcessTimeoutMs: clampInt(rawVoice.postProcessTimeoutMs, 1000, 30000, DEFAULT_CONFIG.postProcessTimeoutMs!),
|
|
276
|
+
postProcessNoticeShown: asBoolean(globalOnly("postProcessNoticeShown"), false),
|
|
203
277
|
// TTS fields — type-validated; mismatched persisted values fall
|
|
204
278
|
// back to safe defaults so a hand-edited config can't poison the
|
|
205
279
|
// engine. Notably: ttsLocalVoiceId rejects strings (would crash
|
|
@@ -251,7 +325,7 @@ export function loadConfigWithSource(cwd: string, options: ConfigPathOptions = {
|
|
|
251
325
|
|
|
252
326
|
if (projectVoice && typeof projectVoice === "object") {
|
|
253
327
|
return {
|
|
254
|
-
config: migrateConfig(projectVoice, "project"),
|
|
328
|
+
config: migrateConfig(projectVoice, "project", globalVoice),
|
|
255
329
|
source: "project",
|
|
256
330
|
globalSettingsPath,
|
|
257
331
|
projectSettingsPath,
|
|
@@ -353,6 +427,11 @@ function serializeConfig(config: VoiceConfig, scope: VoiceSettingsScope): VoiceC
|
|
|
353
427
|
scope === "project" && config.localEndpoint && !isLoopbackEndpoint(config.localEndpoint)
|
|
354
428
|
? undefined
|
|
355
429
|
: config.localEndpoint,
|
|
430
|
+
// D7: model selection and enablement are global-only, and the notice flag
|
|
431
|
+
// describes this machine, not this repository.
|
|
432
|
+
postProcessEnabled: scope === "project" ? undefined : config.postProcessEnabled,
|
|
433
|
+
postProcessModel: scope === "project" ? undefined : config.postProcessModel,
|
|
434
|
+
postProcessNoticeShown: scope === "project" ? undefined : config.postProcessNoticeShown,
|
|
356
435
|
// Shortcut registration is static at extension load time — project-scoped overrides cannot apply
|
|
357
436
|
toggleShortcut: scope === "project" ? undefined : config.toggleShortcut,
|
|
358
437
|
onboarding: {
|
|
@@ -362,6 +441,23 @@ function serializeConfig(config: VoiceConfig, scope: VoiceSettingsScope): VoiceC
|
|
|
362
441
|
};
|
|
363
442
|
}
|
|
364
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Atomic settings write: temp file + rename prevents corruption from partial
|
|
446
|
+
* writes. Shared by every writer in this module so the path cannot diverge.
|
|
447
|
+
*/
|
|
448
|
+
function writeSettingsFile(settingsPath: string, settings: Record<string, unknown>): void {
|
|
449
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
450
|
+
const tmpPath = `${settingsPath}.${process.pid}.tmp`;
|
|
451
|
+
try {
|
|
452
|
+
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + "\n");
|
|
453
|
+
fs.renameSync(tmpPath, settingsPath);
|
|
454
|
+
} finally {
|
|
455
|
+
try {
|
|
456
|
+
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
|
457
|
+
} catch {}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
365
461
|
export function saveConfig(
|
|
366
462
|
config: VoiceConfig,
|
|
367
463
|
scope: VoiceSettingsScope,
|
|
@@ -371,17 +467,62 @@ export function saveConfig(
|
|
|
371
467
|
const settingsPath = scope === "project" ? getProjectSettingsPath(cwd) : getGlobalSettingsPath(options);
|
|
372
468
|
const settings = readJsonFile(settingsPath);
|
|
373
469
|
settings[SETTINGS_KEY] = serializeConfig(config, scope);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
470
|
+
writeSettingsFile(settingsPath, settings);
|
|
471
|
+
return settingsPath;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The global-only keys `saveGlobalVoiceFields` accepts. A project block cannot
|
|
476
|
+
* carry them — `serializeConfig` strips them and the loader ignores them — so
|
|
477
|
+
* they always belong in the global file.
|
|
478
|
+
*/
|
|
479
|
+
type GlobalVoiceFieldKey = "postProcessEnabled" | "postProcessModel" | "postProcessNoticeShown";
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Field-level writer for the global-only voice settings (R26).
|
|
483
|
+
*
|
|
484
|
+
* `saveConfig(config, "global", …)` renders the WHOLE in-memory config, and in a
|
|
485
|
+
* project-scoped session that object also carries project and default values —
|
|
486
|
+
* writing it globally silently resets every unrelated machine-global setting
|
|
487
|
+
* (TTS speed, auto-submit, hold threshold, …). This writer instead reads the
|
|
488
|
+
* existing global file and merges only the named keys into its `voice` block:
|
|
489
|
+
*
|
|
490
|
+
* - an existing `version` is preserved; a block created here gets the current
|
|
491
|
+
* schema version,
|
|
492
|
+
* - no other key is created, changed or removed,
|
|
493
|
+
* - a missing file or `voice` block is created,
|
|
494
|
+
* - a file that exists but cannot be parsed is refused, not overwritten,
|
|
495
|
+
* - the write is atomic (temp file + rename), like `saveConfig`.
|
|
496
|
+
*/
|
|
497
|
+
export function saveGlobalVoiceFields(
|
|
498
|
+
fields: Partial<Pick<VoiceConfig, GlobalVoiceFieldKey>>,
|
|
499
|
+
options: ConfigPathOptions = {}
|
|
500
|
+
): string {
|
|
501
|
+
const settingsPath = getGlobalSettingsPath(options);
|
|
502
|
+
// The shared reader reports a file it cannot parse exactly like a missing one, so this
|
|
503
|
+
// writer has to tell them apart itself: merging into `{}` would replace a damaged
|
|
504
|
+
// settings file with a fresh object and lose every other key. An existing file that
|
|
505
|
+
// cannot be read is logged and left untouched; throwing hands the failure to the
|
|
506
|
+
// caller's guard instead of silently reporting a write that never happened.
|
|
507
|
+
let settings: Record<string, unknown> = {};
|
|
508
|
+
if (fs.existsSync(settingsPath)) {
|
|
381
509
|
try {
|
|
382
|
-
|
|
383
|
-
} catch {
|
|
510
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
511
|
+
} catch (err) {
|
|
512
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
513
|
+
process.stderr.write(`[pi-voicekit] Warning: not writing ${settingsPath}: ${reason}\n`);
|
|
514
|
+
throw new Error(`Refusing to overwrite an unreadable settings file: ${settingsPath}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const existing = settings[SETTINGS_KEY];
|
|
518
|
+
const voice: Record<string, unknown> =
|
|
519
|
+
existing && typeof existing === "object" ? { ...(existing as Record<string, unknown>) } : {};
|
|
520
|
+
if (typeof voice.version !== "number") voice.version = VOICE_CONFIG_VERSION;
|
|
521
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
522
|
+
if (value !== undefined) voice[key] = value;
|
|
384
523
|
}
|
|
524
|
+
settings[SETTINGS_KEY] = voice;
|
|
525
|
+
writeSettingsFile(settingsPath, settings);
|
|
385
526
|
return settingsPath;
|
|
386
527
|
}
|
|
387
528
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript post-processing: conversation-context assembly.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only — no Pi API, no I/O — so the whole module is testable
|
|
5
|
+
* offline. The caller passes the compaction-aware projection returned by
|
|
6
|
+
* `ctx.sessionManager.buildContextEntries()`, which is why `EntryLike` is
|
|
7
|
+
* structural rather than imported: this package does not depend on pi-ai.
|
|
8
|
+
*
|
|
9
|
+
* Spec: docs/superpowers/specs/2026-09-26-stt-post-processing-design.md §4.3
|
|
10
|
+
* (a local design record, not part of the published package)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface EntryLike {
|
|
14
|
+
type?: string;
|
|
15
|
+
message?: { role?: string; content?: unknown };
|
|
16
|
+
summary?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ContextLimits {
|
|
20
|
+
/** User turns to keep, counted from the newest. 0 disables context entirely. */
|
|
21
|
+
turns: number;
|
|
22
|
+
/** Per-entry character cap on the text as returned, elision marker included. */
|
|
23
|
+
perEntryChars: number;
|
|
24
|
+
/** Total character cap across the turns that are kept. */
|
|
25
|
+
totalChars: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ContextTurn {
|
|
29
|
+
role: "user" | "assistant";
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AssembledContext {
|
|
34
|
+
turns: ContextTurn[];
|
|
35
|
+
/** Characters of the text as returned, elision markers included; never above totalChars. */
|
|
36
|
+
characters: number;
|
|
37
|
+
/** True when a cap dropped or shortened something. */
|
|
38
|
+
truncated: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const DEFAULT_CONTEXT_LIMITS: ContextLimits = { turns: 2, perEntryChars: 500, totalChars: 4000 };
|
|
42
|
+
|
|
43
|
+
const ELLIPSIS = "…";
|
|
44
|
+
|
|
45
|
+
/** Extract only the text this feature is allowed to see from one entry's content. */
|
|
46
|
+
function extractText(content: unknown): string {
|
|
47
|
+
if (typeof content === "string") return content;
|
|
48
|
+
if (!Array.isArray(content)) return "";
|
|
49
|
+
const parts: string[] = [];
|
|
50
|
+
for (const part of content) {
|
|
51
|
+
if (!part || typeof part !== "object") continue;
|
|
52
|
+
const block = part as { type?: string; text?: string };
|
|
53
|
+
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
54
|
+
}
|
|
55
|
+
return parts.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Keep the head of a long entry: terms are usually introduced early. */
|
|
59
|
+
function truncateHead(text: string, limit: number): { text: string; truncated: boolean } {
|
|
60
|
+
const trimmed = text.trim();
|
|
61
|
+
const cap = Math.floor(limit);
|
|
62
|
+
if (cap <= 0) return { text: "", truncated: trimmed.length > 0 };
|
|
63
|
+
if (trimmed.length <= cap) return { text: trimmed, truncated: false };
|
|
64
|
+
// R11: the elision marker is part of the cap, so cut one code unit short of it.
|
|
65
|
+
let cut = cap - 1;
|
|
66
|
+
// R12: never end the kept text on a high surrogate — a split pair reaches the
|
|
67
|
+
// prompt as U+FFFD once the text is encoded to UTF-8.
|
|
68
|
+
if (cut > 0 && isHighSurrogate(trimmed.charCodeAt(cut - 1))) cut -= 1;
|
|
69
|
+
return { text: trimmed.slice(0, cut) + ELLIPSIS, truncated: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isHighSurrogate(code: number): boolean {
|
|
73
|
+
return code >= 0xd800 && code <= 0xdbff;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* R10: floor and clamp the turn limit locally. A non-integer value would produce
|
|
78
|
+
* a fractional slice index, and `slice(undefined)` means `slice(0)` — the limit
|
|
79
|
+
* would silently vanish and the whole history would be kept. Non-finite input is
|
|
80
|
+
* malformed and fails closed to no context.
|
|
81
|
+
*/
|
|
82
|
+
function resolveTurns(turns: number): number {
|
|
83
|
+
return Number.isFinite(turns) ? Math.max(0, Math.floor(turns)) : 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toUnits(entries: readonly EntryLike[]): ContextTurn[] {
|
|
87
|
+
const units: ContextTurn[] = [];
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (!entry || typeof entry !== "object") continue;
|
|
90
|
+
if (entry.type === "compaction") {
|
|
91
|
+
// A compaction entry is skipped outright: the digest it carries can hold residues
|
|
92
|
+
// of thinking and tool output, and it measured no gain over the turns alone.
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (entry.type !== "message" || !entry.message) continue;
|
|
96
|
+
const role = entry.message.role;
|
|
97
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
98
|
+
const text = extractText(entry.message.content).trim();
|
|
99
|
+
// Item 7: a user message always opens a turn — an image-only one consumes a turn
|
|
100
|
+
// even though it contributes no text. Assistant text with nothing in it
|
|
101
|
+
// contributes nothing at all.
|
|
102
|
+
if (role === "user" || text) units.push({ role, text });
|
|
103
|
+
}
|
|
104
|
+
return units;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A turn is one user entry plus every following non-user entry (spec §4.3 item 3). */
|
|
108
|
+
function groupTurns(units: readonly ContextTurn[]): ContextTurn[][] {
|
|
109
|
+
const turns: ContextTurn[][] = [];
|
|
110
|
+
for (const unit of units) {
|
|
111
|
+
if (unit.role === "user" || turns.length === 0) turns.push([]);
|
|
112
|
+
turns[turns.length - 1]!.push(unit);
|
|
113
|
+
}
|
|
114
|
+
return turns;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function turnSize(turn: readonly ContextTurn[]): number {
|
|
118
|
+
return turn.reduce((sum, entry) => sum + entry.text.length, 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* R9: the newest turn is never dropped. When it alone exceeds the budget, keep
|
|
123
|
+
* its head — entries in order, the first one (the user entry) included — and
|
|
124
|
+
* elide the tail so the total still fits.
|
|
125
|
+
*/
|
|
126
|
+
function fitTurnToBudget(turn: readonly ContextTurn[], budget: number): ContextTurn[] {
|
|
127
|
+
const fitted: ContextTurn[] = [];
|
|
128
|
+
let remaining = budget;
|
|
129
|
+
for (const entry of turn) {
|
|
130
|
+
// Under one character left, nothing usable fits — an empty entry is not kept.
|
|
131
|
+
if (remaining < 1) break;
|
|
132
|
+
const capped = truncateHead(entry.text, remaining);
|
|
133
|
+
fitted.push({ role: entry.role, text: capped.text });
|
|
134
|
+
remaining -= capped.text.length;
|
|
135
|
+
if (capped.truncated) break;
|
|
136
|
+
}
|
|
137
|
+
return fitted;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function assembleContext(entries: readonly EntryLike[], limits: ContextLimits): AssembledContext {
|
|
141
|
+
const units = toUnits(entries);
|
|
142
|
+
// R10: resolve the turn limit once, locally — see resolveTurns.
|
|
143
|
+
const turns = resolveTurns(limits.turns);
|
|
144
|
+
let truncated = false;
|
|
145
|
+
|
|
146
|
+
let start = units.length;
|
|
147
|
+
if (turns > 0) {
|
|
148
|
+
const userIndexes = units.map((unit, index) => (unit.role === "user" ? index : -1)).filter((index) => index >= 0);
|
|
149
|
+
start = userIndexes.length > turns ? userIndexes[userIndexes.length - turns]! : 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const selected = units.slice(start).map((unit) => {
|
|
153
|
+
const capped = truncateHead(unit.text, limits.perEntryChars);
|
|
154
|
+
truncated = truncated || capped.truncated;
|
|
155
|
+
return { role: unit.role, text: capped.text };
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// R8 + R9: one shared budget, spent on whole turns from the oldest first.
|
|
159
|
+
// Dropping single entries could keep an assistant reply whose user entry is
|
|
160
|
+
// gone, which is not a turn (spec §4.3 item 3). Item 7: grouping is also what
|
|
161
|
+
// counts the turns, so it happens before the empty entries are dropped — inside
|
|
162
|
+
// a turn only the sendable text reaches the prompt.
|
|
163
|
+
const groups = groupTurns(selected).map((turn) => turn.filter((unit) => unit.text));
|
|
164
|
+
const sizes = groups.map(turnSize);
|
|
165
|
+
let first = 0;
|
|
166
|
+
let characters = sizes.reduce((sum, size) => sum + size, 0);
|
|
167
|
+
while (groups.length - first > 1 && characters > limits.totalChars) {
|
|
168
|
+
characters -= sizes[first]!;
|
|
169
|
+
first += 1;
|
|
170
|
+
truncated = true;
|
|
171
|
+
}
|
|
172
|
+
let keptTurns = groups.slice(first);
|
|
173
|
+
if (keptTurns.length === 1 && characters > limits.totalChars) {
|
|
174
|
+
keptTurns = [fitTurnToBudget(keptTurns[0]!, limits.totalChars)];
|
|
175
|
+
characters = turnSize(keptTurns[0]!);
|
|
176
|
+
truncated = true;
|
|
177
|
+
}
|
|
178
|
+
const kept = keptTurns.flat();
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
turns: kept,
|
|
182
|
+
characters,
|
|
183
|
+
truncated,
|
|
184
|
+
};
|
|
185
|
+
}
|