omnirush 0.8.6 → 0.9.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/assets/CHANGELOG.md +85 -0
- package/assets/extensions/omnirush/agents-lib.ts +134 -13
- package/assets/extensions/omnirush/agents.ts +51 -107
- package/assets/extensions/omnirush/bgshell-lib.ts +400 -0
- package/assets/extensions/omnirush/bgshell.ts +392 -0
- package/assets/extensions/omnirush/collector.ts +72 -11
- package/assets/extensions/omnirush/commands.ts +2 -0
- package/assets/extensions/omnirush/deliveries.ts +145 -0
- package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
- package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
- package/assets/extensions/omnirush/guard-lib.ts +230 -0
- package/assets/extensions/omnirush/guard.ts +340 -0
- package/assets/extensions/omnirush/index.ts +12 -0
- package/assets/extensions/omnirush/pi-engine.ts +65 -1
- package/assets/extensions/omnirush/sota.ts +52 -4
- package/assets/extensions/omnirush/status-lib.ts +3 -0
- package/assets/extensions/omnirush/subagents-lib.ts +623 -0
- package/assets/extensions/omnirush/subagents.ts +305 -0
- package/assets/extensions/omnirush/swarm-lib.ts +142 -0
- package/assets/extensions/omnirush/swarm.ts +95 -0
- package/assets/extensions/omnirush/voice/capture.ts +502 -0
- package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
- package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
- package/assets/extensions/omnirush/voice/core/index.ts +21 -0
- package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
- package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
- package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
- package/assets/extensions/omnirush/voice/core/session.ts +403 -0
- package/assets/extensions/omnirush/voice/core/text.ts +81 -0
- package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
- package/assets/extensions/omnirush/voice/core/types.ts +102 -0
- package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
- package/assets/extensions/omnirush/voice/keys.ts +435 -0
- package/assets/extensions/omnirush/voice/kitty.ts +64 -0
- package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
- package/assets/extensions/omnirush/voice/settings.ts +67 -0
- package/assets/extensions/omnirush/voice.ts +838 -0
- package/assets/extensions/omnirush/yolo-lib.ts +80 -0
- package/assets/extensions/omnirush/yolo.ts +85 -0
- package/package.json +7 -3
- package/scripts/brand-engine.js +526 -0
- package/scripts/build-all-packages.py +29 -1
- package/scripts/smoke-packages.py +32 -1
- package/src/bin.js +205 -33
- package/src/compat.js +272 -0
- package/src/lib.js +64 -0
- package/scripts/patch-pi-branding.js +0 -251
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /voice — dictate the prompt.
|
|
3
|
+
*
|
|
4
|
+
* /voice toggle (on uses the last mode, hold by default)
|
|
5
|
+
* /voice hold|tap turn on in that mode
|
|
6
|
+
* /voice off turn off
|
|
7
|
+
* /voice key <key> push-to-talk key: space (default) or a combo like alt+m
|
|
8
|
+
* /voice auto-send on|off send on release when the text has 3+ words
|
|
9
|
+
* /voice status settings, recorder and server availability
|
|
10
|
+
*
|
|
11
|
+
* Hold mode: hold Space to talk, release to stop; the transcript is typed
|
|
12
|
+
* into the prompt where the cursor was and stays editable (nothing is sent
|
|
13
|
+
* unless auto-send is on). Tap mode: Space on an empty prompt starts,
|
|
14
|
+
* Space again stops (Enter stops and sends). Esc or Ctrl+C cancels and
|
|
15
|
+
* puts the prompt back as it was.
|
|
16
|
+
*
|
|
17
|
+
* Speech goes through the OmniRush backend (POST <gateway>/audio/
|
|
18
|
+
* transcriptions, device token) segment by segment while you talk, via the
|
|
19
|
+
* shared voice core (./voice/core, vendored from the desktop app).
|
|
20
|
+
* Recordings are disposable: audio exists only in memory until its
|
|
21
|
+
* segment's text is back, is never written to disk or logged, and never
|
|
22
|
+
* reaches the session or the trace collector — only the text you send
|
|
23
|
+
* becomes the prompt.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { omniDir, resolveOrigin } from "./auth";
|
|
30
|
+
import { sharedRefresher } from "./refresh";
|
|
31
|
+
import {
|
|
32
|
+
CircuitBreaker,
|
|
33
|
+
RemoteTranscriber,
|
|
34
|
+
VoiceError,
|
|
35
|
+
VoiceSession,
|
|
36
|
+
buildKeyterms,
|
|
37
|
+
countWords,
|
|
38
|
+
isVoiceError,
|
|
39
|
+
padInsertion,
|
|
40
|
+
type AudioSource,
|
|
41
|
+
type TranscribeRequest,
|
|
42
|
+
type Transcriber,
|
|
43
|
+
type VoiceResult,
|
|
44
|
+
type VoiceSnapshot,
|
|
45
|
+
} from "./voice/core";
|
|
46
|
+
import { RecorderUnavailableError, openRecorder, realProbe, remoteReason, testCapture, type PcmSource } from "./voice/capture";
|
|
47
|
+
import {
|
|
48
|
+
KeyController,
|
|
49
|
+
isCancelKey,
|
|
50
|
+
kittyMatches,
|
|
51
|
+
legacyMatches,
|
|
52
|
+
parseKittyKey,
|
|
53
|
+
parseVoiceKey,
|
|
54
|
+
type VoiceKey,
|
|
55
|
+
type VoiceMode,
|
|
56
|
+
} from "./voice/keys";
|
|
57
|
+
import { ALL_KEYS_FLAGS, CORE_FLAGS, kittyToLegacy, setFlagsSequence } from "./voice/kitty";
|
|
58
|
+
import { loadVoiceSettings, saveVoiceSettings, type VoiceSettings } from "./voice/settings";
|
|
59
|
+
|
|
60
|
+
const WIDGET = "omnirush-voice";
|
|
61
|
+
const STATUS = "omnirush-voice";
|
|
62
|
+
const PRIVACY_NOTICE =
|
|
63
|
+
"Voice is transcribed by our cloud speech provider. omnirush.ai doesn't store your audio; the provider keeps a copy for up to 30 days.";
|
|
64
|
+
|
|
65
|
+
/** The gateway's transcription route: <gateway>/audio/transcriptions. */
|
|
66
|
+
export function transcriptionEndpoint(env: NodeJS.ProcessEnv = process.env): string {
|
|
67
|
+
const gateway = (env.OMNIRUSH_GATEWAY_URL || "").trim().replace(/\/+$/, "") || `${resolveOrigin(env)}/v1`;
|
|
68
|
+
return `${gateway}/audio/transcriptions`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function currentToken(): string | null {
|
|
72
|
+
const refresher = sharedRefresher();
|
|
73
|
+
return refresher.auth?.accessToken || (process.env.OMNIRUSH_TOKEN || "").trim() || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** RemoteTranscriber with the device bearer and one single-flight refresh on 401. */
|
|
77
|
+
export function createGatewayTranscriber(
|
|
78
|
+
endpoint = transcriptionEndpoint(),
|
|
79
|
+
deps: { token?: () => string | null; refresh?: (token: string) => Promise<boolean>; fetch?: typeof fetch } = {},
|
|
80
|
+
): Transcriber {
|
|
81
|
+
const token = deps.token ?? currentToken;
|
|
82
|
+
const refresh = deps.refresh ?? ((expired: string) => sharedRefresher().refresh(expired));
|
|
83
|
+
let bearer: string | null = null;
|
|
84
|
+
const remote = new RemoteTranscriber({
|
|
85
|
+
url: endpoint,
|
|
86
|
+
headers: (): Record<string, string> => (bearer ? { Authorization: `Bearer ${bearer}` } : {}),
|
|
87
|
+
...(deps.fetch ? { fetch: deps.fetch as any } : {}),
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
async transcribe(request: TranscribeRequest, signal: AbortSignal) {
|
|
91
|
+
bearer = token();
|
|
92
|
+
if (!bearer) throw new VoiceError("signed_out", "Not signed in — run `omnirush login` first.");
|
|
93
|
+
try {
|
|
94
|
+
return await remote.transcribe(request, signal);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!(isVoiceError(error) && error.kind === "signed_out" && error.status === 401)) throw error;
|
|
97
|
+
const expired = bearer;
|
|
98
|
+
const refreshed = await refresh(expired).catch(() => false);
|
|
99
|
+
const next = token();
|
|
100
|
+
if (!refreshed || !next || next === expired) throw error;
|
|
101
|
+
bearer = next;
|
|
102
|
+
return remote.transcribe(request, signal);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface VoiceAvailability {
|
|
109
|
+
available: boolean;
|
|
110
|
+
reason: string | null;
|
|
111
|
+
detail?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** GET <gateway>/audio/transcriptions: is dictation on for this account? Never throws. */
|
|
115
|
+
export async function checkAvailability(
|
|
116
|
+
endpoint = transcriptionEndpoint(),
|
|
117
|
+
deps: { token?: () => string | null; fetch?: typeof fetch } = {},
|
|
118
|
+
): Promise<VoiceAvailability> {
|
|
119
|
+
const token = (deps.token ?? currentToken)();
|
|
120
|
+
if (!token) return { available: false, reason: "signed_out" };
|
|
121
|
+
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
122
|
+
try {
|
|
123
|
+
const response = await doFetch(endpoint, {
|
|
124
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
125
|
+
signal: AbortSignal.timeout(5000),
|
|
126
|
+
});
|
|
127
|
+
const text = await response.text().catch(() => "");
|
|
128
|
+
let body: any = null;
|
|
129
|
+
try {
|
|
130
|
+
body = text ? JSON.parse(text) : null;
|
|
131
|
+
} catch {
|
|
132
|
+
body = null;
|
|
133
|
+
}
|
|
134
|
+
if (response.status === 404 || response.status === 405) return { available: false, reason: "not_supported" };
|
|
135
|
+
if (response.status === 401) return { available: false, reason: "signed_out" };
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
const code = typeof body?.detail === "string" ? body.detail : body?.detail?.code ?? body?.error?.code ?? null;
|
|
138
|
+
if (code === "voice_unavailable") return { available: false, reason: "voice_unavailable" };
|
|
139
|
+
return { available: false, reason: "server_error", detail: `HTTP ${response.status}` };
|
|
140
|
+
}
|
|
141
|
+
if (body && body.available === false) return { available: false, reason: body.reason ?? "voice_unavailable" };
|
|
142
|
+
return { available: true, reason: null };
|
|
143
|
+
} catch (error: any) {
|
|
144
|
+
return { available: false, reason: "unreachable", detail: String(error?.message ?? error) };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function availabilityMessage(result: VoiceAvailability): string {
|
|
149
|
+
switch (result.reason) {
|
|
150
|
+
case "signed_out":
|
|
151
|
+
return "Voice needs you signed in — run `omnirush login` first.";
|
|
152
|
+
case "not_supported":
|
|
153
|
+
return "Voice isn't available on this OmniRush server yet (it has no transcription route).";
|
|
154
|
+
case "voice_unavailable":
|
|
155
|
+
return "Voice is currently unavailable: the OmniRush speech service is turned off for your account. Try again later.";
|
|
156
|
+
case "unreachable":
|
|
157
|
+
return `Voice can't reach the OmniRush server (${result.detail ?? "network error"}).`;
|
|
158
|
+
default:
|
|
159
|
+
return `Voice is currently unavailable (${result.detail ?? result.reason ?? "unknown error"}).`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A readable line for a failed dictation. */
|
|
164
|
+
export function voiceErrorMessage(error: VoiceError): string {
|
|
165
|
+
switch (error.kind) {
|
|
166
|
+
case "unavailable":
|
|
167
|
+
return "Voice is currently unavailable: the OmniRush speech service is turned off for your account.";
|
|
168
|
+
case "no_signal":
|
|
169
|
+
return "No audio came from the microphone — check the input device (and, on macOS, that this terminal may use the microphone).";
|
|
170
|
+
case "no_speech":
|
|
171
|
+
return "No speech detected.";
|
|
172
|
+
case "signed_out":
|
|
173
|
+
return "Voice needs you signed in — run `omnirush login` again.";
|
|
174
|
+
case "breaker_open":
|
|
175
|
+
return "Voice paused after repeated errors. Try again in a moment.";
|
|
176
|
+
default:
|
|
177
|
+
return error.message;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Keyterm context: repo, branch, files the session touched
|
|
183
|
+
|
|
184
|
+
function gitInfo(cwd: string): { repo: string | null; branch: string | null } {
|
|
185
|
+
let dir = path.resolve(cwd);
|
|
186
|
+
for (let i = 0; i < 40; i++) {
|
|
187
|
+
const dotGit = path.join(dir, ".git");
|
|
188
|
+
try {
|
|
189
|
+
let gitDir = dotGit;
|
|
190
|
+
const stat = fs.statSync(dotGit);
|
|
191
|
+
if (stat.isFile()) {
|
|
192
|
+
const pointer = /gitdir:\s*(.+)/.exec(fs.readFileSync(dotGit, "utf8"))?.[1]?.trim();
|
|
193
|
+
if (pointer) gitDir = path.resolve(dir, pointer);
|
|
194
|
+
}
|
|
195
|
+
const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
|
|
196
|
+
const branch = /^ref: refs\/heads\/(.+)$/.exec(head)?.[1] ?? null;
|
|
197
|
+
return { repo: path.basename(dir), branch };
|
|
198
|
+
} catch {
|
|
199
|
+
/* not here */
|
|
200
|
+
}
|
|
201
|
+
const parent = path.dirname(dir);
|
|
202
|
+
if (parent === dir) break;
|
|
203
|
+
dir = parent;
|
|
204
|
+
}
|
|
205
|
+
return { repo: path.basename(path.resolve(cwd)) || null, branch: null };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function topLevelNames(cwd: string): string[] {
|
|
209
|
+
try {
|
|
210
|
+
return fs
|
|
211
|
+
.readdirSync(cwd)
|
|
212
|
+
.filter((name) => !name.startsWith(".") && name !== "node_modules")
|
|
213
|
+
.slice(0, 20);
|
|
214
|
+
} catch {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
interface EditorLike {
|
|
222
|
+
getText(): string;
|
|
223
|
+
getCursor(): { line: number; col: number };
|
|
224
|
+
getLines?(): string[];
|
|
225
|
+
insertTextAtCursor(text: string): void;
|
|
226
|
+
handleInput(data: string): void;
|
|
227
|
+
isShowingAutocomplete?(): boolean;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isEditorLike(component: any): component is EditorLike {
|
|
231
|
+
return (
|
|
232
|
+
!!component &&
|
|
233
|
+
typeof component.getText === "function" &&
|
|
234
|
+
typeof component.getCursor === "function" &&
|
|
235
|
+
typeof component.insertTextAtCursor === "function" &&
|
|
236
|
+
typeof component.handleInput === "function"
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function textAroundCursor(editor: EditorLike): { before: string; after: string } {
|
|
241
|
+
const lines = editor.getLines?.() ?? editor.getText().split("\n");
|
|
242
|
+
const { line, col } = editor.getCursor();
|
|
243
|
+
const current = lines[line] ?? "";
|
|
244
|
+
return { before: current.slice(0, col), after: current.slice(col) };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Delete `text` right before the cursor, only if it is exactly what is there. */
|
|
248
|
+
function eraseBeforeCursor(editor: EditorLike, text: string): boolean {
|
|
249
|
+
if (!text) return true;
|
|
250
|
+
const { before } = textAroundCursor(editor);
|
|
251
|
+
if (!before.endsWith(text)) return false;
|
|
252
|
+
for (let i = 0; i < [...text].length; i++) editor.handleInput("\x7f");
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatElapsed(ms: number): string {
|
|
257
|
+
const s = Math.floor(ms / 1000);
|
|
258
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const BARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
262
|
+
function bars(levels: number[]): string {
|
|
263
|
+
return levels.map((l) => BARS[Math.min(7, Math.floor(Math.max(0, l) * 8))]).join("");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
type UiState =
|
|
267
|
+
| { kind: "idle" }
|
|
268
|
+
| { kind: "warmup" }
|
|
269
|
+
| { kind: "starting" }
|
|
270
|
+
| { kind: "recording"; snapshot: VoiceSnapshot | null }
|
|
271
|
+
| { kind: "finalizing"; snapshot: VoiceSnapshot | null };
|
|
272
|
+
|
|
273
|
+
export default function (pi: ExtensionAPI) {
|
|
274
|
+
const dir = omniDir();
|
|
275
|
+
let settings: VoiceSettings = loadVoiceSettings(dir);
|
|
276
|
+
let ctxRef: any = null;
|
|
277
|
+
let tui: any = null;
|
|
278
|
+
let theme: any = null;
|
|
279
|
+
let unsubscribe: (() => void) | null = null;
|
|
280
|
+
let controller: KeyController | null = null;
|
|
281
|
+
let voiceKey: VoiceKey | null = null;
|
|
282
|
+
let kittyAllKeys = false;
|
|
283
|
+
let ui: UiState = { kind: "idle" };
|
|
284
|
+
let renderTimer: ReturnType<typeof setInterval> | null = null;
|
|
285
|
+
const breaker = new CircuitBreaker();
|
|
286
|
+
let breakerNoticeAt = 0;
|
|
287
|
+
const touchedFiles: string[] = [];
|
|
288
|
+
|
|
289
|
+
// The current dictation.
|
|
290
|
+
let session: VoiceSession | null = null;
|
|
291
|
+
let source: PcmSource | null = null;
|
|
292
|
+
let editor: EditorLike | null = null;
|
|
293
|
+
let anchor: { before: string; after: string } | null = null;
|
|
294
|
+
let inserted = "";
|
|
295
|
+
let lead: string | null = null;
|
|
296
|
+
let diverged = false;
|
|
297
|
+
let sendOnStop = false;
|
|
298
|
+
let stopRequested = false;
|
|
299
|
+
/** After an automatic stop while the key is still held: swallow its repeats. */
|
|
300
|
+
let drainKey = false;
|
|
301
|
+
let drainTimer: ReturnType<typeof setTimeout> | null = null;
|
|
302
|
+
|
|
303
|
+
const notify = (text: string, level: "info" | "warning" | "error" = "info") => ctxRef?.ui?.notify?.(text, level);
|
|
304
|
+
const requestRender = () => tui?.requestRender?.();
|
|
305
|
+
|
|
306
|
+
const setUi = (next: UiState) => {
|
|
307
|
+
ui = next;
|
|
308
|
+
const active = next.kind === "recording" || next.kind === "finalizing" || next.kind === "starting";
|
|
309
|
+
if (active && !renderTimer) renderTimer = setInterval(requestRender, 250);
|
|
310
|
+
if (!active && renderTimer) {
|
|
311
|
+
clearInterval(renderTimer);
|
|
312
|
+
renderTimer = null;
|
|
313
|
+
}
|
|
314
|
+
requestRender();
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const color = (name: string, text: string) => (theme?.fg ? theme.fg(name, text) : text);
|
|
318
|
+
const keyLabel = () => (voiceKey?.id === "space" ? "Space" : voiceKey?.id ?? settings.key);
|
|
319
|
+
|
|
320
|
+
const widget = {
|
|
321
|
+
render(width: number): string[] {
|
|
322
|
+
let line = "";
|
|
323
|
+
switch (ui.kind) {
|
|
324
|
+
case "idle":
|
|
325
|
+
return [];
|
|
326
|
+
case "warmup":
|
|
327
|
+
line = `${color("warning", "●")} keep holding ${keyLabel()}…`;
|
|
328
|
+
break;
|
|
329
|
+
case "starting":
|
|
330
|
+
line = `${color("warning", "●")} starting microphone…`;
|
|
331
|
+
break;
|
|
332
|
+
case "recording": {
|
|
333
|
+
const snap = ui.snapshot;
|
|
334
|
+
const pending = snap?.pending ? color("dim", ` · transcribing ${snap.pending}…`) : "";
|
|
335
|
+
const hint = settings.mode === "hold" ? `release ${keyLabel()} to insert` : `${keyLabel()} to stop · Enter to send`;
|
|
336
|
+
line = `${color("error", "● REC")} ${formatElapsed(snap?.elapsedMs ?? 0)} ${color("accent", bars(snap?.levels ?? []))}${pending} ${color("dim", `${hint} · Esc cancels`)}`;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
case "finalizing": {
|
|
340
|
+
const n = ui.snapshot?.pending ?? 0;
|
|
341
|
+
line = `${color("accent", "●")} processing…${n ? ` (${n} segment${n === 1 ? "" : "s"})` : ""} ${color("dim", "Esc cancels")}`;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const visible = line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
346
|
+
if (visible.length > width) {
|
|
347
|
+
// Drop the hint first; never exceed the width.
|
|
348
|
+
return [line.replace(/\x1b\[[0-9;]*m/g, "").slice(0, Math.max(0, width))];
|
|
349
|
+
}
|
|
350
|
+
return [line];
|
|
351
|
+
},
|
|
352
|
+
invalidate() {},
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// ---- editor text ----------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
/** Keep the editor showing the committed text of the dictation so far. */
|
|
358
|
+
const applyLive = (text: string) => {
|
|
359
|
+
if (!editor || !anchor || diverged || !text) return;
|
|
360
|
+
if (lead === null) lead = padInsertion(anchor.before, text, "").startsWith(" ") ? " " : "";
|
|
361
|
+
const target = lead + text;
|
|
362
|
+
if (target === inserted) return;
|
|
363
|
+
const { before } = textAroundCursor(editor);
|
|
364
|
+
if (!before.endsWith(inserted) || before.slice(0, before.length - inserted.length) !== anchor.before) {
|
|
365
|
+
diverged = true;
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (target.startsWith(inserted)) {
|
|
369
|
+
editor.insertTextAtCursor(target.slice(inserted.length));
|
|
370
|
+
} else {
|
|
371
|
+
if (!eraseBeforeCursor(editor, inserted)) {
|
|
372
|
+
diverged = true;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
editor.insertTextAtCursor(target);
|
|
376
|
+
}
|
|
377
|
+
inserted = target;
|
|
378
|
+
requestRender();
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const resetDictation = () => {
|
|
382
|
+
session = null;
|
|
383
|
+
source = null;
|
|
384
|
+
editor = null;
|
|
385
|
+
anchor = null;
|
|
386
|
+
inserted = "";
|
|
387
|
+
lead = null;
|
|
388
|
+
diverged = false;
|
|
389
|
+
sendOnStop = false;
|
|
390
|
+
stopRequested = false;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const finish = (result: VoiceResult) => {
|
|
394
|
+
const text = result.text;
|
|
395
|
+
if (editor && anchor && text) {
|
|
396
|
+
applyLive(text);
|
|
397
|
+
if (!diverged) {
|
|
398
|
+
const trail = padInsertion("", text, anchor.after).endsWith(" ") && anchor.after ? " " : "";
|
|
399
|
+
if (trail) {
|
|
400
|
+
editor.insertTextAtCursor(trail);
|
|
401
|
+
inserted += trail;
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
notify(`Voice text not inserted — the prompt changed while it was processing:\n ${text}`, "warning");
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (result.error) {
|
|
408
|
+
const kind = result.error.kind;
|
|
409
|
+
const level = text ? "warning" : kind === "no_speech" ? "info" : "error";
|
|
410
|
+
notify(text ? `Part of the dictation was lost: ${voiceErrorMessage(result.error)}` : voiceErrorMessage(result.error), level);
|
|
411
|
+
}
|
|
412
|
+
const words = countWords(text);
|
|
413
|
+
const ed = editor;
|
|
414
|
+
const shouldSend = !diverged && !!text && ((sendOnStop && words >= 1) || (settings.autoSubmit && words >= 3));
|
|
415
|
+
resetDictation();
|
|
416
|
+
setUi({ kind: "idle" });
|
|
417
|
+
if (shouldSend && ed) ed.handleInput("\r");
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// ---- recording ------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
const lazySource = (): AudioSource => ({
|
|
423
|
+
async start(onFrames, onEnd) {
|
|
424
|
+
try {
|
|
425
|
+
const opened = await openRecorder(onFrames, (error) => onEnd?.(error));
|
|
426
|
+
source = opened.source;
|
|
427
|
+
} catch (error: any) {
|
|
428
|
+
if (error instanceof RecorderUnavailableError) throw new VoiceError("no_device", error.message);
|
|
429
|
+
throw isVoiceError(error) ? error : new VoiceError("capture_failed", String(error?.message ?? error));
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
stop() {
|
|
433
|
+
source?.stop();
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const startRecording = (strip: number) => {
|
|
438
|
+
const focused = tui?.getFocusedComponent?.();
|
|
439
|
+
if (!isEditorLike(focused)) {
|
|
440
|
+
controller?.reset();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
editor = focused;
|
|
444
|
+
// The warm-up space that reached the editor is not part of the prompt.
|
|
445
|
+
for (let i = 0; i < strip; i++) if (!eraseBeforeCursor(editor, " ")) eraseBeforeCursor(editor, "\u3000");
|
|
446
|
+
anchor = textAroundCursor(editor);
|
|
447
|
+
inserted = "";
|
|
448
|
+
lead = null;
|
|
449
|
+
diverged = false;
|
|
450
|
+
const cwd = ctxRef?.cwd ?? process.cwd();
|
|
451
|
+
const git = gitInfo(cwd);
|
|
452
|
+
const keyterms = buildKeyterms({ repo: git.repo, branch: git.branch, files: [...touchedFiles.slice(-30), ...topLevelNames(cwd)] });
|
|
453
|
+
let started = false;
|
|
454
|
+
const current = new VoiceSession({
|
|
455
|
+
source: lazySource(),
|
|
456
|
+
transcriber: createGatewayTranscriber(),
|
|
457
|
+
language: settings.language && settings.language !== "auto" ? settings.language : null,
|
|
458
|
+
keyterms,
|
|
459
|
+
maxRecordingMs: 120_000,
|
|
460
|
+
silenceAutoStopMs: settings.mode === "tap" ? 15_000 : null,
|
|
461
|
+
breaker,
|
|
462
|
+
onChange: (snapshot) => {
|
|
463
|
+
if (session !== current) return;
|
|
464
|
+
if (snapshot.phase === "recording") {
|
|
465
|
+
started = true;
|
|
466
|
+
setUi({ kind: "recording", snapshot });
|
|
467
|
+
} else if (snapshot.phase === "finalizing") setUi({ kind: "finalizing", snapshot });
|
|
468
|
+
if (snapshot.phase === "recording" || snapshot.phase === "finalizing") applyLive(snapshot.text);
|
|
469
|
+
// The session ended by itself (cap, silence, input ran dry, capture
|
|
470
|
+
// failed): finish it like a release. Errors before the recording
|
|
471
|
+
// started are reported by start() below.
|
|
472
|
+
const ended = snapshot.phase === "finalizing" || snapshot.phase === "done" || (snapshot.phase === "error" && started);
|
|
473
|
+
if (ended && controller?.recording) {
|
|
474
|
+
if (settings.mode === "hold") startDrain();
|
|
475
|
+
controller.reset();
|
|
476
|
+
}
|
|
477
|
+
// Deferred: stop() must not be re-entered from inside its own onChange.
|
|
478
|
+
if (ended && !stopRequested) setTimeout(() => void stopRecording(false, current), 0);
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
session = current;
|
|
482
|
+
setUi({ kind: "starting" });
|
|
483
|
+
current.start().catch((error) => {
|
|
484
|
+
if (session !== current) return;
|
|
485
|
+
controller?.reset();
|
|
486
|
+
if (settings.mode === "hold") startDrain();
|
|
487
|
+
resetDictation();
|
|
488
|
+
setUi({ kind: "idle" });
|
|
489
|
+
notify(isVoiceError(error) ? voiceErrorMessage(error) : `Voice: ${error?.message ?? error}`, "error");
|
|
490
|
+
});
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const stopRecording = async (send: boolean, which: VoiceSession | null = session) => {
|
|
494
|
+
const current = which;
|
|
495
|
+
if (!current || current !== session) return;
|
|
496
|
+
stopRequested = true;
|
|
497
|
+
sendOnStop = sendOnStop || send;
|
|
498
|
+
setUi({ kind: "finalizing", snapshot: current.snapshot() });
|
|
499
|
+
const result = await current.stop();
|
|
500
|
+
if (session !== current) return; // cancelled meanwhile
|
|
501
|
+
finish(result);
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const cancelRecording = () => {
|
|
505
|
+
const current = session;
|
|
506
|
+
if (!current) return;
|
|
507
|
+
current.cancel();
|
|
508
|
+
if (editor && inserted && !diverged) eraseBeforeCursor(editor, inserted);
|
|
509
|
+
resetDictation();
|
|
510
|
+
controller?.reset();
|
|
511
|
+
setUi({ kind: "idle" });
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const startDrain = () => {
|
|
515
|
+
drainKey = true;
|
|
516
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
517
|
+
drainTimer = setTimeout(() => (drainKey = false), 400);
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// ---- key input ------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
const canStart = (): boolean => {
|
|
523
|
+
if (session || !tui) return false;
|
|
524
|
+
if (tui.hasOverlay?.()) return false;
|
|
525
|
+
const focused = tui.getFocusedComponent?.();
|
|
526
|
+
if (!isEditorLike(focused)) return false;
|
|
527
|
+
if (focused.isShowingAutocomplete?.()) return false;
|
|
528
|
+
if (settings.mode === "tap" && voiceKey?.text && focused.getText().trim() !== "") return false;
|
|
529
|
+
if (breaker.isOpen()) {
|
|
530
|
+
if (Date.now() - breakerNoticeAt > 10_000) {
|
|
531
|
+
breakerNoticeAt = Date.now();
|
|
532
|
+
notify("Voice paused after repeated errors. Try again in a moment.", "warning");
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
return true;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const kittyAvailable = () => !!tui?.terminal?.kittyProtocolActive;
|
|
540
|
+
const writeTerminal = (data: string) => {
|
|
541
|
+
if (tui?.terminal?.write) tui.terminal.write(data);
|
|
542
|
+
else process.stdout.write(data);
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
/** Hold-to-talk on Space: ask a Kitty terminal for release events of text keys (flag 8). */
|
|
546
|
+
const syncKittyFlags = () => {
|
|
547
|
+
const want = !!unsubscribe && settings.mode === "hold" && !!voiceKey?.text && kittyAvailable();
|
|
548
|
+
if (want === kittyAllKeys) return;
|
|
549
|
+
if (!want && !kittyAvailable()) {
|
|
550
|
+
kittyAllKeys = false;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
writeTerminal(setFlagsSequence(want ? ALL_KEYS_FLAGS : CORE_FLAGS));
|
|
554
|
+
kittyAllKeys = want;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const onInput = (data: string): { consume?: boolean; data?: string } | undefined => {
|
|
558
|
+
if (!controller || !voiceKey) return undefined;
|
|
559
|
+
// A busy terminal can deliver several auto-repeated spaces in one read:
|
|
560
|
+
// they are separate key events.
|
|
561
|
+
if (voiceKey.text && data.length > 1 && /^[ \u3000]+$/.test(data)) {
|
|
562
|
+
let passed = "";
|
|
563
|
+
for (const ch of data) {
|
|
564
|
+
const result = onInput(ch);
|
|
565
|
+
if (!result) passed += ch;
|
|
566
|
+
else if (!result.consume) passed += result.data ?? ch;
|
|
567
|
+
}
|
|
568
|
+
return passed === data ? undefined : passed ? { data: passed } : { consume: true };
|
|
569
|
+
}
|
|
570
|
+
if (!kittyAllKeys && settings.mode === "hold" && voiceKey.text && kittyAvailable()) syncKittyFlags();
|
|
571
|
+
const legacy = kittyAllKeys ? kittyToLegacy(data) : undefined;
|
|
572
|
+
const kittyEvent = parseKittyKey(data);
|
|
573
|
+
const ours = kittyEvent ? kittyMatches(voiceKey, kittyEvent) : legacyMatches(voiceKey, data);
|
|
574
|
+
|
|
575
|
+
if (drainKey) {
|
|
576
|
+
if (ours) {
|
|
577
|
+
if (kittyEvent?.event === "release") drainKey = false;
|
|
578
|
+
else startDrain();
|
|
579
|
+
return { consume: true };
|
|
580
|
+
}
|
|
581
|
+
drainKey = false;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Between release and the last segment's text: Esc/Ctrl+C cancels.
|
|
585
|
+
if (session && !controller.recording) {
|
|
586
|
+
if (isCancelKey(data)) {
|
|
587
|
+
cancelRecording();
|
|
588
|
+
return { consume: true };
|
|
589
|
+
}
|
|
590
|
+
// Late repeats of a still-held key must not type spaces into the result.
|
|
591
|
+
if (ours && settings.mode === "hold") return { consume: true };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const action = controller.handle(data);
|
|
595
|
+
if (action.type === "consume") return { consume: true };
|
|
596
|
+
if (legacy !== undefined) return legacy === "" ? { consume: true } : { data: legacy };
|
|
597
|
+
return undefined;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const buildController = () => {
|
|
601
|
+
voiceKey = parseVoiceKey(settings.key);
|
|
602
|
+
controller = new KeyController({
|
|
603
|
+
mode: settings.mode,
|
|
604
|
+
key: voiceKey,
|
|
605
|
+
kitty: () => kittyAllKeys || (!!voiceKey && !voiceKey.text && kittyAvailable()),
|
|
606
|
+
canStart,
|
|
607
|
+
callbacks: {
|
|
608
|
+
onWarmup: () => setUi({ kind: "warmup" }),
|
|
609
|
+
onWarmupAbort: (heldBack) => {
|
|
610
|
+
const focused = tui?.getFocusedComponent?.();
|
|
611
|
+
if (isEditorLike(focused)) focused.insertTextAtCursor(" ".repeat(heldBack));
|
|
612
|
+
setUi({ kind: "idle" });
|
|
613
|
+
},
|
|
614
|
+
onStart: (strip) => startRecording(strip),
|
|
615
|
+
onStop: (send) => void stopRecording(send),
|
|
616
|
+
onCancel: () => cancelRecording(),
|
|
617
|
+
typeKey: () => {
|
|
618
|
+
const focused = tui?.getFocusedComponent?.();
|
|
619
|
+
if (isEditorLike(focused)) focused.handleInput(" ");
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
// ---- arming ---------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
const arm = (ctx: any): string | null => {
|
|
628
|
+
if (ctx?.mode && ctx.mode !== "tui") return "Voice works in the interactive terminal only.";
|
|
629
|
+
try {
|
|
630
|
+
buildController();
|
|
631
|
+
} catch (error: any) {
|
|
632
|
+
return `Voice key "${settings.key}": ${error?.message ?? error}`;
|
|
633
|
+
}
|
|
634
|
+
ctx.ui.setWidget(
|
|
635
|
+
WIDGET,
|
|
636
|
+
(t: any, th: any) => {
|
|
637
|
+
tui = t;
|
|
638
|
+
theme = th;
|
|
639
|
+
return widget;
|
|
640
|
+
},
|
|
641
|
+
{ placement: "belowEditor" },
|
|
642
|
+
);
|
|
643
|
+
if (!tui) {
|
|
644
|
+
ctx.ui.setWidget(WIDGET, undefined);
|
|
645
|
+
return "Voice works in the interactive terminal only.";
|
|
646
|
+
}
|
|
647
|
+
unsubscribe?.();
|
|
648
|
+
unsubscribe = ctx.ui.onTerminalInput(onInput);
|
|
649
|
+
// The Kitty negotiation may still be running at startup: check again shortly.
|
|
650
|
+
syncKittyFlags();
|
|
651
|
+
setTimeout(syncKittyFlags, 600).unref?.();
|
|
652
|
+
const hint = settings.mode === "hold" ? `hold ${keyLabel()}` : `tap ${keyLabel()}`;
|
|
653
|
+
ctx.ui.setStatus?.(STATUS, color("dim", `voice: ${hint}`));
|
|
654
|
+
return null;
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const disarm = (ctx: any) => {
|
|
658
|
+
cancelRecording();
|
|
659
|
+
unsubscribe?.();
|
|
660
|
+
unsubscribe = null;
|
|
661
|
+
syncKittyFlags();
|
|
662
|
+
controller = null;
|
|
663
|
+
ctx?.ui?.setWidget?.(WIDGET, undefined);
|
|
664
|
+
ctx?.ui?.setStatus?.(STATUS, undefined);
|
|
665
|
+
setUi({ kind: "idle" });
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const save = () => {
|
|
669
|
+
try {
|
|
670
|
+
saveVoiceSettings(dir, settings);
|
|
671
|
+
} catch (error: any) {
|
|
672
|
+
notify(`Voice settings not saved (${error?.message ?? error})`, "warning");
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const enable = async (ctx: any, mode: VoiceMode) => {
|
|
677
|
+
if (ctx?.mode && ctx.mode !== "tui") {
|
|
678
|
+
notify("Voice works in the interactive terminal only.", "warning");
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (!currentToken()) {
|
|
682
|
+
notify("Voice needs you signed in — run `omnirush login` first.", "error");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const probe = realProbe();
|
|
686
|
+
const remote = remoteReason(probe);
|
|
687
|
+
if (remote) {
|
|
688
|
+
notify(`Voice can't use a microphone here: ${remote}.`, "error");
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const availability = await checkAvailability();
|
|
692
|
+
if (!availability.available) {
|
|
693
|
+
notify(availabilityMessage(availability), "error");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
let recorder = "";
|
|
697
|
+
try {
|
|
698
|
+
const check = await testCapture(300);
|
|
699
|
+
recorder = check.recorder;
|
|
700
|
+
if (check.recorder !== "file" && check.maxRms < 0.001) {
|
|
701
|
+
notify("No audio detected from the microphone in a short test — check the input device (and the terminal's microphone permission).", "warning");
|
|
702
|
+
}
|
|
703
|
+
} catch (error: any) {
|
|
704
|
+
notify(error?.message ?? String(error), "error");
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const previous = { ...settings };
|
|
708
|
+
settings = { ...settings, enabled: true, mode };
|
|
709
|
+
const failure = arm(ctx);
|
|
710
|
+
if (failure) {
|
|
711
|
+
settings = previous;
|
|
712
|
+
notify(failure, "error");
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const firstTime = !settings.noticeShown;
|
|
716
|
+
settings.noticeShown = true;
|
|
717
|
+
save();
|
|
718
|
+
const how = mode === "hold" ? `Hold ${keyLabel()} to talk, release to insert` : `Tap ${keyLabel()} on an empty prompt to talk, tap again to stop`;
|
|
719
|
+
const lines = [`Voice on (${mode}). ${how} · Esc cancels · auto-send ${settings.autoSubmit ? "on" : "off"} · mic: ${recorder}`];
|
|
720
|
+
if (firstTime) lines.push(PRIVACY_NOTICE);
|
|
721
|
+
notify(lines.join("\n"));
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const statusLines = async (): Promise<string[]> => {
|
|
725
|
+
const availability = await checkAvailability();
|
|
726
|
+
const remote = remoteReason(realProbe());
|
|
727
|
+
return [
|
|
728
|
+
"Voice:",
|
|
729
|
+
` state ${settings.enabled ? `on (${settings.mode})` : "off"}`,
|
|
730
|
+
` key ${settings.key}${kittyAllKeys ? " (Kitty key-release events)" : ""}`,
|
|
731
|
+
` auto-send ${settings.autoSubmit ? "on (3+ words)" : "off"}`,
|
|
732
|
+
` language ${settings.language}`,
|
|
733
|
+
` server ${availability.available ? "available" : availabilityMessage(availability)}`,
|
|
734
|
+
` microphone ${remote ?? "local session"}`,
|
|
735
|
+
];
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
pi.registerCommand("voice", {
|
|
739
|
+
description: "Dictate prompts: /voice [hold|tap|off|status|key <key>|auto-send on|off]",
|
|
740
|
+
getArgumentCompletions: (prefix: string) => {
|
|
741
|
+
const items = ["hold", "tap", "off", "status", "key", "auto-send"].map((value) => ({ value, label: value }));
|
|
742
|
+
const typed = prefix.trim();
|
|
743
|
+
// An exact match offers nothing, so Enter runs the command at once.
|
|
744
|
+
const filtered = items.filter((item) => item.value.startsWith(typed) && item.value !== typed);
|
|
745
|
+
return filtered.length ? filtered : null;
|
|
746
|
+
},
|
|
747
|
+
handler: async (args: string, ctx: any) => {
|
|
748
|
+
ctxRef = ctx;
|
|
749
|
+
const [sub = "", ...rest] = String(args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
750
|
+
const arg = rest.join(" ");
|
|
751
|
+
switch (sub.toLowerCase()) {
|
|
752
|
+
case "":
|
|
753
|
+
if (settings.enabled && unsubscribe) {
|
|
754
|
+
settings = { ...settings, enabled: false };
|
|
755
|
+
disarm(ctx);
|
|
756
|
+
save();
|
|
757
|
+
notify("Voice off.");
|
|
758
|
+
} else {
|
|
759
|
+
await enable(ctx, settings.mode);
|
|
760
|
+
}
|
|
761
|
+
return;
|
|
762
|
+
case "hold":
|
|
763
|
+
case "tap":
|
|
764
|
+
if (settings.enabled && unsubscribe) disarm(ctx);
|
|
765
|
+
await enable(ctx, sub.toLowerCase() as VoiceMode);
|
|
766
|
+
return;
|
|
767
|
+
case "off":
|
|
768
|
+
settings = { ...settings, enabled: false };
|
|
769
|
+
disarm(ctx);
|
|
770
|
+
save();
|
|
771
|
+
notify("Voice off.");
|
|
772
|
+
return;
|
|
773
|
+
case "status":
|
|
774
|
+
notify((await statusLines()).join("\n"));
|
|
775
|
+
return;
|
|
776
|
+
case "key": {
|
|
777
|
+
if (!arg) {
|
|
778
|
+
notify(`Voice key: ${settings.key}. Change with /voice key <space|alt+m|ctrl+space|…>`);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
const parsed = parseVoiceKey(arg);
|
|
783
|
+
settings = { ...settings, key: parsed.id };
|
|
784
|
+
} catch (error: any) {
|
|
785
|
+
notify(`Voice key not changed: ${error?.message ?? error}`, "error");
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
save();
|
|
789
|
+
if (settings.enabled && unsubscribe) {
|
|
790
|
+
disarm(ctx);
|
|
791
|
+
arm(ctx);
|
|
792
|
+
}
|
|
793
|
+
notify(`Voice key: ${settings.key}${parseVoiceKey(settings.key).text ? "" : " (starts instantly)"}.`);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
case "auto-send":
|
|
797
|
+
case "autosend": {
|
|
798
|
+
const value = arg.toLowerCase();
|
|
799
|
+
if (value !== "on" && value !== "off") {
|
|
800
|
+
notify(`Auto-send is ${settings.autoSubmit ? "on" : "off"}. Use /voice auto-send on|off.`);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
settings = { ...settings, autoSubmit: value === "on" };
|
|
804
|
+
save();
|
|
805
|
+
notify(`Voice auto-send ${value} (sends on release when the text has at least 3 words).`);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
default:
|
|
809
|
+
notify("Usage: /voice [hold|tap|off|status|key <key>|auto-send on|off]", "warning");
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
pi.on("session_start", (_event: any, ctx: any) => {
|
|
815
|
+
ctxRef = ctx;
|
|
816
|
+
settings = loadVoiceSettings(dir);
|
|
817
|
+
if (!settings.enabled || ctx?.mode !== "tui") return;
|
|
818
|
+
if (remoteReason(realProbe())) return; // stays saved; a local session picks it up
|
|
819
|
+
const failure = arm(ctx);
|
|
820
|
+
if (failure) notify(failure, "warning");
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
pi.on("session_shutdown", (_event: any, ctx: any) => {
|
|
824
|
+
disarm(ctx ?? ctxRef);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// Files the agent works on feed the keyterms (names only, never contents).
|
|
828
|
+
pi.on("tool_call", (event: any) => {
|
|
829
|
+
const input = event?.input ?? {};
|
|
830
|
+
for (const key of ["path", "file_path", "filePath"]) {
|
|
831
|
+
const value = input[key];
|
|
832
|
+
if (typeof value === "string" && value) {
|
|
833
|
+
touchedFiles.push(path.basename(value));
|
|
834
|
+
if (touchedFiles.length > 60) touchedFiles.splice(0, touchedFiles.length - 60);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
}
|