pi-voicekit 0.1.3 → 0.2.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * pi-voice — Enterprise-grade voice STT for Pi CLI.
2
+ * pi-voicekit — Enterprise-grade voice STT for Pi CLI.
3
3
  *
4
4
  * Architecture (modeled after Claude Code's voice pipeline):
5
5
  *
@@ -71,6 +71,7 @@ import {
71
71
  loadConfigWithSource,
72
72
  loadGlobalToggleShortcut,
73
73
  saveConfig,
74
+ saveGlobalVoiceFields,
74
75
  type VoiceConfig,
75
76
  type VoiceSettingsScope,
76
77
  } from "./voice/config";
@@ -101,6 +102,16 @@ import {
101
102
  import { shouldArmReleaseDetectOnRepeat, decideRecordingStartTimer } from "./voice/hold-to-talk";
102
103
  import { GapTimer, type TimerPort } from "./voice/release-controller";
103
104
  import { audioToolOrder, type AudioToolName } from "./voice/audio-tool";
105
+ import {
106
+ decideApply,
107
+ finalizePolishDisposition,
108
+ EDITOR_READ_FAILED,
109
+ parseModelRef,
110
+ polishModelOptions,
111
+ polishTranscript,
112
+ resolveModelChoice,
113
+ } from "./voice/post-process";
114
+ import { DEFAULT_CONTEXT_LIMITS } from "./voice/post-process-context";
104
115
 
105
116
  /** Adapter for the real event loop — lets GapTimer run under the real setTimeout. */
106
117
  const realTimerPort: TimerPort = {
@@ -491,6 +502,10 @@ function startStreamingSession(
491
502
  };
492
503
 
493
504
  ws.onmessage = (event: MessageEvent) => {
505
+ // R22: once the session is finalized (finalizeSession / abortSession) the
506
+ // transport must stop writing the editor — a late Results message would
507
+ // otherwise overwrite what the user typed while the polish pass waited.
508
+ if (session.closed) return;
494
509
  try {
495
510
  const msg = typeof event.data === "string" ? JSON.parse(event.data) : null;
496
511
  if (!msg) return;
@@ -752,6 +767,291 @@ export default function (pi: ExtensionAPI) {
752
767
 
753
768
  // Streaming session state
754
769
  let activeSession: VoiceSession | null = null;
770
+
771
+ // ─── Transcript polish (post-processing) ─────────────────────────────────
772
+ // One bounded pass between the final transcript and the editor write. The
773
+ // token below is what makes a late result inert (spec §4.1.1).
774
+ type PolishPassToken = { invalidated: "discard" | "abort" | null };
775
+ let activePolishPass: PolishPassToken | null = null;
776
+ /**
777
+ * Editor value the pending pass started from, or null when no pass is pending. The
778
+ * escape handler compares the live editor against it: a pending pass has written
779
+ * nothing, so a difference is text the user typed while waiting.
780
+ */
781
+ let polishPassEditorSnapshot: string | null = null;
782
+
783
+ function invalidatePolishPass(reason: string, cleanup: "complete" | "relinquish" = "relinquish"): void {
784
+ const pass = activePolishPass;
785
+ if (!pass) return;
786
+ const disposition = cleanup === "complete" ? "discard" : "abort";
787
+ if (pass.invalidated !== disposition) voiceDebug("polish pass invalidated", { reason });
788
+ pass.invalidated = disposition;
789
+ // Retain a discarded pass until its tail completes, or a later teardown takes
790
+ // ownership. The awaiting callback retains this token even after relinquishing.
791
+ if (cleanup === "relinquish") {
792
+ activePolishPass = null;
793
+ polishPassEditorSnapshot = null;
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Ownership reads go through here: a throwing editor read is a failed read and
799
+ * never an unchanged editor (spec invariant 2).
800
+ */
801
+ function readEditorOrFailed(): string | typeof EDITOR_READ_FAILED {
802
+ try {
803
+ return ctx?.ui.getEditorText?.() ?? "";
804
+ } catch (err) {
805
+ voiceDebug("editor read threw — treating the text as unreadable", { error: String(err) });
806
+ return EDITOR_READ_FAILED;
807
+ }
808
+ }
809
+
810
+ function polishModelLookup(provider: string, modelId: string): { model: unknown; hasAuth: boolean } | undefined {
811
+ const found = ctx?.modelRegistry.find(provider, modelId);
812
+ if (!found) return undefined;
813
+ return { model: found, hasAuth: ctx!.modelRegistry.hasConfiguredAuth(found) };
814
+ }
815
+
816
+ /**
817
+ * Scope the polish numbers are persisted to. `config.scope` is an in-memory field a
818
+ * project file can set itself, so reading it can write to one file while the loader
819
+ * keeps reading the other: the command reports success and a reload shows the old
820
+ * value. `configSource` is where this session's config was actually loaded from; only
821
+ * a session with no file at all (defaults) falls back to the field. Polish settings
822
+ * only — the pre-existing commands keep their own rule.
823
+ */
824
+ function polishWriteScope(): VoiceSettingsScope {
825
+ if (configSource === "global" || configSource === "project") return configSource;
826
+ return config.scope === "project" ? "project" : "global";
827
+ }
828
+
829
+ /**
830
+ * R30: `saveGlobalVoiceFields` refuses to overwrite a settings file it cannot read and
831
+ * throws. Every user-reachable caller reports that refusal in plain words instead of
832
+ * letting it surface as an unhandled error; the in-memory value is only updated when
833
+ * the write actually succeeded.
834
+ */
835
+ function saveGlobalVoiceFieldsOrNotify(
836
+ fields: Parameters<typeof saveGlobalVoiceFields>[0],
837
+ notify: (message: string) => void
838
+ ): boolean {
839
+ try {
840
+ saveGlobalVoiceFields(fields);
841
+ return true;
842
+ } catch {
843
+ notify("Voice polish: the settings file could not be read — nothing was changed.");
844
+ return false;
845
+ }
846
+ }
847
+
848
+ /**
849
+ * The model the pass actually ran on, as `provider/id`. The configured value is the
850
+ * "session" marker whenever the session model is in play, so a telemetry line built
851
+ * from it alone cannot be grouped per model.
852
+ */
853
+ function polishModelLabel(choice: { model?: unknown; ref: string }): string {
854
+ const model = choice.model as { provider?: unknown; id?: unknown } | undefined;
855
+ if (model && typeof model.provider === "string" && typeof model.id === "string") {
856
+ return `${model.provider}/${model.id}`;
857
+ }
858
+ return choice.ref;
859
+ }
860
+
861
+ /** What the polish pass did with one dictation — recorded on the history entry for `last`. */
862
+ type PolishOutcomeStatus = "applied" | "discarded" | "failed";
863
+
864
+ /**
865
+ * Outcome of one polish pass.
866
+ * - `apply`: write `text` (the pass's rewrite, or the raw transcript on any failure).
867
+ * - `discard`: the editor changed while we waited — write NOTHING, send NOTHING.
868
+ * - `abort`: a newer recording or session owns the flow — leave the state alone.
869
+ * `status` is provisional until the caller attempts the write. History and the
870
+ * telemetry disposition are finalized together from the actual write result.
871
+ */
872
+ type PolishOutcome = (
873
+ | { action: "apply"; text: string; status: "applied" | "failed" }
874
+ | { action: "discard"; text: string; status: "discarded" }
875
+ | { action: "abort"; text: string }
876
+ ) & { telemetry?: PolishTelemetry };
877
+ type PolishTelemetry = {
878
+ model: string;
879
+ configured: string;
880
+ status: string;
881
+ ms: number;
882
+ contextChars?: number;
883
+ truncated?: boolean;
884
+ reason?: string;
885
+ error?: string;
886
+ };
887
+ async function runPolishPass(raw: string, editorSnapshot: string): Promise<PolishOutcome> {
888
+ const id: PolishPassToken = { invalidated: null };
889
+ activePolishPass = id;
890
+ polishPassEditorSnapshot = editorSnapshot;
891
+ if (!config.postProcessNoticeShown && ctx?.hasUI) {
892
+ // D5: one-time disclosure. The flag is set in memory first (an assignment cannot
893
+ // throw); the write and the notification then sit in guards of their own. A
894
+ // read-only config directory must cost the flag's persistence, never the
895
+ // disclosure — the in-memory flag still suppresses a repeat in this process.
896
+ config.postProcessNoticeShown = true;
897
+ try {
898
+ // R26: field-level global write — `config` also carries this project's values, so
899
+ // a whole-block write would reset unrelated machine-global settings. Persist the
900
+ // flag BEFORE notifying, per the house rule in tts-onboarding.
901
+ saveGlobalVoiceFields({ postProcessNoticeShown: true });
902
+ } catch (error) {
903
+ voiceDebug("polish notice setting write failed", String(error));
904
+ }
905
+ try {
906
+ const turns = config.postProcessContextTurns ?? DEFAULT_CONTEXT_LIMITS.turns;
907
+ ctx.ui.notify(
908
+ [
909
+ "Voice polish is on: every dictation makes one extra model call,",
910
+ turns > 0
911
+ ? `and the last ${turns} conversation turns are sent with it.`
912
+ : "and no conversation context is sent with it.",
913
+ "Turn it off with /voice-polish off.",
914
+ ].join(" "),
915
+ "info"
916
+ );
917
+ } catch (error) {
918
+ voiceDebug("polish notice notification failed", String(error));
919
+ }
920
+ }
921
+ // R19: everything before the model call is fail-open too — a throw here
922
+ // (registry lookup, notify, status) must not cost the user their dictation.
923
+ let choice: ReturnType<typeof resolveModelChoice>;
924
+ try {
925
+ choice = resolveModelChoice(parseModelRef(config.postProcessModel), polishModelLookup, ctx?.model);
926
+ } catch (err) {
927
+ activePolishPass = null;
928
+ polishPassEditorSnapshot = null;
929
+ voiceDebug("polish model resolution threw — using the raw transcript", { error: String(err) });
930
+ return { action: "apply", text: raw, status: "failed" };
931
+ }
932
+ if (!choice.model) {
933
+ voiceDebug("polish skipped", { ref: choice.ref, reason: choice.reason });
934
+ try {
935
+ if (choice.reason === "malformed") {
936
+ ctx?.ui.notify(
937
+ `Voice polish: "${choice.ref}" is not a provider/modelId reference — pick a model with /voice-polish model. Using the raw transcript.`,
938
+ "warning"
939
+ );
940
+ } else if (choice.reason === "not-found" || choice.reason === "no-auth") {
941
+ const why = choice.reason === "not-found" ? "is not available" : "has no configured authentication";
942
+ ctx?.ui.notify(`Voice polish: model ${choice.ref} ${why} — using the raw transcript.`, "warning");
943
+ }
944
+ } catch (err) {
945
+ voiceDebug("polish skip notify threw", { error: String(err) });
946
+ }
947
+ activePolishPass = null;
948
+ polishPassEditorSnapshot = null;
949
+ return { action: "apply", text: raw, status: "failed" };
950
+ }
951
+ const model = choice.model;
952
+ const started = Date.now();
953
+ try {
954
+ ctx?.ui.setStatus("voice", "polishing…");
955
+ } catch (err) {
956
+ voiceDebug("polish status write threw", { error: String(err) });
957
+ }
958
+ try {
959
+ const result = await polishTranscript({
960
+ raw,
961
+ entries: ctx?.sessionManager.buildContextEntries() ?? [],
962
+ limits: {
963
+ turns: config.postProcessContextTurns ?? DEFAULT_CONTEXT_LIMITS.turns,
964
+ perEntryChars: DEFAULT_CONTEXT_LIMITS.perEntryChars,
965
+ totalChars: DEFAULT_CONTEXT_LIMITS.totalChars,
966
+ },
967
+ timeoutMs: config.postProcessTimeoutMs ?? 8000,
968
+ timestamp: Date.now(),
969
+ isCurrent: () => activePolishPass === id && id.invalidated === null,
970
+ call: (request, signal) =>
971
+ ctx!.modelRegistry.complete(
972
+ model as never,
973
+ { systemPrompt: request.systemPrompt, messages: request.messages as never },
974
+ { signal, maxTokens: request.maxTokens }
975
+ ),
976
+ debug: (reason, data) => voiceDebug(`polish ${reason}`, data),
977
+ });
978
+ const telemetry = {
979
+ model: polishModelLabel(choice),
980
+ configured: choice.ref,
981
+ status: result.status,
982
+ ms: Date.now() - started,
983
+ contextChars: result.contextChars,
984
+ truncated: result.truncatedContext,
985
+ };
986
+ // A newer recording or session owns the editor now: change nothing at all.
987
+ if (activePolishPass !== id) {
988
+ voiceDebug("polish result", { ...telemetry, disposition: "aborted", reason: "invalidated" });
989
+ return { action: "abort", text: raw };
990
+ }
991
+ const decision = decideApply({
992
+ tokenCurrent: id.invalidated === null,
993
+ editorSnapshot,
994
+ currentEditor: readEditorOrFailed(),
995
+ });
996
+ // The caller finalizes telemetry after the editor write, not at this decision.
997
+ const pendingTelemetry = { ...telemetry, reason: decision.apply ? result.reason : decision.reason };
998
+ if (!decision.apply) {
999
+ return { action: "discard", text: raw, status: "discarded", telemetry: pendingTelemetry };
1000
+ }
1001
+ return {
1002
+ action: "apply",
1003
+ text: result.status === "applied" ? result.text : raw,
1004
+ status: result.status === "applied" ? "applied" : "failed",
1005
+ telemetry: pendingTelemetry,
1006
+ };
1007
+ } catch (err) {
1008
+ // Item 3: a throw here is decided by ownership, not by convenience. The pass's
1009
+ // verdict is unavailable, so the raw transcript is the fallback — but only while
1010
+ // the pass still owns a matching editor: an editor that changed, and equally one
1011
+ // that could not be read, means discard (no write, no dispatch, the dictation is
1012
+ // still recorded by the caller).
1013
+ if (activePolishPass !== id) {
1014
+ voiceDebug("polish result", {
1015
+ model: polishModelLabel(choice),
1016
+ configured: choice.ref,
1017
+ status: "failed",
1018
+ disposition: "aborted",
1019
+ reason: "invalidated",
1020
+ ms: Date.now() - started,
1021
+ error: String(err),
1022
+ });
1023
+ return { action: "abort", text: raw };
1024
+ }
1025
+ const decision = decideApply({
1026
+ tokenCurrent: id.invalidated === null,
1027
+ editorSnapshot,
1028
+ currentEditor: readEditorOrFailed(),
1029
+ });
1030
+ const telemetry: PolishTelemetry = {
1031
+ model: polishModelLabel(choice),
1032
+ configured: choice.ref,
1033
+ status: "failed",
1034
+ ms: Date.now() - started,
1035
+ reason: decision.reason ?? "pass-threw",
1036
+ error: String(err),
1037
+ };
1038
+ if (!decision.apply) return { action: "discard", text: raw, status: "discarded", telemetry };
1039
+ return { action: "apply", text: raw, status: "failed", telemetry };
1040
+ } finally {
1041
+ // R20: a stale pass must not restore its status text over the flow that
1042
+ // replaced it — and a cosmetic status write is never allowed to throw.
1043
+ if (activePolishPass === id) {
1044
+ activePolishPass = null;
1045
+ polishPassEditorSnapshot = null;
1046
+ try {
1047
+ updateVoiceStatus();
1048
+ } catch (err) {
1049
+ voiceDebug("polish status restore threw", { error: String(err) });
1050
+ }
1051
+ }
1052
+ }
1053
+ }
1054
+
755
1055
  let preRecordingSession: StreamingSession | null = null; // Started during warmup, promoted on confirm (Deepgram only)
756
1056
 
757
1057
  let lastStopTime = 0; // For Escape-to-clear-editor within 30s of recording
@@ -780,13 +1080,35 @@ export default function (pi: ExtensionAPI) {
780
1080
  timestamp: number;
781
1081
  duration: number;
782
1082
  mode: "hold" | "toggle" | "dictate";
1083
+ /** The exact string this feature wrote to the editor, when it wrote one. */
1084
+ writtenText?: string;
1085
+ /** `prefix + raw ASR output` — what a restore puts back. */
1086
+ rawFullText?: string;
1087
+ /** True when a polish rewrite replaced the raw text; the Polish tab's Last dictation row looks for these. */
1088
+ polishedApplied?: boolean;
1089
+ /**
1090
+ * What the pass did with this dictation, including a discarded one. Set whenever a
1091
+ * pass ran, so `/voice-polish last` can show the newest dictation it processed
1092
+ * rather than the newest one it wrote. Absent when no pass ran at all.
1093
+ */
1094
+ polishOutcome?: PolishOutcomeStatus;
783
1095
  }
784
1096
 
785
1097
  const recordingHistory: RecordingHistoryEntry[] = [];
786
1098
  const MAX_HISTORY = 50;
787
1099
 
788
- function addToHistory(text: string, duration: number, mode: "hold" | "toggle" | "dictate" = "hold") {
789
- recordingHistory.unshift({ text, timestamp: Date.now(), duration, mode });
1100
+ function addToHistory(
1101
+ text: string,
1102
+ duration: number,
1103
+ mode: "hold" | "toggle" | "dictate" = "hold",
1104
+ extra: {
1105
+ writtenText?: string;
1106
+ rawFullText?: string;
1107
+ polishedApplied?: boolean;
1108
+ polishOutcome?: PolishOutcomeStatus;
1109
+ } = {}
1110
+ ) {
1111
+ recordingHistory.unshift({ text, timestamp: Date.now(), duration, mode, ...extra });
790
1112
  if (recordingHistory.length > MAX_HISTORY) recordingHistory.pop();
791
1113
  }
792
1114
 
@@ -892,7 +1214,14 @@ export default function (pi: ExtensionAPI) {
892
1214
  }
893
1215
 
894
1216
  function hideWidget() {
895
- if (ctx?.hasUI) ctx.ui.setWidget("voice-recording", undefined);
1217
+ if (!ctx?.hasUI) return;
1218
+ // R24: hiding the widget is cosmetic — a UI throw here must not reject the
1219
+ // completion callback before its write, its history record and its tail.
1220
+ try {
1221
+ ctx.ui.setWidget("voice-recording", undefined);
1222
+ } catch (err) {
1223
+ voiceDebug("hideWidget threw", { error: String(err) });
1224
+ }
896
1225
  }
897
1226
 
898
1227
  /** Reset all hold-to-talk state to idle. Call after any recording stop/error/cancel. */
@@ -908,6 +1237,9 @@ export default function (pi: ExtensionAPI) {
908
1237
  }
909
1238
 
910
1239
  function voiceCleanup() {
1240
+ // R17: a pass pending when teardown starts must never write after it
1241
+ // (covers /voice off, the /voice toggle, the settings panel, shutdown).
1242
+ invalidatePolishPass("voice-disabled");
911
1243
  // v7.1: cancel in-flight installs FIRST so their AbortControllers
912
1244
  // fire before we drop UI state. Without this, a session_shutdown
913
1245
  // during a download would leave the network/disk work running
@@ -1242,6 +1574,7 @@ export default function (pi: ExtensionAPI) {
1242
1574
  // This prevents the "slow connection overlaps new recording" bug.
1243
1575
  if (voiceState === "finalizing" || voiceState === "recording") {
1244
1576
  abortSession(activeSession);
1577
+ invalidatePolishPass("new-recording");
1245
1578
  activeSession = null;
1246
1579
  clearRecordingAnimTimer();
1247
1580
  clearWarmupWidget();
@@ -1321,7 +1654,7 @@ export default function (pi: ExtensionAPI) {
1321
1654
  updateLiveTranscriptWidget(interim, finals);
1322
1655
  updateVoiceStatus();
1323
1656
  },
1324
- onDone: (fullText: string, meta: { hadAudio: boolean; hadSpeech: boolean }) => {
1657
+ onDone: async (fullText: string, meta: { hadAudio: boolean; hadSpeech: boolean }) => {
1325
1658
  voiceDebug("onDone callback", { fullText: fullText.slice(0, 100), meta, voiceState, spaceConsumed });
1326
1659
  activeSession = null;
1327
1660
  clearRecordingAnimTimer();
@@ -1337,34 +1670,133 @@ export default function (pi: ExtensionAPI) {
1337
1670
  playSound("error");
1338
1671
  // Full state reset on empty result
1339
1672
  resetHoldState({ cooldown: 3000 });
1340
- if (!meta.hadAudio) {
1341
- ctx?.ui.notify("Microphone captured no audio. Check mic permissions.", "error");
1342
- } else if (!meta.hadSpeech) {
1343
- ctx?.ui.notify("Microphone captured silence — no speech detected.", "warning");
1344
- } else {
1345
- ctx?.ui.notify("No speech detected.", "warning");
1673
+ try {
1674
+ if (!meta.hadAudio) {
1675
+ ctx?.ui.notify("Microphone captured no audio. Check mic permissions.", "error");
1676
+ } else if (!meta.hadSpeech) {
1677
+ ctx?.ui.notify("Microphone captured silence — no speech detected.", "warning");
1678
+ } else {
1679
+ ctx?.ui.notify("No speech detected.", "warning");
1680
+ }
1681
+ } catch (err) {
1682
+ // R24: a failed notification must never skip the idle transition.
1683
+ voiceDebug("no-speech notify threw", { error: String(err) });
1684
+ }
1685
+ // R24: the transition renders the status bar — keep it non-fatal too.
1686
+ try {
1687
+ setVoiceState("idle");
1688
+ } catch (err) {
1689
+ voiceDebug("idle transition threw", { error: String(err) });
1346
1690
  }
1347
- setVoiceState("idle");
1348
1691
  return;
1349
1692
  }
1350
1693
 
1351
1694
  hideWidget();
1352
1695
 
1696
+ // R23: the recorded duration is the recording length — the model wait
1697
+ // below must not be counted as recording time.
1698
+ const elapsed = ((Date.now() - recordingStart) / 1000).toFixed(1);
1699
+
1700
+ // Transcript polish: bounded, fail-open, never blocking the recording flow.
1701
+ let spokenText = fullText;
1702
+ let skipWrite = false;
1703
+ let polishOutcome: PolishOutcomeStatus | undefined;
1704
+ let polishTelemetry: PolishTelemetry | undefined;
1705
+ if (ctx?.hasUI && config.postProcessEnabled !== false) {
1706
+ // R18: the streaming transport can finalize itself (ws.onclose /
1707
+ // finalizeTimer) without going through stopVoiceRecording, so the state
1708
+ // may still be "recording" here. Hold the pass inside the finalizing
1709
+ // window so every handler-reachable teardown early-returns or
1710
+ // invalidates. Narrow on purpose: a late callback that arrives after an
1711
+ // abort must not resurrect the state.
1712
+ // R24: the transition renders the status bar (setVoiceState →
1713
+ // updateVoiceStatus → ctx.ui.setStatus), so a UI throw here used to reject
1714
+ // this callback and skip the write, the history record and the tail.
1715
+ // Wrap it — the state field is already assigned before the render.
1716
+ if (voiceState === "recording") {
1717
+ try {
1718
+ setVoiceState("finalizing");
1719
+ } catch (err) {
1720
+ voiceDebug("finalizing transition threw — continuing", { error: String(err) });
1721
+ }
1722
+ }
1723
+ let outcome: PolishOutcome;
1724
+ try {
1725
+ outcome = await runPolishPass(fullText, ctx.ui.getEditorText?.() ?? "");
1726
+ } catch (err) {
1727
+ // Item 3: the pass decides its own throws by ownership; only a failure that
1728
+ // never reached that decision lands here, for example a throwing editor
1729
+ // snapshot read (the pass's own ownership read cannot throw). Ownership was
1730
+ // never established, so the raw text may not overwrite the editor: discard.
1731
+ // The dictation is still recorded and the completion tail still runs.
1732
+ invalidatePolishPass("pass-threw");
1733
+ voiceDebug("polish pass threw before ownership — discarding the editor write", {
1734
+ error: String(err),
1735
+ });
1736
+ outcome = { action: "discard", text: fullText, status: "discarded" };
1737
+ }
1738
+ // A newer recording or session owns the flow now: touch nothing at all.
1739
+ if (outcome.action === "abort") return;
1740
+ spokenText = outcome.text;
1741
+ polishOutcome = outcome.status;
1742
+ polishTelemetry = outcome.telemetry;
1743
+ // The editor changed while we waited: keep the user's text, say so once.
1744
+ if (outcome.action === "discard") {
1745
+ skipWrite = true;
1746
+ try {
1747
+ ctx.ui.notify("Voice polish: the editor changed while I was working — kept your text.", "info");
1748
+ } catch (err) {
1749
+ voiceDebug("polish discard notify threw", { error: String(err) });
1750
+ }
1751
+ }
1752
+ }
1753
+
1353
1754
  if (ctx?.hasUI) {
1354
1755
  const prefix = editorTextBeforeVoice ? editorTextBeforeVoice + " " : "";
1355
1756
  const isLocal = config.backend === "local";
1356
- const finalText = prefix + fullText;
1757
+ const finalText = prefix + spokenText;
1758
+ // R21: history records what was actually written, not what was planned.
1759
+ let wroteEditor = false;
1760
+ let editorWriteFailed = false;
1761
+
1762
+ // A discarded pass must not write.
1763
+ if (!skipWrite) {
1764
+ // R24: the editor read/write is a UI call — a throw must leave
1765
+ // `wroteEditor` false and still reach the history record and the tail.
1766
+ try {
1767
+ if (isLocal) {
1768
+ // Local backend (batch mode): no interim transcripts were sent to the editor,
1769
+ // so we must always insert the final text. This is the ONLY place it arrives.
1770
+ ctx.ui.setEditorText(finalText);
1771
+ wroteEditor = true;
1772
+ } else {
1773
+ // Streaming backend: interim transcripts already updated the editor live.
1774
+ // Only set final text if the editor still has content (user didn't hit Enter).
1775
+ const currentEditorText = ctx.ui.getEditorText?.() ?? "";
1776
+ if (currentEditorText.trim()) {
1777
+ ctx.ui.setEditorText(finalText);
1778
+ wroteEditor = true;
1779
+ }
1780
+ }
1781
+ } catch (err) {
1782
+ editorWriteFailed = true;
1783
+ voiceDebug("editor write threw — continuing the completion", { error: String(err) });
1784
+ }
1785
+ }
1357
1786
 
1358
- if (isLocal) {
1359
- // Local backend (batch mode): no interim transcripts were sent to the editor,
1360
- // so we must always insert the final text. This is the ONLY place it arrives.
1361
- ctx.ui.setEditorText(finalText);
1362
- } else {
1363
- // Streaming backend: interim transcripts already updated the editor live.
1364
- // Only set final text if the editor still has content (user didn't hit Enter).
1365
- const currentEditorText = ctx.ui.getEditorText?.() ?? "";
1366
- if (currentEditorText.trim()) {
1367
- ctx.ui.setEditorText(finalText);
1787
+ if (polishOutcome !== undefined) {
1788
+ const final = finalizePolishDisposition(polishOutcome, wroteEditor, editorWriteFailed);
1789
+ polishOutcome = final.status;
1790
+ if (polishTelemetry) {
1791
+ voiceDebug("polish result", {
1792
+ ...polishTelemetry,
1793
+ disposition: final.disposition,
1794
+ reason: editorWriteFailed
1795
+ ? "editor-write-failed"
1796
+ : !wroteEditor && !skipWrite
1797
+ ? "editor-write-skipped"
1798
+ : polishTelemetry.reason,
1799
+ });
1368
1800
  }
1369
1801
  }
1370
1802
 
@@ -1373,7 +1805,7 @@ export default function (pi: ExtensionAPI) {
1373
1805
  // agent immediately instead of sitting in the editor
1374
1806
  // waiting for [enter]. Defaults OFF; user toggles via
1375
1807
  // /voice-autosubmit or settings panel.
1376
- if (config.autoSubmitOnSpeak === true && finalText.trim().length > 0) {
1808
+ if (config.autoSubmitOnSpeak === true && finalText.trim().length > 0 && !skipWrite) {
1377
1809
  // v7.2.3 — if the agent is currently mid-turn
1378
1810
  // (especially mid-retry), DON'T auto-submit.
1379
1811
  // followUp queueing during a retry pile-up
@@ -1443,22 +1875,37 @@ export default function (pi: ExtensionAPI) {
1443
1875
  }
1444
1876
  } else {
1445
1877
  voiceDebug("autoSubmitOnSpeak: pi.sendUserMessage not available on this Pi version");
1446
- ctx.ui.notify(
1447
- "Auto-submit ON but unavailable on this Pi version (need pi.sendUserMessage). " +
1448
- "Press [enter] to send, or update Pi.",
1449
- "warning"
1450
- );
1878
+ try {
1879
+ ctx.ui.notify(
1880
+ "Auto-submit ON but unavailable on this Pi version (need pi.sendUserMessage). " +
1881
+ "Press [enter] to send, or update Pi.",
1882
+ "warning"
1883
+ );
1884
+ } catch (err) {
1885
+ // R24: a failed warning must not skip the history record or the tail.
1886
+ voiceDebug("auto-submit unavailable notify threw", { error: String(err) });
1887
+ }
1451
1888
  }
1452
1889
  } // end else (agent not busy)
1453
1890
  }
1454
1891
 
1455
- const elapsed = ((Date.now() - recordingStart) / 1000).toFixed(1);
1456
- addToHistory(fullText, parseFloat(elapsed));
1892
+ addToHistory(fullText, parseFloat(elapsed), "hold", {
1893
+ writtenText: wroteEditor ? finalText : undefined,
1894
+ rawFullText: prefix + fullText,
1895
+ polishedApplied: wroteEditor && spokenText !== fullText,
1896
+ polishOutcome,
1897
+ });
1457
1898
  }
1458
1899
  playSound("stop");
1459
1900
  // Full state reset on successful completion
1460
1901
  resetHoldState();
1461
- setVoiceState("idle");
1902
+ // R24: the last call of the callback is a UI render via setVoiceState —
1903
+ // swallow it so the float promise cannot reject after the tail.
1904
+ try {
1905
+ setVoiceState("idle");
1906
+ } catch (err) {
1907
+ voiceDebug("idle transition threw", { error: String(err) });
1908
+ }
1462
1909
  },
1463
1910
  onError: (err: string) => {
1464
1911
  activeSession = null;
@@ -2161,6 +2608,11 @@ export default function (pi: ExtensionAPI) {
2161
2608
  abortSession(activeSession);
2162
2609
  activeSession = null;
2163
2610
  }
2611
+ // The pass can already be pending when activeSession is null (the normal
2612
+ // finalizing case), so this sits outside the block above (ruling R4).
2613
+ const passSnapshot = activePolishPass !== null ? polishPassEditorSnapshot : null;
2614
+ const userEditedDuringPass = passSnapshot !== null && readEditorOrFailed() !== passSnapshot;
2615
+ invalidatePolishPass("cancelled");
2164
2616
  clearRecordingAnimTimer();
2165
2617
  clearWarmupWidget();
2166
2618
  hideWidget();
@@ -2168,8 +2620,12 @@ export default function (pi: ExtensionAPI) {
2168
2620
  clearInterval(statusTimer);
2169
2621
  statusTimer = null;
2170
2622
  }
2171
- // Restore editor text to what it was before recording
2172
- if (ctx?.hasUI) ctx.ui.setEditorText(editorTextBeforeVoice);
2623
+ // Restore editor text to what it was before recording — but only while the
2624
+ // extension still owns the editor. A pending pass has written nothing, so an
2625
+ // editor that differs from the pass snapshot holds text the user typed while
2626
+ // waiting; restoring would delete it. With no pass pending, the old behaviour
2627
+ // stands: the live interim text is cleared.
2628
+ if (ctx?.hasUI && !userEditedDuringPass) ctx.ui.setEditorText(editorTextBeforeVoice);
2173
2629
  resetHoldState();
2174
2630
  playSound("error");
2175
2631
  setVoiceState("idle");
@@ -2269,6 +2725,7 @@ export default function (pi: ExtensionAPI) {
2269
2725
  }
2270
2726
 
2271
2727
  ctx = startCtx;
2728
+ invalidatePolishPass(`session-${reason}`);
2272
2729
  currentCwd = startCtx.cwd;
2273
2730
  const loaded = loadConfigWithSource(startCtx.cwd);
2274
2731
  config = loaded.config;
@@ -2279,7 +2736,7 @@ export default function (pi: ExtensionAPI) {
2279
2736
  // actually loaded after a `pi install .` (path-installed
2280
2737
  // extensions cache modules across `pi install` reinstalls — only
2281
2738
  // a fresh `pi` process picks up source changes).
2282
- voiceDebug("pi-listen v7.1.3 loaded", { reason });
2739
+ voiceDebug("pi-voicekit loaded", { reason });
2283
2740
 
2284
2741
  // Migration / setup runs on EVERY session_start, regardless of reason.
2285
2742
  // Only the first-run notification is gated on isStartup.
@@ -2323,7 +2780,7 @@ export default function (pi: ExtensionAPI) {
2323
2780
  ? `Local model: ${LOCAL_MODELS.find((m) => m.id === config.localModel)?.name || config.localModel} (offline, batch mode)`
2324
2781
  : "Deepgram Nova-3 (cloud, live streaming)";
2325
2782
  const lines = [
2326
- "pi-listen ready!",
2783
+ "pi-voicekit ready!",
2327
2784
  "",
2328
2785
  " Hold SPACE to record → release to transcribe",
2329
2786
  ` ${toggleShortcutLabel} to toggle recording`,
@@ -2339,7 +2796,7 @@ export default function (pi: ExtensionAPI) {
2339
2796
  // No backend configured — show install hint only on actual startup.
2340
2797
  if (!isStartup) return;
2341
2798
  const lines = [
2342
- "pi-listen installed — voice input for Pi",
2799
+ "pi-voicekit installed — voice input for Pi",
2343
2800
  "",
2344
2801
  " Two backends available:",
2345
2802
  " • Deepgram — cloud, live streaming, $200 free credit (6–12 months of use)",
@@ -2366,6 +2823,7 @@ export default function (pi: ExtensionAPI) {
2366
2823
  voiceDebug("voiceCleanup threw during shutdown", { error: String(err) });
2367
2824
  }
2368
2825
  ctx = null;
2826
+ invalidatePolishPass("session-shutdown");
2369
2827
 
2370
2828
  // Clear the sherpa recognizer cache ONLY on terminal quit. On older Pi
2371
2829
  // versions (< 0.65.0) shutdown handlers are not awaited before the
@@ -2387,6 +2845,20 @@ export default function (pi: ExtensionAPI) {
2387
2845
  }
2388
2846
  });
2389
2847
 
2848
+ // A submitted user message and a branch navigation both end the window in which a
2849
+ // pending pass may still write. On the local backend the pass has written nothing
2850
+ // yet, so the editor-equality guard passes after the user types a message and
2851
+ // submits it (the editor returns to empty), and branch navigation fires
2852
+ // session_tree rather than session_start — either way a stale transcript could
2853
+ // reappear and auto-send. Invalidating twice is harmless: the operation is
2854
+ // idempotent.
2855
+ pi.on("input", async () => {
2856
+ invalidatePolishPass("user-input", "complete");
2857
+ });
2858
+ pi.on("session_tree", async () => {
2859
+ invalidatePolishPass("session-tree", "complete");
2860
+ });
2861
+
2390
2862
  // Note: pi-mono < 0.65.0 fired a discrete "session_switch" event for
2391
2863
  // /new, /resume, /fork. That event was removed in 0.65.0 in favor of the
2392
2864
  // session_shutdown → session_start (with reason) flow handled above.
@@ -2955,27 +3427,27 @@ export default function (pi: ExtensionAPI) {
2955
3427
  // ─── /voice-setup → redirects to settings panel ─────────────────────────
2956
3428
 
2957
3429
  pi.registerCommand("voice-setup", {
2958
- description: "Open pi-listen settings panel",
3430
+ description: "Open pi-voicekit settings panel",
2959
3431
  handler: async (_args, cmdCtx) => openSettingsPanel(cmdCtx),
2960
3432
  });
2961
3433
 
2962
3434
  // ─── /voice-language → redirects to settings panel ───────────────────────
2963
3435
 
2964
3436
  pi.registerCommand("voice-language", {
2965
- description: "Open pi-listen settings to change language",
3437
+ description: "Open pi-voicekit settings to change language",
2966
3438
  handler: async (_args, cmdCtx) => openSettingsPanel(cmdCtx),
2967
3439
  });
2968
3440
 
2969
3441
  // ─── /voice-help → v7.1 §11 keyboard / command reference ────────────────
2970
3442
 
2971
3443
  pi.registerCommand("voice-help", {
2972
- description: "Show pi-listen keyboard + command reference",
3444
+ description: "Show pi-voicekit keyboard + command reference",
2973
3445
  handler: async (_args, cmdCtx) => openHelpOverlay(cmdCtx),
2974
3446
  });
2975
3447
 
2976
3448
  async function openHelpOverlay(cmdCtx: ExtensionCommandContext): Promise<void> {
2977
3449
  if (!cmdCtx.hasUI) {
2978
- cmdCtx.ui.notify("pi-listen: hold space=record · /voice-speak <text> · /voice-settings · /voice-help", "info");
3450
+ cmdCtx.ui.notify("pi-voicekit: hold space=record · /voice-speak <text> · /voice-settings · /voice-help", "info");
2979
3451
  return;
2980
3452
  }
2981
3453
  const { HelpOverlay } = await import("./voice/ui-help-overlay");
@@ -3019,6 +3491,13 @@ export default function (pi: ExtensionAPI) {
3019
3491
  },
3020
3492
  resolveApiKey: () => resolveDeepgramApiKey(config) ?? undefined,
3021
3493
  deepgramLanguages: LANGUAGES.map((l) => ({ name: l.name, code: l.code, popular: l.popular })),
3494
+ // Polish tab: the picker rows come from the same helper /voice-polish uses, so
3495
+ // the panel keeps making no Pi API calls of its own.
3496
+ getPolishModels: getPolishModelChoices,
3497
+ // Item 6: the polish numbers go to the scope the config was loaded from, not to
3498
+ // the in-memory field a project file can set.
3499
+ getPolishScope: polishWriteScope,
3500
+ getLastDictation: () => recordingHistory.find((item) => item.polishedApplied),
3022
3501
  };
3023
3502
 
3024
3503
  let panel!: InstanceType<typeof VoiceSettingsPanel>;
@@ -3492,6 +3971,167 @@ export default function (pi: ExtensionAPI) {
3492
3971
  },
3493
3972
  });
3494
3973
 
3974
+ /**
3975
+ * Model choices for the /voice-polish picker: every text-capable model Pi
3976
+ * exposes, as canonical `provider/id` references. Task 7 reuses this for the
3977
+ * settings panel — do not duplicate the filter.
3978
+ */
3979
+ function getPolishModelChoices(): { ref: string; label: string }[] {
3980
+ const models =
3981
+ ctx && ctx.scopedModels.length > 0
3982
+ ? ctx.scopedModels.map((entry) => entry.model)
3983
+ : (ctx?.modelRegistry.getAvailable() ?? []);
3984
+ return models
3985
+ .filter((model) => model.input.includes("text"))
3986
+ .map((model) => ({ ref: `${model.provider}/${model.id}`, label: model.name || model.id }));
3987
+ }
3988
+
3989
+ pi.registerCommand("voice-polish", {
3990
+ description: "Voice: /voice-polish [on|off|model|turns <0-10>|last|restore]",
3991
+ handler: async (args, cmdCtx) => {
3992
+ ctx = cmdCtx;
3993
+ const sub = (args || "").trim();
3994
+ // R27: subcommand names are case-insensitive, like /voice-autosubmit.
3995
+ const [rawVerb, ...rest] = sub.split(/\s+/);
3996
+ const verb = rawVerb.toLowerCase();
3997
+
3998
+ if (!verb || verb === "status") {
3999
+ cmdCtx.ui.notify(
4000
+ [
4001
+ `Voice polish: ${config.postProcessEnabled !== false ? "on" : "off"}`,
4002
+ ` model: ${config.postProcessModel ?? "session"}`,
4003
+ ` turns: ${config.postProcessContextTurns ?? DEFAULT_CONTEXT_LIMITS.turns}`,
4004
+ ` timeout: ${config.postProcessTimeoutMs ?? 8000} ms`,
4005
+ ].join("\n"),
4006
+ "info"
4007
+ );
4008
+ return;
4009
+ }
4010
+ if (verb === "on" || verb === "off") {
4011
+ // D7/R26: enablement is global-only, so it is written field by field to the
4012
+ // GLOBAL file. A project block would be stripped by the serializer (and ignored
4013
+ // on load) — the command would report success and the setting would silently
4014
+ // revert on the next /reload.
4015
+ const next = verb === "on";
4016
+ if (
4017
+ !saveGlobalVoiceFieldsOrNotify({ postProcessEnabled: next }, (message) =>
4018
+ cmdCtx.ui.notify(message, "warning")
4019
+ )
4020
+ ) {
4021
+ return;
4022
+ }
4023
+ config.postProcessEnabled = next;
4024
+ cmdCtx.ui.notify(`Voice polish ${next ? "enabled" : "disabled"}.`, "info");
4025
+ return;
4026
+ }
4027
+ if (verb === "model") {
4028
+ // Model selection is a picker, like /model and /workflow-model — never a
4029
+ // hand-typed reference (maintainer decision, 2026-09-26).
4030
+ if (rest.length > 0) {
4031
+ cmdCtx.ui.notify(
4032
+ "Voice polish: pick the model from the list — run /voice-polish model with no argument.",
4033
+ "warning"
4034
+ );
4035
+ return;
4036
+ }
4037
+ // R27: guard the headless path BEFORE building the rows — the list would call
4038
+ // the model registry for a list nobody can see.
4039
+ if (!cmdCtx.hasUI || typeof cmdCtx.ui.select !== "function") {
4040
+ cmdCtx.ui.notify(`Current polish model: ${config.postProcessModel ?? "session"}`, "info");
4041
+ return;
4042
+ }
4043
+ const options = polishModelOptions(getPolishModelChoices(), config.postProcessModel);
4044
+ const picked = await cmdCtx.ui.select(
4045
+ "Polish model",
4046
+ options.map((option) => option.label)
4047
+ );
4048
+ const chosen = options.find((option) => option.label === picked);
4049
+ if (!chosen) return; // dismissed — keep the current value
4050
+ // R26: model choice is global-only — field-level write to the global file.
4051
+ if (
4052
+ !saveGlobalVoiceFieldsOrNotify({ postProcessModel: chosen.value }, (message) =>
4053
+ cmdCtx.ui.notify(message, "warning")
4054
+ )
4055
+ ) {
4056
+ return;
4057
+ }
4058
+ config.postProcessModel = chosen.value;
4059
+ cmdCtx.ui.notify(`Voice polish model set to ${chosen.value}.`, "info");
4060
+ return;
4061
+ }
4062
+ if (verb === "turns") {
4063
+ const turns = Number(rest[0]);
4064
+ if (!Number.isInteger(turns) || turns < 0 || turns > 10) {
4065
+ cmdCtx.ui.notify("Usage: /voice-polish turns <0-10>", "warning");
4066
+ return;
4067
+ }
4068
+ config.postProcessContextTurns = turns;
4069
+ // R25 + item 6: the turn count is honoured in both scopes, so it is persisted at
4070
+ // the scope this session actually loads from — a write to any other file would be
4071
+ // overridden by the project block on the next /reload and report a success that
4072
+ // does not stick.
4073
+ saveConfig(config, polishWriteScope(), currentCwd);
4074
+ cmdCtx.ui.notify(`Voice polish context turns set to ${turns}.`, "info");
4075
+ return;
4076
+ }
4077
+ if (verb === "last") {
4078
+ // The newest dictation a pass ran for — a discarded one is invisible to
4079
+ // `restore`, but its raw text stays reachable here, which is what the
4080
+ // retention promise is about.
4081
+ const entry = recordingHistory.find((item) => item.polishOutcome !== undefined);
4082
+ if (!entry) {
4083
+ cmdCtx.ui.notify("No polished dictation in this session yet.", "info");
4084
+ return;
4085
+ }
4086
+ cmdCtx.ui.notify(
4087
+ [
4088
+ `STATUS: ${entry.polishOutcome}`,
4089
+ `RAW: ${entry.rawFullText ?? entry.text}`,
4090
+ entry.writtenText !== undefined
4091
+ ? `WRITTEN: ${entry.writtenText}`
4092
+ : "WRITTEN: (nothing — the editor kept your text)",
4093
+ ].join("\n"),
4094
+ "info"
4095
+ );
4096
+ return;
4097
+ }
4098
+ if (verb === "restore") {
4099
+ // Stricter than `last`: only a dictation that owns an editor write can be
4100
+ // restored, whatever the pass status was.
4101
+ const entry = recordingHistory.find((item) => item.writtenText !== undefined);
4102
+ if (!entry) {
4103
+ cmdCtx.ui.notify("No polished dictation in this session yet.", "info");
4104
+ return;
4105
+ }
4106
+ if (entry.rawFullText === undefined) {
4107
+ cmdCtx.ui.notify("That dictation stored no raw transcript — nothing to restore.", "warning");
4108
+ return;
4109
+ }
4110
+ // Compare against the exact string this feature last wrote (`prefix + polished`) —
4111
+ // which is why history stores it. Comparing against the bare transcript would
4112
+ // always pass whenever the user had a draft, i.e. the guard would be a no-op.
4113
+ const decision = decideApply({
4114
+ tokenCurrent: true,
4115
+ // `writtenText` is guaranteed by the lookup above (R27) — no runtime re-check.
4116
+ editorSnapshot: entry.writtenText!,
4117
+ currentEditor: cmdCtx.ui.getEditorText(),
4118
+ });
4119
+ if (!decision.apply) {
4120
+ cmdCtx.ui.notify(
4121
+ "Editor changed since that dictation — not restoring. Copy from /voice-polish last.",
4122
+ "warning"
4123
+ );
4124
+ return;
4125
+ }
4126
+ // Write `prefix + raw`: a restore must not delete what the user typed before dictating.
4127
+ cmdCtx.ui.setEditorText(entry.rawFullText);
4128
+ cmdCtx.ui.notify("Restored the raw transcript into the editor.", "info");
4129
+ return;
4130
+ }
4131
+ cmdCtx.ui.notify("Usage: /voice-polish [on|off|model|turns <0-10>|last|restore]", "warning");
4132
+ },
4133
+ });
4134
+
3495
4135
  pi.registerCommand("voice-speak-stop", {
3496
4136
  description: "Stop in-flight TTS playback",
3497
4137
  handler: async (_args, cmdCtx) => {
@@ -3637,10 +4277,10 @@ export default function (pi: ExtensionAPI) {
3637
4277
  handler: async (_args, cmdCtx) => openSettingsPanel(cmdCtx, 3),
3638
4278
  });
3639
4279
 
3640
- // ─── /voice-settings — unified pi-listen settings panel ─────────────
4280
+ // ─── /voice-settings — unified pi-voicekit settings panel ─────────────
3641
4281
 
3642
4282
  pi.registerCommand("voice-settings", {
3643
- description: "Open pi-listen settings — backend, models, language, device",
4283
+ description: "Open pi-voicekit settings — backend, models, language, device",
3644
4284
  handler: async (_args, cmdCtx) => openSettingsPanel(cmdCtx),
3645
4285
  });
3646
4286