pi-voicekit 0.1.4 → 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.
- package/README.md +65 -9
- package/extensions/voice/config.ts +155 -14
- package/extensions/voice/post-process-context.ts +185 -0
- package/extensions/voice/post-process-prompt.ts +95 -0
- package/extensions/voice/post-process.ts +219 -0
- package/extensions/voice/settings-panel.ts +265 -4
- package/extensions/voice.ts +673 -33
- package/package.json +1 -1
package/extensions/voice.ts
CHANGED
|
@@ -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(
|
|
789
|
-
|
|
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)
|
|
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
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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 +
|
|
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 (
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
-
|
|
1447
|
-
|
|
1448
|
-
"
|
|
1449
|
-
|
|
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
|
-
|
|
1456
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
@@ -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.
|
|
@@ -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) => {
|