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.
@@ -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,18 @@ 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
+ buildPolishAudit,
107
+ polishSamplingOptions,
108
+ decideApply,
109
+ finalizePolishDisposition,
110
+ EDITOR_READ_FAILED,
111
+ parseModelRef,
112
+ polishModelOptions,
113
+ polishTranscript,
114
+ resolveModelChoice,
115
+ } from "./voice/post-process";
116
+ import { DEFAULT_CONTEXT_LIMITS } from "./voice/post-process-context";
104
117
 
105
118
  /** Adapter for the real event loop — lets GapTimer run under the real setTimeout. */
106
119
  const realTimerPort: TimerPort = {
@@ -491,6 +504,10 @@ function startStreamingSession(
491
504
  };
492
505
 
493
506
  ws.onmessage = (event: MessageEvent) => {
507
+ // R22: once the session is finalized (finalizeSession / abortSession) the
508
+ // transport must stop writing the editor — a late Results message would
509
+ // otherwise overwrite what the user typed while the polish pass waited.
510
+ if (session.closed) return;
494
511
  try {
495
512
  const msg = typeof event.data === "string" ? JSON.parse(event.data) : null;
496
513
  if (!msg) return;
@@ -752,6 +769,298 @@ export default function (pi: ExtensionAPI) {
752
769
 
753
770
  // Streaming session state
754
771
  let activeSession: VoiceSession | null = null;
772
+
773
+ // ─── Transcript polish (post-processing) ─────────────────────────────────
774
+ // One bounded pass between the final transcript and the editor write. The
775
+ // token below is what makes a late result inert (spec §4.1.1).
776
+ type PolishPassToken = { invalidated: "discard" | "abort" | null };
777
+ let activePolishPass: PolishPassToken | null = null;
778
+ /**
779
+ * Editor value the pending pass started from, or null when no pass is pending. The
780
+ * escape handler compares the live editor against it: a pending pass has written
781
+ * nothing, so a difference is text the user typed while waiting.
782
+ */
783
+ let polishPassEditorSnapshot: string | null = null;
784
+
785
+ function invalidatePolishPass(reason: string, cleanup: "complete" | "relinquish" = "relinquish"): void {
786
+ const pass = activePolishPass;
787
+ if (!pass) return;
788
+ const disposition = cleanup === "complete" ? "discard" : "abort";
789
+ if (pass.invalidated !== disposition) voiceDebug("polish pass invalidated", { reason });
790
+ pass.invalidated = disposition;
791
+ // Retain a discarded pass until its tail completes, or a later teardown takes
792
+ // ownership. The awaiting callback retains this token even after relinquishing.
793
+ if (cleanup === "relinquish") {
794
+ activePolishPass = null;
795
+ polishPassEditorSnapshot = null;
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Ownership reads go through here: a throwing editor read is a failed read and
801
+ * never an unchanged editor (spec invariant 2).
802
+ */
803
+ function readEditorOrFailed(): string | typeof EDITOR_READ_FAILED {
804
+ try {
805
+ return ctx?.ui.getEditorText?.() ?? "";
806
+ } catch (err) {
807
+ voiceDebug("editor read threw — treating the text as unreadable", { error: String(err) });
808
+ return EDITOR_READ_FAILED;
809
+ }
810
+ }
811
+
812
+ function polishModelLookup(provider: string, modelId: string): { model: unknown; hasAuth: boolean } | undefined {
813
+ const found = ctx?.modelRegistry.find(provider, modelId);
814
+ if (!found) return undefined;
815
+ return { model: found, hasAuth: ctx!.modelRegistry.hasConfiguredAuth(found) };
816
+ }
817
+
818
+ /**
819
+ * Scope the polish numbers are persisted to. `config.scope` is an in-memory field a
820
+ * project file can set itself, so reading it can write to one file while the loader
821
+ * keeps reading the other: the command reports success and a reload shows the old
822
+ * value. `configSource` is where this session's config was actually loaded from; only
823
+ * a session with no file at all (defaults) falls back to the field. Polish settings
824
+ * only — the pre-existing commands keep their own rule.
825
+ */
826
+ function polishWriteScope(): VoiceSettingsScope {
827
+ if (configSource === "global" || configSource === "project") return configSource;
828
+ return config.scope === "project" ? "project" : "global";
829
+ }
830
+
831
+ /**
832
+ * R30: `saveGlobalVoiceFields` refuses to overwrite a settings file it cannot read and
833
+ * throws. Every user-reachable caller reports that refusal in plain words instead of
834
+ * letting it surface as an unhandled error; the in-memory value is only updated when
835
+ * the write actually succeeded.
836
+ */
837
+ function saveGlobalVoiceFieldsOrNotify(
838
+ fields: Parameters<typeof saveGlobalVoiceFields>[0],
839
+ notify: (message: string) => void
840
+ ): boolean {
841
+ try {
842
+ saveGlobalVoiceFields(fields);
843
+ return true;
844
+ } catch {
845
+ notify("Voice polish: the settings file could not be read — nothing was changed.");
846
+ return false;
847
+ }
848
+ }
849
+
850
+ /**
851
+ * The model the pass actually ran on, as `provider/id`. The configured value is the
852
+ * "session" marker whenever the session model is in play, so a telemetry line built
853
+ * from it alone cannot be grouped per model.
854
+ */
855
+ function polishModelLabel(choice: { model?: unknown; ref: string }): string {
856
+ const model = choice.model as { provider?: unknown; id?: unknown } | undefined;
857
+ if (model && typeof model.provider === "string" && typeof model.id === "string") {
858
+ return `${model.provider}/${model.id}`;
859
+ }
860
+ return choice.ref;
861
+ }
862
+
863
+ /** What the polish pass did with one dictation — recorded on the history entry for `last`. */
864
+ type PolishOutcomeStatus = "applied" | "discarded" | "failed";
865
+
866
+ /**
867
+ * Outcome of one polish pass.
868
+ * - `apply`: write `text` (the pass's rewrite, or the raw transcript on any failure).
869
+ * - `discard`: the editor changed while we waited — write NOTHING, send NOTHING.
870
+ * - `abort`: a newer recording or session owns the flow — leave the state alone.
871
+ * `status` is provisional until the caller attempts the write. History and the
872
+ * telemetry disposition are finalized together from the actual write result.
873
+ */
874
+ type PolishOutcome = (
875
+ | { action: "apply"; text: string; status: "applied" | "failed" }
876
+ | { action: "discard"; text: string; status: "discarded" }
877
+ | { action: "abort"; text: string }
878
+ ) & { telemetry?: PolishTelemetry };
879
+ type PolishTelemetry = {
880
+ model: string;
881
+ configured: string;
882
+ status: string;
883
+ ms: number;
884
+ contextChars?: number;
885
+ truncated?: boolean;
886
+ reason?: string;
887
+ error?: string;
888
+ };
889
+ async function runPolishPass(raw: string, editorSnapshot: string): Promise<PolishOutcome> {
890
+ const id: PolishPassToken = { invalidated: null };
891
+ activePolishPass = id;
892
+ polishPassEditorSnapshot = editorSnapshot;
893
+ if (!config.postProcessNoticeShown && ctx?.hasUI) {
894
+ // D5: one-time disclosure. The flag is set in memory first (an assignment cannot
895
+ // throw); the write and the notification then sit in guards of their own. A
896
+ // read-only config directory must cost the flag's persistence, never the
897
+ // disclosure — the in-memory flag still suppresses a repeat in this process.
898
+ config.postProcessNoticeShown = true;
899
+ try {
900
+ // R26: field-level global write — `config` also carries this project's values, so
901
+ // a whole-block write would reset unrelated machine-global settings. Persist the
902
+ // flag BEFORE notifying, per the house rule in tts-onboarding.
903
+ saveGlobalVoiceFields({ postProcessNoticeShown: true });
904
+ } catch (error) {
905
+ voiceDebug("polish notice setting write failed", String(error));
906
+ }
907
+ try {
908
+ const turns = config.postProcessContextTurns ?? DEFAULT_CONTEXT_LIMITS.turns;
909
+ ctx.ui.notify(
910
+ [
911
+ "Voice polish is on: every dictation makes one extra model call,",
912
+ turns > 0
913
+ ? `and the last ${turns} conversation turns are sent with it.`
914
+ : "and no conversation context is sent with it.",
915
+ "Turn it off with /voice-polish off.",
916
+ ].join(" "),
917
+ "info"
918
+ );
919
+ } catch (error) {
920
+ voiceDebug("polish notice notification failed", String(error));
921
+ }
922
+ }
923
+ // R19: everything before the model call is fail-open too — a throw here
924
+ // (registry lookup, notify, status) must not cost the user their dictation.
925
+ let choice: ReturnType<typeof resolveModelChoice>;
926
+ try {
927
+ choice = resolveModelChoice(parseModelRef(config.postProcessModel), polishModelLookup, ctx?.model);
928
+ } catch (err) {
929
+ activePolishPass = null;
930
+ polishPassEditorSnapshot = null;
931
+ voiceDebug("polish model resolution threw — using the raw transcript", { error: String(err) });
932
+ return { action: "apply", text: raw, status: "failed" };
933
+ }
934
+ if (!choice.model) {
935
+ voiceDebug("polish skipped", { ref: choice.ref, reason: choice.reason });
936
+ try {
937
+ if (choice.reason === "malformed") {
938
+ ctx?.ui.notify(
939
+ `Voice polish: "${choice.ref}" is not a provider/modelId reference — pick a model with /voice-polish model. Using the raw transcript.`,
940
+ "warning"
941
+ );
942
+ } else if (choice.reason === "not-found" || choice.reason === "no-auth") {
943
+ const why = choice.reason === "not-found" ? "is not available" : "has no configured authentication";
944
+ ctx?.ui.notify(`Voice polish: model ${choice.ref} ${why} — using the raw transcript.`, "warning");
945
+ }
946
+ } catch (err) {
947
+ voiceDebug("polish skip notify threw", { error: String(err) });
948
+ }
949
+ activePolishPass = null;
950
+ polishPassEditorSnapshot = null;
951
+ return { action: "apply", text: raw, status: "failed" };
952
+ }
953
+ const model = choice.model;
954
+ const started = Date.now();
955
+ try {
956
+ ctx?.ui.setStatus("voice", "polishing…");
957
+ } catch (err) {
958
+ voiceDebug("polish status write threw", { error: String(err) });
959
+ }
960
+ try {
961
+ const result = await polishTranscript({
962
+ raw,
963
+ entries: ctx?.sessionManager.buildContextEntries() ?? [],
964
+ limits: {
965
+ turns: config.postProcessContextTurns ?? DEFAULT_CONTEXT_LIMITS.turns,
966
+ perEntryChars: DEFAULT_CONTEXT_LIMITS.perEntryChars,
967
+ totalChars: DEFAULT_CONTEXT_LIMITS.totalChars,
968
+ },
969
+ timeoutMs: config.postProcessTimeoutMs ?? 8000,
970
+ timestamp: Date.now(),
971
+ isCurrent: () => activePolishPass === id && id.invalidated === null,
972
+ call: (request, signal) =>
973
+ ctx!.modelRegistry.complete(
974
+ model as never,
975
+ { systemPrompt: request.systemPrompt, messages: request.messages as never },
976
+ {
977
+ signal,
978
+ maxTokens: request.maxTokens,
979
+ // Measured 2026-09-26: without this a reasoning model spends the whole budget thinking
980
+ // about a long dictation and the pass falls back to the raw text — see
981
+ // polishSamplingOptions.
982
+ ...polishSamplingOptions(model as { reasoning?: boolean }, raw.length),
983
+ }
984
+ ),
985
+ debug: (reason, data) => voiceDebug(`polish ${reason}`, data),
986
+ });
987
+ const telemetry = {
988
+ model: polishModelLabel(choice),
989
+ configured: choice.ref,
990
+ status: result.status,
991
+ ms: Date.now() - started,
992
+ contextChars: result.contextChars,
993
+ truncated: result.truncatedContext,
994
+ };
995
+ // A newer recording or session owns the editor now: change nothing at all.
996
+ if (activePolishPass !== id) {
997
+ voiceDebug("polish result", { ...telemetry, disposition: "aborted", reason: "invalidated" });
998
+ return { action: "abort", text: raw };
999
+ }
1000
+ const decision = decideApply({
1001
+ tokenCurrent: id.invalidated === null,
1002
+ editorSnapshot,
1003
+ currentEditor: readEditorOrFailed(),
1004
+ });
1005
+ // The caller finalizes telemetry after the editor write, not at this decision.
1006
+ const pendingTelemetry = { ...telemetry, reason: decision.apply ? result.reason : decision.reason };
1007
+ if (!decision.apply) {
1008
+ return { action: "discard", text: raw, status: "discarded", telemetry: pendingTelemetry };
1009
+ }
1010
+ return {
1011
+ action: "apply",
1012
+ text: result.status === "applied" ? result.text : raw,
1013
+ status: result.status === "applied" ? "applied" : "failed",
1014
+ telemetry: pendingTelemetry,
1015
+ };
1016
+ } catch (err) {
1017
+ // Item 3: a throw here is decided by ownership, not by convenience. The pass's
1018
+ // verdict is unavailable, so the raw transcript is the fallback — but only while
1019
+ // the pass still owns a matching editor: an editor that changed, and equally one
1020
+ // that could not be read, means discard (no write, no dispatch, the dictation is
1021
+ // still recorded by the caller).
1022
+ if (activePolishPass !== id) {
1023
+ voiceDebug("polish result", {
1024
+ model: polishModelLabel(choice),
1025
+ configured: choice.ref,
1026
+ status: "failed",
1027
+ disposition: "aborted",
1028
+ reason: "invalidated",
1029
+ ms: Date.now() - started,
1030
+ error: String(err),
1031
+ });
1032
+ return { action: "abort", text: raw };
1033
+ }
1034
+ const decision = decideApply({
1035
+ tokenCurrent: id.invalidated === null,
1036
+ editorSnapshot,
1037
+ currentEditor: readEditorOrFailed(),
1038
+ });
1039
+ const telemetry: PolishTelemetry = {
1040
+ model: polishModelLabel(choice),
1041
+ configured: choice.ref,
1042
+ status: "failed",
1043
+ ms: Date.now() - started,
1044
+ reason: decision.reason ?? "pass-threw",
1045
+ error: String(err),
1046
+ };
1047
+ if (!decision.apply) return { action: "discard", text: raw, status: "discarded", telemetry };
1048
+ return { action: "apply", text: raw, status: "failed", telemetry };
1049
+ } finally {
1050
+ // R20: a stale pass must not restore its status text over the flow that
1051
+ // replaced it — and a cosmetic status write is never allowed to throw.
1052
+ if (activePolishPass === id) {
1053
+ activePolishPass = null;
1054
+ polishPassEditorSnapshot = null;
1055
+ try {
1056
+ updateVoiceStatus();
1057
+ } catch (err) {
1058
+ voiceDebug("polish status restore threw", { error: String(err) });
1059
+ }
1060
+ }
1061
+ }
1062
+ }
1063
+
755
1064
  let preRecordingSession: StreamingSession | null = null; // Started during warmup, promoted on confirm (Deepgram only)
756
1065
 
757
1066
  let lastStopTime = 0; // For Escape-to-clear-editor within 30s of recording
@@ -780,13 +1089,35 @@ export default function (pi: ExtensionAPI) {
780
1089
  timestamp: number;
781
1090
  duration: number;
782
1091
  mode: "hold" | "toggle" | "dictate";
1092
+ /** The exact string this feature wrote to the editor, when it wrote one. */
1093
+ writtenText?: string;
1094
+ /** `prefix + raw ASR output` — what a restore puts back. */
1095
+ rawFullText?: string;
1096
+ /** True when a polish rewrite replaced the raw text; the Polish tab's Last dictation row looks for these. */
1097
+ polishedApplied?: boolean;
1098
+ /**
1099
+ * What the pass did with this dictation, including a discarded one. Set whenever a
1100
+ * pass ran, so `/voice-polish last` can show the newest dictation it processed
1101
+ * rather than the newest one it wrote. Absent when no pass ran at all.
1102
+ */
1103
+ polishOutcome?: PolishOutcomeStatus;
783
1104
  }
784
1105
 
785
1106
  const recordingHistory: RecordingHistoryEntry[] = [];
786
1107
  const MAX_HISTORY = 50;
787
1108
 
788
- function addToHistory(text: string, duration: number, mode: "hold" | "toggle" | "dictate" = "hold") {
789
- recordingHistory.unshift({ text, timestamp: Date.now(), duration, mode });
1109
+ function addToHistory(
1110
+ text: string,
1111
+ duration: number,
1112
+ mode: "hold" | "toggle" | "dictate" = "hold",
1113
+ extra: {
1114
+ writtenText?: string;
1115
+ rawFullText?: string;
1116
+ polishedApplied?: boolean;
1117
+ polishOutcome?: PolishOutcomeStatus;
1118
+ } = {}
1119
+ ) {
1120
+ recordingHistory.unshift({ text, timestamp: Date.now(), duration, mode, ...extra });
790
1121
  if (recordingHistory.length > MAX_HISTORY) recordingHistory.pop();
791
1122
  }
792
1123
 
@@ -892,7 +1223,14 @@ export default function (pi: ExtensionAPI) {
892
1223
  }
893
1224
 
894
1225
  function hideWidget() {
895
- if (ctx?.hasUI) ctx.ui.setWidget("voice-recording", undefined);
1226
+ if (!ctx?.hasUI) return;
1227
+ // R24: hiding the widget is cosmetic — a UI throw here must not reject the
1228
+ // completion callback before its write, its history record and its tail.
1229
+ try {
1230
+ ctx.ui.setWidget("voice-recording", undefined);
1231
+ } catch (err) {
1232
+ voiceDebug("hideWidget threw", { error: String(err) });
1233
+ }
896
1234
  }
897
1235
 
898
1236
  /** Reset all hold-to-talk state to idle. Call after any recording stop/error/cancel. */
@@ -908,6 +1246,9 @@ export default function (pi: ExtensionAPI) {
908
1246
  }
909
1247
 
910
1248
  function voiceCleanup() {
1249
+ // R17: a pass pending when teardown starts must never write after it
1250
+ // (covers /voice off, the /voice toggle, the settings panel, shutdown).
1251
+ invalidatePolishPass("voice-disabled");
911
1252
  // v7.1: cancel in-flight installs FIRST so their AbortControllers
912
1253
  // fire before we drop UI state. Without this, a session_shutdown
913
1254
  // during a download would leave the network/disk work running
@@ -1242,6 +1583,7 @@ export default function (pi: ExtensionAPI) {
1242
1583
  // This prevents the "slow connection overlaps new recording" bug.
1243
1584
  if (voiceState === "finalizing" || voiceState === "recording") {
1244
1585
  abortSession(activeSession);
1586
+ invalidatePolishPass("new-recording");
1245
1587
  activeSession = null;
1246
1588
  clearRecordingAnimTimer();
1247
1589
  clearWarmupWidget();
@@ -1321,7 +1663,7 @@ export default function (pi: ExtensionAPI) {
1321
1663
  updateLiveTranscriptWidget(interim, finals);
1322
1664
  updateVoiceStatus();
1323
1665
  },
1324
- onDone: (fullText: string, meta: { hadAudio: boolean; hadSpeech: boolean }) => {
1666
+ onDone: async (fullText: string, meta: { hadAudio: boolean; hadSpeech: boolean }) => {
1325
1667
  voiceDebug("onDone callback", { fullText: fullText.slice(0, 100), meta, voiceState, spaceConsumed });
1326
1668
  activeSession = null;
1327
1669
  clearRecordingAnimTimer();
@@ -1337,34 +1679,154 @@ export default function (pi: ExtensionAPI) {
1337
1679
  playSound("error");
1338
1680
  // Full state reset on empty result
1339
1681
  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");
1682
+ try {
1683
+ if (!meta.hadAudio) {
1684
+ ctx?.ui.notify("Microphone captured no audio. Check mic permissions.", "error");
1685
+ } else if (!meta.hadSpeech) {
1686
+ ctx?.ui.notify("Microphone captured silence — no speech detected.", "warning");
1687
+ } else {
1688
+ ctx?.ui.notify("No speech detected.", "warning");
1689
+ }
1690
+ } catch (err) {
1691
+ // R24: a failed notification must never skip the idle transition.
1692
+ voiceDebug("no-speech notify threw", { error: String(err) });
1693
+ }
1694
+ // R24: the transition renders the status bar — keep it non-fatal too.
1695
+ try {
1696
+ setVoiceState("idle");
1697
+ } catch (err) {
1698
+ voiceDebug("idle transition threw", { error: String(err) });
1346
1699
  }
1347
- setVoiceState("idle");
1348
1700
  return;
1349
1701
  }
1350
1702
 
1351
1703
  hideWidget();
1352
1704
 
1705
+ // R23: the recorded duration is the recording length — the model wait
1706
+ // below must not be counted as recording time.
1707
+ const elapsed = ((Date.now() - recordingStart) / 1000).toFixed(1);
1708
+
1709
+ // Transcript polish: bounded, fail-open, never blocking the recording flow.
1710
+ let spokenText = fullText;
1711
+ let skipWrite = false;
1712
+ let polishOutcome: PolishOutcomeStatus | undefined;
1713
+ let polishTelemetry: PolishTelemetry | undefined;
1714
+ if (ctx?.hasUI && config.postProcessEnabled !== false) {
1715
+ // R18: the streaming transport can finalize itself (ws.onclose /
1716
+ // finalizeTimer) without going through stopVoiceRecording, so the state
1717
+ // may still be "recording" here. Hold the pass inside the finalizing
1718
+ // window so every handler-reachable teardown early-returns or
1719
+ // invalidates. Narrow on purpose: a late callback that arrives after an
1720
+ // abort must not resurrect the state.
1721
+ // R24: the transition renders the status bar (setVoiceState →
1722
+ // updateVoiceStatus → ctx.ui.setStatus), so a UI throw here used to reject
1723
+ // this callback and skip the write, the history record and the tail.
1724
+ // Wrap it — the state field is already assigned before the render.
1725
+ if (voiceState === "recording") {
1726
+ try {
1727
+ setVoiceState("finalizing");
1728
+ } catch (err) {
1729
+ voiceDebug("finalizing transition threw — continuing", { error: String(err) });
1730
+ }
1731
+ }
1732
+ let outcome: PolishOutcome;
1733
+ try {
1734
+ outcome = await runPolishPass(fullText, ctx.ui.getEditorText?.() ?? "");
1735
+ } catch (err) {
1736
+ // Item 3: the pass decides its own throws by ownership; only a failure that
1737
+ // never reached that decision lands here, for example a throwing editor
1738
+ // snapshot read (the pass's own ownership read cannot throw). Ownership was
1739
+ // never established, so the raw text may not overwrite the editor: discard.
1740
+ // The dictation is still recorded and the completion tail still runs.
1741
+ invalidatePolishPass("pass-threw");
1742
+ voiceDebug("polish pass threw before ownership — discarding the editor write", {
1743
+ error: String(err),
1744
+ });
1745
+ outcome = { action: "discard", text: fullText, status: "discarded" };
1746
+ }
1747
+ // A newer recording or session owns the flow now: touch nothing at all.
1748
+ if (outcome.action === "abort") return;
1749
+ spokenText = outcome.text;
1750
+ polishOutcome = outcome.status;
1751
+ polishTelemetry = outcome.telemetry;
1752
+ // The editor changed while we waited: keep the user's text, say so once.
1753
+ if (outcome.action === "discard") {
1754
+ skipWrite = true;
1755
+ try {
1756
+ ctx.ui.notify("Voice polish: the editor changed while I was working — kept your text.", "info");
1757
+ } catch (err) {
1758
+ voiceDebug("polish discard notify threw", { error: String(err) });
1759
+ }
1760
+ }
1761
+ }
1762
+
1353
1763
  if (ctx?.hasUI) {
1354
1764
  const prefix = editorTextBeforeVoice ? editorTextBeforeVoice + " " : "";
1355
1765
  const isLocal = config.backend === "local";
1356
- const finalText = prefix + fullText;
1766
+ const finalText = prefix + spokenText;
1767
+ // R21: history records what was actually written, not what was planned.
1768
+ let wroteEditor = false;
1769
+ let editorWriteFailed = false;
1770
+
1771
+ // A discarded pass must not write.
1772
+ if (!skipWrite) {
1773
+ // R24: the editor read/write is a UI call — a throw must leave
1774
+ // `wroteEditor` false and still reach the history record and the tail.
1775
+ try {
1776
+ if (isLocal) {
1777
+ // Local backend (batch mode): no interim transcripts were sent to the editor,
1778
+ // so we must always insert the final text. This is the ONLY place it arrives.
1779
+ ctx.ui.setEditorText(finalText);
1780
+ wroteEditor = true;
1781
+ } else {
1782
+ // Streaming backend: interim transcripts already updated the editor live.
1783
+ // Only set final text if the editor still has content (user didn't hit Enter).
1784
+ const currentEditorText = ctx.ui.getEditorText?.() ?? "";
1785
+ if (currentEditorText.trim()) {
1786
+ ctx.ui.setEditorText(finalText);
1787
+ wroteEditor = true;
1788
+ }
1789
+ }
1790
+ } catch (err) {
1791
+ editorWriteFailed = true;
1792
+ voiceDebug("editor write threw — continuing the completion", { error: String(err) });
1793
+ }
1794
+ }
1357
1795
 
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);
1796
+ if (polishOutcome !== undefined) {
1797
+ const final = finalizePolishDisposition(polishOutcome, wroteEditor, editorWriteFailed);
1798
+ polishOutcome = final.status;
1799
+ // One reason string for the debug log and the audit entry.
1800
+ const reason = editorWriteFailed
1801
+ ? "editor-write-failed"
1802
+ : !wroteEditor && !skipWrite
1803
+ ? "editor-write-skipped"
1804
+ : polishTelemetry?.reason;
1805
+ if (polishTelemetry) {
1806
+ voiceDebug("polish result", {
1807
+ ...polishTelemetry,
1808
+ disposition: final.disposition,
1809
+ reason,
1810
+ });
1811
+ }
1812
+ // Durable audit record: one CustomEntry per dictation, carrying the raw text, what
1813
+ // actually reached the editor and why the pass did what it did. A CustomEntry never
1814
+ // enters the model's context, so the pass stays analysable after the session ends
1815
+ // without changing what the model sees. A failure here must not affect the dictation.
1816
+ try {
1817
+ pi.appendEntry(
1818
+ "voice-polish",
1819
+ buildPolishAudit({
1820
+ raw: prefix + fullText,
1821
+ written: wroteEditor ? finalText : undefined,
1822
+ status: final.status,
1823
+ disposition: final.disposition,
1824
+ reason,
1825
+ telemetry: polishTelemetry,
1826
+ })
1827
+ );
1828
+ } catch (err) {
1829
+ voiceDebug("polish audit entry failed", { error: String(err) });
1368
1830
  }
1369
1831
  }
1370
1832
 
@@ -1373,7 +1835,7 @@ export default function (pi: ExtensionAPI) {
1373
1835
  // agent immediately instead of sitting in the editor
1374
1836
  // waiting for [enter]. Defaults OFF; user toggles via
1375
1837
  // /voice-autosubmit or settings panel.
1376
- if (config.autoSubmitOnSpeak === true && finalText.trim().length > 0) {
1838
+ if (config.autoSubmitOnSpeak === true && finalText.trim().length > 0 && !skipWrite) {
1377
1839
  // v7.2.3 — if the agent is currently mid-turn
1378
1840
  // (especially mid-retry), DON'T auto-submit.
1379
1841
  // followUp queueing during a retry pile-up
@@ -1443,22 +1905,37 @@ export default function (pi: ExtensionAPI) {
1443
1905
  }
1444
1906
  } else {
1445
1907
  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
- );
1908
+ try {
1909
+ ctx.ui.notify(
1910
+ "Auto-submit ON but unavailable on this Pi version (need pi.sendUserMessage). " +
1911
+ "Press [enter] to send, or update Pi.",
1912
+ "warning"
1913
+ );
1914
+ } catch (err) {
1915
+ // R24: a failed warning must not skip the history record or the tail.
1916
+ voiceDebug("auto-submit unavailable notify threw", { error: String(err) });
1917
+ }
1451
1918
  }
1452
1919
  } // end else (agent not busy)
1453
1920
  }
1454
1921
 
1455
- const elapsed = ((Date.now() - recordingStart) / 1000).toFixed(1);
1456
- addToHistory(fullText, parseFloat(elapsed));
1922
+ addToHistory(fullText, parseFloat(elapsed), "hold", {
1923
+ writtenText: wroteEditor ? finalText : undefined,
1924
+ rawFullText: prefix + fullText,
1925
+ polishedApplied: wroteEditor && spokenText !== fullText,
1926
+ polishOutcome,
1927
+ });
1457
1928
  }
1458
1929
  playSound("stop");
1459
1930
  // Full state reset on successful completion
1460
1931
  resetHoldState();
1461
- setVoiceState("idle");
1932
+ // R24: the last call of the callback is a UI render via setVoiceState —
1933
+ // swallow it so the float promise cannot reject after the tail.
1934
+ try {
1935
+ setVoiceState("idle");
1936
+ } catch (err) {
1937
+ voiceDebug("idle transition threw", { error: String(err) });
1938
+ }
1462
1939
  },
1463
1940
  onError: (err: string) => {
1464
1941
  activeSession = null;
@@ -2161,6 +2638,11 @@ export default function (pi: ExtensionAPI) {
2161
2638
  abortSession(activeSession);
2162
2639
  activeSession = null;
2163
2640
  }
2641
+ // The pass can already be pending when activeSession is null (the normal
2642
+ // finalizing case), so this sits outside the block above (ruling R4).
2643
+ const passSnapshot = activePolishPass !== null ? polishPassEditorSnapshot : null;
2644
+ const userEditedDuringPass = passSnapshot !== null && readEditorOrFailed() !== passSnapshot;
2645
+ invalidatePolishPass("cancelled");
2164
2646
  clearRecordingAnimTimer();
2165
2647
  clearWarmupWidget();
2166
2648
  hideWidget();
@@ -2168,8 +2650,12 @@ export default function (pi: ExtensionAPI) {
2168
2650
  clearInterval(statusTimer);
2169
2651
  statusTimer = null;
2170
2652
  }
2171
- // Restore editor text to what it was before recording
2172
- if (ctx?.hasUI) ctx.ui.setEditorText(editorTextBeforeVoice);
2653
+ // Restore editor text to what it was before recording — but only while the
2654
+ // extension still owns the editor. A pending pass has written nothing, so an
2655
+ // editor that differs from the pass snapshot holds text the user typed while
2656
+ // waiting; restoring would delete it. With no pass pending, the old behaviour
2657
+ // stands: the live interim text is cleared.
2658
+ if (ctx?.hasUI && !userEditedDuringPass) ctx.ui.setEditorText(editorTextBeforeVoice);
2173
2659
  resetHoldState();
2174
2660
  playSound("error");
2175
2661
  setVoiceState("idle");
@@ -2269,6 +2755,7 @@ export default function (pi: ExtensionAPI) {
2269
2755
  }
2270
2756
 
2271
2757
  ctx = startCtx;
2758
+ invalidatePolishPass(`session-${reason}`);
2272
2759
  currentCwd = startCtx.cwd;
2273
2760
  const loaded = loadConfigWithSource(startCtx.cwd);
2274
2761
  config = loaded.config;
@@ -2366,6 +2853,7 @@ export default function (pi: ExtensionAPI) {
2366
2853
  voiceDebug("voiceCleanup threw during shutdown", { error: String(err) });
2367
2854
  }
2368
2855
  ctx = null;
2856
+ invalidatePolishPass("session-shutdown");
2369
2857
 
2370
2858
  // Clear the sherpa recognizer cache ONLY on terminal quit. On older Pi
2371
2859
  // versions (< 0.65.0) shutdown handlers are not awaited before the
@@ -2387,6 +2875,20 @@ export default function (pi: ExtensionAPI) {
2387
2875
  }
2388
2876
  });
2389
2877
 
2878
+ // A submitted user message and a branch navigation both end the window in which a
2879
+ // pending pass may still write. On the local backend the pass has written nothing
2880
+ // yet, so the editor-equality guard passes after the user types a message and
2881
+ // submits it (the editor returns to empty), and branch navigation fires
2882
+ // session_tree rather than session_start — either way a stale transcript could
2883
+ // reappear and auto-send. Invalidating twice is harmless: the operation is
2884
+ // idempotent.
2885
+ pi.on("input", async () => {
2886
+ invalidatePolishPass("user-input", "complete");
2887
+ });
2888
+ pi.on("session_tree", async () => {
2889
+ invalidatePolishPass("session-tree", "complete");
2890
+ });
2891
+
2390
2892
  // Note: pi-mono < 0.65.0 fired a discrete "session_switch" event for
2391
2893
  // /new, /resume, /fork. That event was removed in 0.65.0 in favor of the
2392
2894
  // session_shutdown → session_start (with reason) flow handled above.
@@ -3019,6 +3521,13 @@ export default function (pi: ExtensionAPI) {
3019
3521
  },
3020
3522
  resolveApiKey: () => resolveDeepgramApiKey(config) ?? undefined,
3021
3523
  deepgramLanguages: LANGUAGES.map((l) => ({ name: l.name, code: l.code, popular: l.popular })),
3524
+ // Polish tab: the picker rows come from the same helper /voice-polish uses, so
3525
+ // the panel keeps making no Pi API calls of its own.
3526
+ getPolishModels: getPolishModelChoices,
3527
+ // Item 6: the polish numbers go to the scope the config was loaded from, not to
3528
+ // the in-memory field a project file can set.
3529
+ getPolishScope: polishWriteScope,
3530
+ getLastDictation: () => recordingHistory.find((item) => item.polishedApplied),
3022
3531
  };
3023
3532
 
3024
3533
  let panel!: InstanceType<typeof VoiceSettingsPanel>;
@@ -3492,6 +4001,167 @@ export default function (pi: ExtensionAPI) {
3492
4001
  },
3493
4002
  });
3494
4003
 
4004
+ /**
4005
+ * Model choices for the /voice-polish picker: every text-capable model Pi
4006
+ * exposes, as canonical `provider/id` references. Task 7 reuses this for the
4007
+ * settings panel — do not duplicate the filter.
4008
+ */
4009
+ function getPolishModelChoices(): { ref: string; label: string }[] {
4010
+ const models =
4011
+ ctx && ctx.scopedModels.length > 0
4012
+ ? ctx.scopedModels.map((entry) => entry.model)
4013
+ : (ctx?.modelRegistry.getAvailable() ?? []);
4014
+ return models
4015
+ .filter((model) => model.input.includes("text"))
4016
+ .map((model) => ({ ref: `${model.provider}/${model.id}`, label: model.name || model.id }));
4017
+ }
4018
+
4019
+ pi.registerCommand("voice-polish", {
4020
+ description: "Voice: /voice-polish [on|off|model|turns <0-10>|last|restore]",
4021
+ handler: async (args, cmdCtx) => {
4022
+ ctx = cmdCtx;
4023
+ const sub = (args || "").trim();
4024
+ // R27: subcommand names are case-insensitive, like /voice-autosubmit.
4025
+ const [rawVerb, ...rest] = sub.split(/\s+/);
4026
+ const verb = rawVerb.toLowerCase();
4027
+
4028
+ if (!verb || verb === "status") {
4029
+ cmdCtx.ui.notify(
4030
+ [
4031
+ `Voice polish: ${config.postProcessEnabled !== false ? "on" : "off"}`,
4032
+ ` model: ${config.postProcessModel ?? "session"}`,
4033
+ ` turns: ${config.postProcessContextTurns ?? DEFAULT_CONTEXT_LIMITS.turns}`,
4034
+ ` timeout: ${config.postProcessTimeoutMs ?? 8000} ms`,
4035
+ ].join("\n"),
4036
+ "info"
4037
+ );
4038
+ return;
4039
+ }
4040
+ if (verb === "on" || verb === "off") {
4041
+ // D7/R26: enablement is global-only, so it is written field by field to the
4042
+ // GLOBAL file. A project block would be stripped by the serializer (and ignored
4043
+ // on load) — the command would report success and the setting would silently
4044
+ // revert on the next /reload.
4045
+ const next = verb === "on";
4046
+ if (
4047
+ !saveGlobalVoiceFieldsOrNotify({ postProcessEnabled: next }, (message) =>
4048
+ cmdCtx.ui.notify(message, "warning")
4049
+ )
4050
+ ) {
4051
+ return;
4052
+ }
4053
+ config.postProcessEnabled = next;
4054
+ cmdCtx.ui.notify(`Voice polish ${next ? "enabled" : "disabled"}.`, "info");
4055
+ return;
4056
+ }
4057
+ if (verb === "model") {
4058
+ // Model selection is a picker, like /model and /workflow-model — never a
4059
+ // hand-typed reference (maintainer decision, 2026-09-26).
4060
+ if (rest.length > 0) {
4061
+ cmdCtx.ui.notify(
4062
+ "Voice polish: pick the model from the list — run /voice-polish model with no argument.",
4063
+ "warning"
4064
+ );
4065
+ return;
4066
+ }
4067
+ // R27: guard the headless path BEFORE building the rows — the list would call
4068
+ // the model registry for a list nobody can see.
4069
+ if (!cmdCtx.hasUI || typeof cmdCtx.ui.select !== "function") {
4070
+ cmdCtx.ui.notify(`Current polish model: ${config.postProcessModel ?? "session"}`, "info");
4071
+ return;
4072
+ }
4073
+ const options = polishModelOptions(getPolishModelChoices(), config.postProcessModel);
4074
+ const picked = await cmdCtx.ui.select(
4075
+ "Polish model",
4076
+ options.map((option) => option.label)
4077
+ );
4078
+ const chosen = options.find((option) => option.label === picked);
4079
+ if (!chosen) return; // dismissed — keep the current value
4080
+ // R26: model choice is global-only — field-level write to the global file.
4081
+ if (
4082
+ !saveGlobalVoiceFieldsOrNotify({ postProcessModel: chosen.value }, (message) =>
4083
+ cmdCtx.ui.notify(message, "warning")
4084
+ )
4085
+ ) {
4086
+ return;
4087
+ }
4088
+ config.postProcessModel = chosen.value;
4089
+ cmdCtx.ui.notify(`Voice polish model set to ${chosen.value}.`, "info");
4090
+ return;
4091
+ }
4092
+ if (verb === "turns") {
4093
+ const turns = Number(rest[0]);
4094
+ if (!Number.isInteger(turns) || turns < 0 || turns > 10) {
4095
+ cmdCtx.ui.notify("Usage: /voice-polish turns <0-10>", "warning");
4096
+ return;
4097
+ }
4098
+ config.postProcessContextTurns = turns;
4099
+ // R25 + item 6: the turn count is honoured in both scopes, so it is persisted at
4100
+ // the scope this session actually loads from — a write to any other file would be
4101
+ // overridden by the project block on the next /reload and report a success that
4102
+ // does not stick.
4103
+ saveConfig(config, polishWriteScope(), currentCwd);
4104
+ cmdCtx.ui.notify(`Voice polish context turns set to ${turns}.`, "info");
4105
+ return;
4106
+ }
4107
+ if (verb === "last") {
4108
+ // The newest dictation a pass ran for — a discarded one is invisible to
4109
+ // `restore`, but its raw text stays reachable here, which is what the
4110
+ // retention promise is about.
4111
+ const entry = recordingHistory.find((item) => item.polishOutcome !== undefined);
4112
+ if (!entry) {
4113
+ cmdCtx.ui.notify("No polished dictation in this session yet.", "info");
4114
+ return;
4115
+ }
4116
+ cmdCtx.ui.notify(
4117
+ [
4118
+ `STATUS: ${entry.polishOutcome}`,
4119
+ `RAW: ${entry.rawFullText ?? entry.text}`,
4120
+ entry.writtenText !== undefined
4121
+ ? `WRITTEN: ${entry.writtenText}`
4122
+ : "WRITTEN: (nothing — the editor kept your text)",
4123
+ ].join("\n"),
4124
+ "info"
4125
+ );
4126
+ return;
4127
+ }
4128
+ if (verb === "restore") {
4129
+ // Stricter than `last`: only a dictation that owns an editor write can be
4130
+ // restored, whatever the pass status was.
4131
+ const entry = recordingHistory.find((item) => item.writtenText !== undefined);
4132
+ if (!entry) {
4133
+ cmdCtx.ui.notify("No polished dictation in this session yet.", "info");
4134
+ return;
4135
+ }
4136
+ if (entry.rawFullText === undefined) {
4137
+ cmdCtx.ui.notify("That dictation stored no raw transcript — nothing to restore.", "warning");
4138
+ return;
4139
+ }
4140
+ // Compare against the exact string this feature last wrote (`prefix + polished`) —
4141
+ // which is why history stores it. Comparing against the bare transcript would
4142
+ // always pass whenever the user had a draft, i.e. the guard would be a no-op.
4143
+ const decision = decideApply({
4144
+ tokenCurrent: true,
4145
+ // `writtenText` is guaranteed by the lookup above (R27) — no runtime re-check.
4146
+ editorSnapshot: entry.writtenText!,
4147
+ currentEditor: cmdCtx.ui.getEditorText(),
4148
+ });
4149
+ if (!decision.apply) {
4150
+ cmdCtx.ui.notify(
4151
+ "Editor changed since that dictation — not restoring. Copy from /voice-polish last.",
4152
+ "warning"
4153
+ );
4154
+ return;
4155
+ }
4156
+ // Write `prefix + raw`: a restore must not delete what the user typed before dictating.
4157
+ cmdCtx.ui.setEditorText(entry.rawFullText);
4158
+ cmdCtx.ui.notify("Restored the raw transcript into the editor.", "info");
4159
+ return;
4160
+ }
4161
+ cmdCtx.ui.notify("Usage: /voice-polish [on|off|model|turns <0-10>|last|restore]", "warning");
4162
+ },
4163
+ });
4164
+
3495
4165
  pi.registerCommand("voice-speak-stop", {
3496
4166
  description: "Stop in-flight TTS playback",
3497
4167
  handler: async (_args, cmdCtx) => {