pi-voicekit 0.2.2 → 0.3.0

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.
@@ -432,33 +432,57 @@ export function segmentPcmForLongAudio(samples: Float32Array, sampleRate: number
432
432
  return segments.length > 0 ? segments : [samples];
433
433
  }
434
434
 
435
+ /**
436
+ * Decode VAD segments in order, calling `onSegment(text, index)` as soon as a segment's text
437
+ * is available and before the next segment starts decoding. Returns the non-empty trimmed
438
+ * texts in order — the parts `transcribeBufferSegmented` joins.
439
+ *
440
+ * The recognizer is already a parameter, so this seam can be driven with a stub in tests.
441
+ * `onSegment` is observational: a throwing callback is swallowed, because an observer must
442
+ * never cost the user their transcript.
443
+ */
444
+ export async function decodeSegmentsInOrder(
445
+ recognizer: SherpaRecognizer,
446
+ segments: readonly Float32Array[],
447
+ onSegment?: (text: string, index: number) => void
448
+ ): Promise<string[]> {
449
+ const parts: string[] = [];
450
+ for (const [index, segment] of segments.entries()) {
451
+ const stream = recognizer.createStream();
452
+ stream.acceptWaveform({ sampleRate: 16000, samples: segment });
453
+ await recognizer.decodeAsync(stream);
454
+ const r = recognizer.getResult(stream);
455
+ const t = (r?.text || "").trim();
456
+ if (onSegment) {
457
+ try {
458
+ onSegment(t, index);
459
+ } catch {}
460
+ }
461
+ if (t) parts.push(t);
462
+ }
463
+ return parts;
464
+ }
465
+
435
466
  /**
436
467
  * Transcribe PCM with automatic VAD segmentation for long recordings.
437
468
  * Byte-identical fast path (single decode) for audio ≤ thresholdSecs.
469
+ *
470
+ * `onSegment` is called for every decoded segment — the fast path's single segment included —
471
+ * as soon as it decodes and before the next decode starts. Leaving it out keeps today's exact
472
+ * return value (docs/superpowers/specs/2026-09-26-polish-pipeline-design.md §4.1).
438
473
  */
439
474
  export async function transcribeBufferSegmented(
440
475
  pcmData: Buffer,
441
476
  recognizer: SherpaRecognizer,
442
- thresholdSecs = 10
477
+ thresholdSecs = 10,
478
+ onSegment?: (text: string, index: number) => void
443
479
  ): Promise<string> {
444
480
  getSherpaModule();
445
481
  const samples = pcmToFloat32(pcmData);
482
+ // The fast path is one segment too, so a pipelining caller sees exactly one segment and a
483
+ // short dictation keeps today's one-call behaviour.
446
484
  if (samples.length / 16000 <= thresholdSecs) {
447
- const stream = recognizer.createStream();
448
- stream.acceptWaveform({ sampleRate: 16000, samples });
449
- await recognizer.decodeAsync(stream);
450
- const r = recognizer.getResult(stream);
451
- return (r?.text || "").trim();
452
- }
453
-
454
- const parts: string[] = [];
455
- for (const seg of segmentPcmForLongAudio(samples, 16000)) {
456
- const stream = recognizer.createStream();
457
- stream.acceptWaveform({ sampleRate: 16000, samples: seg });
458
- await recognizer.decodeAsync(stream);
459
- const r = recognizer.getResult(stream);
460
- const t = (r?.text || "").trim();
461
- if (t) parts.push(t);
485
+ return (await decodeSegmentsInOrder(recognizer, [samples], onSegment)).join(" ");
462
486
  }
463
- return parts.join(" ");
487
+ return (await decodeSegmentsInOrder(recognizer, segmentPcmForLongAudio(samples, 16000), onSegment)).join(" ");
464
488
  }
@@ -94,6 +94,7 @@ import {
94
94
  checkLocalServer,
95
95
  LOCAL_MODELS,
96
96
  DEFAULT_LOCAL_ENDPOINT,
97
+ DEFAULT_LOCAL_MODEL,
97
98
  getLanguagesForLocalModel,
98
99
  isLanguageSupportedByModel,
99
100
  localLanguageDisplayName,
@@ -112,8 +113,11 @@ import {
112
113
  polishModelOptions,
113
114
  polishTranscript,
114
115
  resolveModelChoice,
116
+ type EditorRead,
117
+ type PolishAuditSegments,
115
118
  } from "./voice/post-process";
116
- import { DEFAULT_CONTEXT_LIMITS } from "./voice/post-process-context";
119
+ import { createPolishQueue, type PolishQueue } from "./voice/post-process-queue";
120
+ import { DEFAULT_CONTEXT_LIMITS, type EntryLike } from "./voice/post-process-context";
117
121
  import { polishMaxTokens } from "./voice/post-process-prompt";
118
122
 
119
123
  /** Adapter for the real event loop — lets GapTimer run under the real setTimeout. */
@@ -774,7 +778,14 @@ export default function (pi: ExtensionAPI) {
774
778
  // ─── Transcript polish (post-processing) ─────────────────────────────────
775
779
  // One bounded pass between the final transcript and the editor write. The
776
780
  // token below is what makes a late result inert (spec §4.1.1).
777
- type PolishPassToken = { invalidated: "discard" | "abort" | null };
781
+ /**
782
+ * Why the pending pass lost ownership:
783
+ * - `discard` — the result must not be used, and the editor keeps the user's text.
784
+ * - `abort` — a newer recording or session owns the flow; touch nothing.
785
+ * - `raw` — `discard`'s ownership rules, but the pass may still write the recogniser's
786
+ * text (used when the user turns polish off mid-dictation).
787
+ */
788
+ type PolishPassToken = { invalidated: "discard" | "abort" | "raw" | null };
778
789
  let activePolishPass: PolishPassToken | null = null;
779
790
  /**
780
791
  * Editor value the pending pass started from, or null when no pass is pending. The
@@ -783,20 +794,86 @@ export default function (pi: ExtensionAPI) {
783
794
  */
784
795
  let polishPassEditorSnapshot: string | null = null;
785
796
 
786
- function invalidatePolishPass(reason: string, cleanup: "complete" | "relinquish" = "relinquish"): void {
797
+ function invalidatePolishPass(reason: string, cleanup: "complete" | "relinquish" | "raw" = "relinquish"): void {
787
798
  const pass = activePolishPass;
788
799
  if (!pass) return;
789
- const disposition = cleanup === "complete" ? "discard" : "abort";
800
+ const disposition = cleanup === "complete" ? "discard" : cleanup === "raw" ? "raw" : "abort";
790
801
  if (pass.invalidated !== disposition) voiceDebug("polish pass invalidated", { reason });
791
802
  pass.invalidated = disposition;
792
- // Retain a discarded pass until its tail completes, or a later teardown takes
793
- // ownership. The awaiting callback retains this token even after relinquishing.
803
+ // Retain a discarded or raw pass until its tail completes, or a later teardown takes
804
+ // ownership: `raw` still has to decide the write and record the audit. The awaiting
805
+ // callback retains this token even after relinquishing.
794
806
  if (cleanup === "relinquish") {
795
807
  activePolishPass = null;
796
808
  polishPassEditorSnapshot = null;
797
809
  }
798
810
  }
799
811
 
812
+ /**
813
+ * One local dictation's segmented pass: the token every segment shares, the bounded queue
814
+ * and the request facts the audit entry summarises. Created on the first recogniser
815
+ * segment, so a dictation with no segments never departs from the single-call behaviour.
816
+ */
817
+ interface PolishQueuePass {
818
+ token: PolishPassToken;
819
+ queue: PolishQueue;
820
+ /** Wall clock when the first segment's polish work started. */
821
+ startedAt: number;
822
+ /** The model that actually ran, as `provider/id`, for telemetry. */
823
+ modelLabel: string;
824
+ configured: string;
825
+ /** What the requests carried; the queue decides sampling per segment. */
826
+ stats: { thinkingOff: boolean; maxTokens?: number };
827
+ /**
828
+ * The editor when the pass was created. The final write compares against this snapshot
829
+ * only — a fresh read at the end would mistake text the user typed while later segments
830
+ * were still being recognised for an unchanged editor and overwrite it.
831
+ */
832
+ editorSnapshot: string | typeof EDITOR_READ_FAILED;
833
+ /** The UI captured with the pass, so a reassigned `ctx` cannot retarget its writes. */
834
+ ui: ExtensionContext["ui"];
835
+ }
836
+
837
+ /**
838
+ * D5 one-time disclosure and the model choice, shared by the single-call pass and the
839
+ * segment queue. The notice fires before any transcript can leave, and resolution never
840
+ * falls back (D8). The caller owns the failure notice, because only the caller knows
841
+ * whether the dictation still has a write path.
842
+ */
843
+ function resolvePolishModel(): ReturnType<typeof resolveModelChoice> {
844
+ if (!config.postProcessNoticeShown && ctx?.hasUI) {
845
+ // D5: one-time disclosure. The flag is set in memory first (an assignment cannot
846
+ // throw); the write and the notification then sit in guards of their own. A
847
+ // read-only config directory must cost the flag's persistence, never the
848
+ // disclosure — the in-memory flag still suppresses a repeat in this process.
849
+ config.postProcessNoticeShown = true;
850
+ try {
851
+ // R26: field-level global write — `config` also carries this project's values, so
852
+ // a whole-block write would reset unrelated machine-global settings. Persist the
853
+ // flag BEFORE notifying, per the house rule in tts-onboarding.
854
+ saveGlobalVoiceFields({ postProcessNoticeShown: true });
855
+ } catch (error) {
856
+ voiceDebug("polish notice setting write failed", String(error));
857
+ }
858
+ try {
859
+ const turns = config.postProcessContextTurns ?? DEFAULT_CONTEXT_LIMITS.turns;
860
+ ctx.ui.notify(
861
+ [
862
+ "Voice polish is on: every dictation makes one extra model call,",
863
+ turns > 0
864
+ ? `and the last ${turns} conversation turns are sent with it.`
865
+ : "and no conversation context is sent with it.",
866
+ "Turn it off with /voice-polish off.",
867
+ ].join(" "),
868
+ "info"
869
+ );
870
+ } catch (error) {
871
+ voiceDebug("polish notice notification failed", String(error));
872
+ }
873
+ }
874
+ return resolveModelChoice(parseModelRef(config.postProcessModel), polishModelLookup, ctx?.model);
875
+ }
876
+
800
877
  /**
801
878
  * Ownership reads go through here: a throwing editor read is a failed read and
802
879
  * never an unchanged editor (spec invariant 2).
@@ -887,48 +964,20 @@ export default function (pi: ExtensionAPI) {
887
964
  /** Recorded so a slow or truncated pass can be diagnosed without reading code. */
888
965
  thinkingOff?: boolean;
889
966
  maxTokens?: number;
967
+ /** Per-segment outcome when the queue produced this dictation; absent for a single call. */
968
+ segments?: PolishAuditSegments;
890
969
  reason?: string;
891
970
  error?: string;
892
971
  };
893
- async function runPolishPass(raw: string, editorSnapshot: string): Promise<PolishOutcome> {
972
+ async function runPolishPass(raw: string, editorSnapshot: EditorRead): Promise<PolishOutcome> {
894
973
  const id: PolishPassToken = { invalidated: null };
895
974
  activePolishPass = id;
896
- polishPassEditorSnapshot = editorSnapshot;
897
- if (!config.postProcessNoticeShown && ctx?.hasUI) {
898
- // D5: one-time disclosure. The flag is set in memory first (an assignment cannot
899
- // throw); the write and the notification then sit in guards of their own. A
900
- // read-only config directory must cost the flag's persistence, never the
901
- // disclosure — the in-memory flag still suppresses a repeat in this process.
902
- config.postProcessNoticeShown = true;
903
- try {
904
- // R26: field-level global write — `config` also carries this project's values, so
905
- // a whole-block write would reset unrelated machine-global settings. Persist the
906
- // flag BEFORE notifying, per the house rule in tts-onboarding.
907
- saveGlobalVoiceFields({ postProcessNoticeShown: true });
908
- } catch (error) {
909
- voiceDebug("polish notice setting write failed", String(error));
910
- }
911
- try {
912
- const turns = config.postProcessContextTurns ?? DEFAULT_CONTEXT_LIMITS.turns;
913
- ctx.ui.notify(
914
- [
915
- "Voice polish is on: every dictation makes one extra model call,",
916
- turns > 0
917
- ? `and the last ${turns} conversation turns are sent with it.`
918
- : "and no conversation context is sent with it.",
919
- "Turn it off with /voice-polish off.",
920
- ].join(" "),
921
- "info"
922
- );
923
- } catch (error) {
924
- voiceDebug("polish notice notification failed", String(error));
925
- }
926
- }
975
+ polishPassEditorSnapshot = editorSnapshot === EDITOR_READ_FAILED ? null : editorSnapshot;
927
976
  // R19: everything before the model call is fail-open too — a throw here
928
977
  // (registry lookup, notify, status) must not cost the user their dictation.
929
978
  let choice: ReturnType<typeof resolveModelChoice>;
930
979
  try {
931
- choice = resolveModelChoice(parseModelRef(config.postProcessModel), polishModelLookup, ctx?.model);
980
+ choice = resolvePolishModel();
932
981
  } catch (err) {
933
982
  activePolishPass = null;
934
983
  polishPassEditorSnapshot = null;
@@ -1006,19 +1055,27 @@ export default function (pi: ExtensionAPI) {
1006
1055
  return { action: "abort", text: raw };
1007
1056
  }
1008
1057
  const decision = decideApply({
1009
- tokenCurrent: id.invalidated === null,
1058
+ // `raw` is the polish-off disposition: the rewrite is never used, but the dictation
1059
+ // still owns its write decision and keeps the recogniser's text.
1060
+ tokenCurrent: id.invalidated === null || id.invalidated === "raw",
1010
1061
  editorSnapshot,
1011
1062
  currentEditor: readEditorOrFailed(),
1012
1063
  });
1064
+ const rawOnly = id.invalidated === "raw";
1013
1065
  // The caller finalizes telemetry after the editor write, not at this decision.
1014
- const pendingTelemetry = { ...telemetry, reason: decision.apply ? result.reason : decision.reason };
1066
+ const pendingTelemetry = {
1067
+ ...telemetry,
1068
+ ...(rawOnly ? { status: "skipped" } : {}),
1069
+ reason: decision.apply ? (rawOnly ? "polish-off" : result.reason) : decision.reason,
1070
+ };
1015
1071
  if (!decision.apply) {
1016
1072
  return { action: "discard", text: raw, status: "discarded", telemetry: pendingTelemetry };
1017
1073
  }
1074
+ const applied = !rawOnly && result.status === "applied";
1018
1075
  return {
1019
1076
  action: "apply",
1020
- text: result.status === "applied" ? result.text : raw,
1021
- status: result.status === "applied" ? "applied" : "failed",
1077
+ text: applied ? result.text : raw,
1078
+ status: applied ? "applied" : "failed",
1022
1079
  telemetry: pendingTelemetry,
1023
1080
  };
1024
1081
  } catch (err) {
@@ -1040,7 +1097,8 @@ export default function (pi: ExtensionAPI) {
1040
1097
  return { action: "abort", text: raw };
1041
1098
  }
1042
1099
  const decision = decideApply({
1043
- tokenCurrent: id.invalidated === null,
1100
+ // A polish-off pass still owns the raw-text write; a discarded or relinquished one does not.
1101
+ tokenCurrent: id.invalidated === null || id.invalidated === "raw",
1044
1102
  editorSnapshot,
1045
1103
  currentEditor: readEditorOrFailed(),
1046
1104
  });
@@ -1069,6 +1127,201 @@ export default function (pi: ExtensionAPI) {
1069
1127
  }
1070
1128
  }
1071
1129
 
1130
+ /**
1131
+ * Create the bounded segment queue for one local, in-process dictation. Called when the
1132
+ * first non-empty recogniser segment decodes, so a silent or endpoint-backed dictation
1133
+ * never creates a queue and keeps `runPolishPass` exactly as it is.
1134
+ */
1135
+ function createLocalPolishQueuePass(): PolishQueuePass | null {
1136
+ // Same gate as the onDone polish block: with polish off (or no UI) no call may fire,
1137
+ // and the dictation stays exactly as it was before the pipeline existed.
1138
+ if (!ctx?.hasUI || config.postProcessEnabled === false) return null;
1139
+ let choice: ReturnType<typeof resolveModelChoice>;
1140
+ try {
1141
+ choice = resolvePolishModel();
1142
+ } catch (err) {
1143
+ // The raw fallback, and the notice, are decided once in runPolishPass.
1144
+ voiceDebug("polish model resolution threw — using the raw transcript", { error: String(err) });
1145
+ return null;
1146
+ }
1147
+ if (!choice.model) {
1148
+ voiceDebug("polish skipped", { ref: choice.ref, reason: choice.reason });
1149
+ return null;
1150
+ }
1151
+ // Read the session context before any pass state is claimed: a throw here leaves the
1152
+ // caller's buffer intact, so a later segment can still create the pass.
1153
+ let entries: readonly EntryLike[];
1154
+ try {
1155
+ entries = ctx.sessionManager.buildContextEntries();
1156
+ } catch (err) {
1157
+ voiceDebug("polish context build threw — keeping the segments buffered", { error: String(err) });
1158
+ return null;
1159
+ }
1160
+ const model = choice.model;
1161
+ // Pin everything this pass needs for the whole dictation: `ctx` is reassigned on every
1162
+ // command and session event, and a call queued for this dictation must never land in a
1163
+ // newer context.
1164
+ const modelRegistry = ctx.modelRegistry;
1165
+ const ui = ctx.ui;
1166
+ const id: PolishPassToken = { invalidated: null };
1167
+ activePolishPass = id;
1168
+ let editorSnapshot: string | typeof EDITOR_READ_FAILED;
1169
+ try {
1170
+ editorSnapshot = ui.getEditorText?.() ?? "";
1171
+ } catch (err) {
1172
+ // A failed read is not an unchanged editor: the pass keeps the marker and the write
1173
+ // decision discards, so nothing can overwrite text we could not read (invariant 2).
1174
+ voiceDebug("polish editor snapshot read threw — the pass will discard its write", { error: String(err) });
1175
+ editorSnapshot = EDITOR_READ_FAILED;
1176
+ }
1177
+ polishPassEditorSnapshot = editorSnapshot === EDITOR_READ_FAILED ? null : editorSnapshot;
1178
+ const stats: PolishQueuePass["stats"] = { thinkingOff: false };
1179
+ const queue = createPolishQueue({
1180
+ timeoutMs: config.postProcessTimeoutMs ?? 8000,
1181
+ entries,
1182
+ limits: {
1183
+ turns: config.postProcessContextTurns ?? DEFAULT_CONTEXT_LIMITS.turns,
1184
+ perEntryChars: DEFAULT_CONTEXT_LIMITS.perEntryChars,
1185
+ totalChars: DEFAULT_CONTEXT_LIMITS.totalChars,
1186
+ },
1187
+ model: model as { reasoning?: boolean },
1188
+ isCurrent: () => activePolishPass === id && id.invalidated === null,
1189
+ call: (request, signal) => {
1190
+ // The queue decides this segment's sampling; record what actually left so the one
1191
+ // audit entry per dictation can summarise it.
1192
+ if (request.samplingParams) stats.thinkingOff = true;
1193
+ stats.maxTokens = Math.max(stats.maxTokens ?? 0, request.maxTokens);
1194
+ return modelRegistry.complete(
1195
+ model as never,
1196
+ { systemPrompt: request.systemPrompt, messages: request.messages as never },
1197
+ {
1198
+ signal,
1199
+ maxTokens: request.maxTokens,
1200
+ // Forward the gate, not the whole request, so the queue's decision reaches the
1201
+ // transport exactly as the single-call path's does.
1202
+ ...(request.samplingParams ? { samplingParams: request.samplingParams } : {}),
1203
+ }
1204
+ );
1205
+ },
1206
+ debug: (reason, data) => voiceDebug(`polish ${reason}`, data),
1207
+ });
1208
+ return {
1209
+ token: id,
1210
+ queue,
1211
+ startedAt: Date.now(),
1212
+ modelLabel: polishModelLabel(choice),
1213
+ configured: choice.ref,
1214
+ stats,
1215
+ editorSnapshot,
1216
+ ui,
1217
+ };
1218
+ }
1219
+
1220
+ /**
1221
+ * The ownership decision every queue outcome goes through. The editor must still match the
1222
+ * snapshot the pass started from — never a fresh read, which would mistake text the user
1223
+ * typed while later segments were recognised for an unchanged editor (review finding 1).
1224
+ * A snapshot that could not be read is a deliberate discard (spec invariant 2).
1225
+ */
1226
+ function decideQueueWrite(pass: PolishQueuePass): { apply: boolean; reason?: string } {
1227
+ const snapshot = pass.editorSnapshot;
1228
+ if (snapshot === EDITOR_READ_FAILED) return { apply: false, reason: "editor-unreadable" };
1229
+ return decideApply({
1230
+ // `raw` keeps the write path alive; a discarded or relinquished pass may not write.
1231
+ tokenCurrent: pass.token.invalidated === null || pass.token.invalidated === "raw",
1232
+ editorSnapshot: snapshot,
1233
+ currentEditor: readEditorOrFailed(),
1234
+ });
1235
+ }
1236
+
1237
+ /**
1238
+ * Finish the segmented pass once recognition is done: await every segment, then decide the
1239
+ * write with the same ownership rules as the single call. Fail-open throughout: a queue
1240
+ * that never received a segment delegates to `runPolishPass`, and a failed segment keeps
1241
+ * its own raw text while its neighbours keep theirs.
1242
+ */
1243
+ async function finishPolishQueuePass(pass: PolishQueuePass, raw: string): Promise<PolishOutcome> {
1244
+ const id = pass.token;
1245
+ const started = pass.startedAt;
1246
+ if (activePolishPass === id) {
1247
+ try {
1248
+ pass.ui.setStatus("voice", "polishing…");
1249
+ } catch (err) {
1250
+ voiceDebug("polish status write threw", { error: String(err) });
1251
+ }
1252
+ }
1253
+ try {
1254
+ const result = await pass.queue.finish();
1255
+ if (result.segments.length === 0 && id.invalidated === null) {
1256
+ // Defensive: the caller pushes a segment in the same tick the pass is created, so a
1257
+ // queue with no segments should not exist. If it does, keep today's single-call
1258
+ // behaviour; an invalidated pass never opens a new request.
1259
+ if (activePolishPass === id) {
1260
+ activePolishPass = null;
1261
+ polishPassEditorSnapshot = null;
1262
+ }
1263
+ return await runPolishPass(raw, readEditorOrFailed());
1264
+ }
1265
+ // `raw` is the polish-off disposition: queued work stops and the polished text must
1266
+ // not be used, but the dictation still gets its raw transcript and its audit entry.
1267
+ const polishOff = id.invalidated === "raw";
1268
+ const failureReason = polishOff
1269
+ ? "polish-off"
1270
+ : result.segments.find((segment) => segment.reason !== undefined)?.reason;
1271
+ const telemetry: PolishTelemetry = {
1272
+ model: pass.modelLabel,
1273
+ configured: pass.configured,
1274
+ status: polishOff
1275
+ ? "skipped"
1276
+ : result.polished > 0
1277
+ ? "applied"
1278
+ : failureReason === "invalidated"
1279
+ ? "skipped"
1280
+ : "rejected",
1281
+ ms: Date.now() - started,
1282
+ thinkingOff: pass.stats.thinkingOff,
1283
+ segments: {
1284
+ count: result.segments.length,
1285
+ polished: result.polished,
1286
+ failed: result.failed,
1287
+ retried: result.retried,
1288
+ },
1289
+ };
1290
+ if (pass.stats.maxTokens !== undefined) telemetry.maxTokens = pass.stats.maxTokens;
1291
+ // A newer recording or session owns the editor now: change nothing at all.
1292
+ if (activePolishPass !== id) {
1293
+ voiceDebug("polish result", { ...telemetry, disposition: "aborted", reason: "invalidated" });
1294
+ return { action: "abort", text: raw };
1295
+ }
1296
+ const decision = decideQueueWrite(pass);
1297
+ const pendingTelemetry = { ...telemetry, reason: decision.apply ? failureReason : decision.reason };
1298
+ if (!decision.apply) {
1299
+ return { action: "discard", text: raw, status: "discarded", telemetry: pendingTelemetry };
1300
+ }
1301
+ const polished = !polishOff && result.polished > 0;
1302
+ return {
1303
+ action: "apply",
1304
+ // One failed segment must not cost its neighbours their polish; when every segment
1305
+ // fell back, or polish was turned off, the recogniser's own join is the raw transcript.
1306
+ text: polished ? result.text : raw,
1307
+ status: polished ? "applied" : "failed",
1308
+ telemetry: pendingTelemetry,
1309
+ };
1310
+ } finally {
1311
+ // R20: a stale pass must not restore its status text over the flow that
1312
+ // replaced it — and a cosmetic status write is never allowed to throw.
1313
+ if (activePolishPass === id) {
1314
+ activePolishPass = null;
1315
+ polishPassEditorSnapshot = null;
1316
+ try {
1317
+ updateVoiceStatus();
1318
+ } catch (err) {
1319
+ voiceDebug("polish status restore threw", { error: String(err) });
1320
+ }
1321
+ }
1322
+ }
1323
+ }
1324
+
1072
1325
  let preRecordingSession: StreamingSession | null = null; // Started during warmup, promoted on confirm (Deepgram only)
1073
1326
 
1074
1327
  let lastStopTime = 0; // For Escape-to-clear-editor within 30s of recording
@@ -1664,6 +1917,16 @@ export default function (pi: ExtensionAPI) {
1664
1917
  });
1665
1918
  setVoiceState("recording");
1666
1919
 
1920
+ // This dictation's segmented polish state, local in-process only. Both live in this
1921
+ // recording's closure, so a late segment from an aborted session can never land in the
1922
+ // next dictation's queue.
1923
+ let localPolishSession: LocalSession | null = null;
1924
+ let localPolishQueue: PolishQueuePass | null = null;
1925
+ // Every recognised segment, in arrival order, until the queue owns them. The buffer is
1926
+ // what lets a queue created after the first segment cover the whole dictation instead of
1927
+ // only its tail (a model or context failure on an early segment must cost no content).
1928
+ const localSegments: { index: number; text: string }[] = [];
1929
+
1667
1930
  // ── Callbacks for the active recording session ──
1668
1931
  const recordingCallbacks = {
1669
1932
  onTranscript: (interim: string, finals: string[]) => {
@@ -1671,6 +1934,37 @@ export default function (pi: ExtensionAPI) {
1671
1934
  updateLiveTranscriptWidget(interim, finals);
1672
1935
  updateVoiceStatus();
1673
1936
  },
1937
+ onSegment: (text: string, index: number) => {
1938
+ // Local in-process recognition only, and only while the session is live: an aborted
1939
+ // session's late segments must not open calls for a pass nobody owns.
1940
+ if (!localPolishSession || localPolishSession.closed) return;
1941
+ if (!text.trim()) return;
1942
+ localSegments.push({ index, text });
1943
+ const pass = localPolishQueue;
1944
+ if (pass) {
1945
+ // Checked between segments as well, so a cancelled pass stops opening calls it can
1946
+ // never write.
1947
+ if (pass.token.invalidated !== null) return;
1948
+ pass.queue.push(index, text);
1949
+ return;
1950
+ }
1951
+ // First non-empty segment: try to create the pass. A failure (model unavailable, the
1952
+ // context read threw) is retried on the next segment, and the buffer above keeps every
1953
+ // earlier segment so the queue that eventually succeeds still covers all of them.
1954
+ let created: PolishQueuePass | null = null;
1955
+ try {
1956
+ created = createLocalPolishQueuePass();
1957
+ } catch (err) {
1958
+ voiceDebug("polish queue creation threw — keeping the segments buffered", { error: String(err) });
1959
+ return;
1960
+ }
1961
+ if (!created) return;
1962
+ localPolishQueue = created;
1963
+ for (const buffered of localSegments) {
1964
+ if (created.token.invalidated !== null) break;
1965
+ created.queue.push(buffered.index, buffered.text);
1966
+ }
1967
+ },
1674
1968
  onDone: async (fullText: string, meta: { hadAudio: boolean; hadSpeech: boolean }) => {
1675
1969
  voiceDebug("onDone callback", { fullText: fullText.slice(0, 100), meta, voiceState, spaceConsumed });
1676
1970
  activeSession = null;
@@ -1719,7 +2013,11 @@ export default function (pi: ExtensionAPI) {
1719
2013
  let skipWrite = false;
1720
2014
  let polishOutcome: PolishOutcomeStatus | undefined;
1721
2015
  let polishTelemetry: PolishTelemetry | undefined;
1722
- if (ctx?.hasUI && config.postProcessEnabled !== false) {
2016
+ // A queue created before polish was switched off still owes the dictation its
2017
+ // ownership decision and its audit entry: the current switch cannot bypass a pass
2018
+ // that already made (and paid for) requests (review finding 4).
2019
+ const queuePass = localPolishQueue;
2020
+ if (ctx?.hasUI && (queuePass !== null || config.postProcessEnabled !== false)) {
1723
2021
  // R18: the streaming transport can finalize itself (ws.onclose /
1724
2022
  // finalizeTimer) without going through stopVoiceRecording, so the state
1725
2023
  // may still be "recording" here. Hold the pass inside the finalizing
@@ -1739,13 +2037,14 @@ export default function (pi: ExtensionAPI) {
1739
2037
  }
1740
2038
  let outcome: PolishOutcome;
1741
2039
  try {
1742
- outcome = await runPolishPass(fullText, ctx.ui.getEditorText?.() ?? "");
2040
+ outcome = queuePass
2041
+ ? await finishPolishQueuePass(queuePass, fullText)
2042
+ : await runPolishPass(fullText, readEditorOrFailed());
1743
2043
  } catch (err) {
1744
2044
  // Item 3: the pass decides its own throws by ownership; only a failure that
1745
- // never reached that decision lands here, for example a throwing editor
1746
- // snapshot read (the pass's own ownership read cannot throw). Ownership was
1747
- // never established, so the raw text may not overwrite the editor: discard.
1748
- // The dictation is still recorded and the completion tail still runs.
2045
+ // never reached that decision lands here. Ownership was never established, so the
2046
+ // raw text may not overwrite the editor: discard. The dictation is still recorded
2047
+ // and the completion tail still runs.
1749
2048
  invalidatePolishPass("pass-threw");
1750
2049
  voiceDebug("polish pass threw before ownership — discarding the editor write", {
1751
2050
  error: String(err),
@@ -1830,6 +2129,13 @@ export default function (pi: ExtensionAPI) {
1830
2129
  editorPrefixChars: prefix.length,
1831
2130
  thinkingOff: polishTelemetry?.thinkingOff,
1832
2131
  maxTokens: polishTelemetry?.maxTokens,
2132
+ durationSec: Number(elapsed),
2133
+ backend: config.backend,
2134
+ // The local model in effect, with the same fallback the transcriber applies; omitted
2135
+ // for cloud backends, which have no local model id to group by.
2136
+ recognizer: config.backend === "local" ? config.localModel || DEFAULT_LOCAL_MODEL : undefined,
2137
+ // One summary of the segmented pass; absent when a single call produced the text.
2138
+ segments: polishTelemetry?.segments,
1833
2139
  written: wroteEditor ? finalText : undefined,
1834
2140
  status: final.status,
1835
2141
  disposition: final.disposition,
@@ -1958,6 +2264,10 @@ export default function (pi: ExtensionAPI) {
1958
2264
  }
1959
2265
  hideWidget();
1960
2266
 
2267
+ // A queue can already be in flight when transcription fails: invalidate its shared
2268
+ // pass id so no late segment can write or open further calls.
2269
+ invalidatePolishPass("recording-error");
2270
+
1961
2271
  // ── STOP THE LOOP ──
1962
2272
  // On error, fully reset ALL hold state AND set a cooldown
1963
2273
  // so incoming key-repeat events can't re-trigger activation.
@@ -1990,6 +2300,7 @@ export default function (pi: ExtensionAPI) {
1990
2300
  voiceDebug(`${audioTool.name} stderr:`, msg);
1991
2301
  });
1992
2302
  session = startLocalSession(recProc, recordingCallbacks);
2303
+ localPolishSession = session;
1993
2304
 
1994
2305
  // Feed audio level meter for waveform animation
1995
2306
  recProc.stdout?.on("data", (chunk: Buffer) => {
@@ -4063,6 +4374,10 @@ export default function (pi: ExtensionAPI) {
4063
4374
  return;
4064
4375
  }
4065
4376
  config.postProcessEnabled = next;
4377
+ // Stop an in-flight pass now. A queue that already sent segments still finishes its
4378
+ // ownership decision and writes its audit entry (with the raw text), but no further
4379
+ // request may leave once polish is off (review finding 4).
4380
+ if (!next) invalidatePolishPass("polish-off", "raw");
4066
4381
  cmdCtx.ui.notify(`Voice polish ${next ? "enabled" : "disabled"}.`, "info");
4067
4382
  return;
4068
4383
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-voicekit",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Voice in + voice out for Pi CLI — hold-to-talk STT (Deepgram streaming or 21 offline models) plus TTS (Kitten Nano, Piper, Kokoro, or Deepgram Aura)",
5
5
  "type": "module",
6
6
  "keywords": [