omnirush 0.8.5 → 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.
Files changed (49) hide show
  1. package/assets/CHANGELOG.md +85 -0
  2. package/assets/collect-once.ts +6 -5
  3. package/assets/extensions/omnirush/agents-lib.ts +196 -12
  4. package/assets/extensions/omnirush/agents.ts +97 -105
  5. package/assets/extensions/omnirush/auth.js +399 -115
  6. package/assets/extensions/omnirush/bgshell-lib.ts +400 -0
  7. package/assets/extensions/omnirush/bgshell.ts +392 -0
  8. package/assets/extensions/omnirush/collector.ts +103 -16
  9. package/assets/extensions/omnirush/commands.ts +14 -15
  10. package/assets/extensions/omnirush/deliveries.ts +145 -0
  11. package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
  12. package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
  13. package/assets/extensions/omnirush/guard-lib.ts +230 -0
  14. package/assets/extensions/omnirush/guard.ts +340 -0
  15. package/assets/extensions/omnirush/index.ts +12 -0
  16. package/assets/extensions/omnirush/pi-engine.ts +65 -1
  17. package/assets/extensions/omnirush/sota.ts +104 -47
  18. package/assets/extensions/omnirush/status-lib.ts +3 -0
  19. package/assets/extensions/omnirush/subagents-lib.ts +623 -0
  20. package/assets/extensions/omnirush/subagents.ts +305 -0
  21. package/assets/extensions/omnirush/swarm-lib.ts +142 -0
  22. package/assets/extensions/omnirush/swarm.ts +95 -0
  23. package/assets/extensions/omnirush/voice/capture.ts +502 -0
  24. package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
  25. package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
  26. package/assets/extensions/omnirush/voice/core/index.ts +21 -0
  27. package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
  28. package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
  29. package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
  30. package/assets/extensions/omnirush/voice/core/session.ts +403 -0
  31. package/assets/extensions/omnirush/voice/core/text.ts +81 -0
  32. package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
  33. package/assets/extensions/omnirush/voice/core/types.ts +102 -0
  34. package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
  35. package/assets/extensions/omnirush/voice/keys.ts +435 -0
  36. package/assets/extensions/omnirush/voice/kitty.ts +64 -0
  37. package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
  38. package/assets/extensions/omnirush/voice/settings.ts +67 -0
  39. package/assets/extensions/omnirush/voice.ts +838 -0
  40. package/assets/extensions/omnirush/yolo-lib.ts +80 -0
  41. package/assets/extensions/omnirush/yolo.ts +85 -0
  42. package/package.json +6 -2
  43. package/scripts/brand-engine.js +526 -0
  44. package/scripts/build-all-packages.py +29 -1
  45. package/scripts/smoke-packages.py +32 -1
  46. package/src/bin.js +252 -53
  47. package/src/compat.js +272 -0
  48. package/src/lib.js +64 -0
  49. package/scripts/patch-pi-branding.js +0 -251
@@ -0,0 +1,102 @@
1
+ /** 16 kHz mono signed 16-bit PCM: the only format voice-core produces and uploads. */
2
+ export const VOICE_SAMPLE_RATE = 16_000;
3
+
4
+ export type VoiceErrorKind =
5
+ /** The microphone delivered only silence (RMS never rose above the no-signal floor). */
6
+ | "no_signal"
7
+ /** There was sound, but nothing that transcribed to words. */
8
+ | "no_speech"
9
+ /** The transcription service could not be reached, or kept failing. */
10
+ | "network"
11
+ /** Voice is switched off for this account or server (503 voice_unavailable). */
12
+ | "unavailable"
13
+ /** The service could not decode the audio (422 audio_unreadable). */
14
+ | "unreadable"
15
+ /** Too many requests or the daily audio budget is spent (429). */
16
+ | "rate_limited"
17
+ /** The account is signed out or its session expired (401). */
18
+ | "signed_out"
19
+ /** The OS or the user refused microphone access. */
20
+ | "permission_denied"
21
+ /** No microphone is connected, or the chosen one is gone. */
22
+ | "no_device"
23
+ /** The microphone exists but could not be opened (in use, driver error). */
24
+ | "capture_failed"
25
+ /** Three failures in a short window: voice pauses for a moment. */
26
+ | "breaker_open"
27
+ /** The recording or a segment was larger than the service accepts. */
28
+ | "too_large";
29
+
30
+ export class VoiceError extends Error {
31
+ readonly kind: VoiceErrorKind;
32
+ readonly retryable: boolean;
33
+ /** How long the service asked to wait before the next try, when it said. */
34
+ readonly retryAfterMs: number | null;
35
+ readonly status: number | null;
36
+
37
+ constructor(kind: VoiceErrorKind, message: string, options: { retryable?: boolean; retryAfterMs?: number | null; status?: number | null } = {}) {
38
+ super(message);
39
+ this.name = "VoiceError";
40
+ this.kind = kind;
41
+ this.retryable = options.retryable ?? false;
42
+ this.retryAfterMs = options.retryAfterMs ?? null;
43
+ this.status = options.status ?? null;
44
+ }
45
+ }
46
+
47
+ export function isVoiceError(value: unknown): value is VoiceError {
48
+ return value instanceof VoiceError;
49
+ }
50
+
51
+ /** One speech segment's upload. */
52
+ export type TranscribeRequest = {
53
+ /** A complete RIFF/WAVE file, 16 kHz mono s16le. */
54
+ wav: Uint8Array;
55
+ segmentIndex: number;
56
+ recordingId: string;
57
+ /** BCP-47 hint, or null for auto-detect. */
58
+ language: string | null;
59
+ keyterms: readonly string[];
60
+ durationMs: number;
61
+ };
62
+
63
+ export type TranscribeResult = { text: string };
64
+
65
+ export interface Transcriber {
66
+ /** Resolves with the segment's text; rejects with a VoiceError (or anything, read as a network failure). */
67
+ transcribe(request: TranscribeRequest, signal: AbortSignal): Promise<TranscribeResult>;
68
+ }
69
+
70
+ /**
71
+ * Where PCM comes from: a microphone, a WAV file (tests), a native recorder
72
+ * (CLI). Frames are 16 kHz mono Int16 of any length.
73
+ */
74
+ export interface AudioSource {
75
+ start(onFrames: (frames: Int16Array) => void, onEnd?: (error?: VoiceError) => void): Promise<void>;
76
+ stop(): void;
77
+ }
78
+
79
+ export type VoicePhase = "idle" | "arming" | "recording" | "finalizing" | "done" | "cancelled" | "error";
80
+
81
+ export type VoiceSnapshot = {
82
+ phase: VoicePhase;
83
+ /** The last 16 input levels, 0..1, oldest first. */
84
+ levels: number[];
85
+ elapsedMs: number;
86
+ /** Text of the finished segments, in order, stopped at the first one still in flight. */
87
+ text: string;
88
+ /** Segments uploaded (or queued) whose text has not arrived yet. */
89
+ pending: number;
90
+ /** Segments that failed for good; their audio is lost, the rest of the text is kept. */
91
+ failed: number;
92
+ error: VoiceError | null;
93
+ };
94
+
95
+ export type VoiceResult = {
96
+ text: string;
97
+ /** Words in `text`, counting CJK characters one by one. */
98
+ words: number;
99
+ segments: number;
100
+ /** Set when nothing usable came back (no signal, no speech, service down) or part of it was lost. */
101
+ error: VoiceError | null;
102
+ };
@@ -0,0 +1,95 @@
1
+ import { VOICE_SAMPLE_RATE } from "./types";
2
+
3
+ /** A RIFF/WAVE file holding `samples` as 16-bit PCM, mono. */
4
+ export function encodeWav(samples: Int16Array, sampleRate = VOICE_SAMPLE_RATE): Uint8Array {
5
+ const dataBytes = samples.length * 2;
6
+ const bytes = new Uint8Array(44 + dataBytes);
7
+ const view = new DataView(bytes.buffer);
8
+ const ascii = (offset: number, text: string) => {
9
+ for (let index = 0; index < text.length; index += 1) view.setUint8(offset + index, text.charCodeAt(index));
10
+ };
11
+ ascii(0, "RIFF");
12
+ view.setUint32(4, 36 + dataBytes, true);
13
+ ascii(8, "WAVE");
14
+ ascii(12, "fmt ");
15
+ view.setUint32(16, 16, true);
16
+ view.setUint16(20, 1, true); // PCM
17
+ view.setUint16(22, 1, true); // mono
18
+ view.setUint32(24, sampleRate, true);
19
+ view.setUint32(28, sampleRate * 2, true);
20
+ view.setUint16(32, 2, true);
21
+ view.setUint16(34, 16, true);
22
+ ascii(36, "data");
23
+ view.setUint32(40, dataBytes, true);
24
+ for (let index = 0; index < samples.length; index += 1) view.setInt16(44 + index * 2, samples[index]!, true);
25
+ return bytes;
26
+ }
27
+
28
+ export type DecodedWav = {
29
+ sampleRate: number;
30
+ /** Mono, -1..1 (channels averaged). */
31
+ samples: Float32Array;
32
+ };
33
+
34
+ /**
35
+ * Reads a PCM WAV file: 8/16/24/32-bit integer or 32-bit float, any channel
36
+ * count and sample rate. Used by the file input override, never on mic audio.
37
+ */
38
+ export function decodeWav(input: Uint8Array): DecodedWav {
39
+ const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
40
+ const tag = (offset: number) => String.fromCharCode(...input.subarray(offset, offset + 4));
41
+ if (input.byteLength < 12 || tag(0) !== "RIFF" || tag(8) !== "WAVE") throw new Error("Not a WAV file");
42
+ let format = 0;
43
+ let channels = 0;
44
+ let sampleRate = 0;
45
+ let bits = 0;
46
+ let offset = 12;
47
+ while (offset + 8 <= input.byteLength) {
48
+ const id = tag(offset);
49
+ const size = view.getUint32(offset + 4, true);
50
+ const body = offset + 8;
51
+ if (id === "fmt ") {
52
+ format = view.getUint16(body, true);
53
+ channels = view.getUint16(body + 2, true);
54
+ sampleRate = view.getUint32(body + 4, true);
55
+ bits = view.getUint16(body + 14, true);
56
+ if (format === 0xfffe && size >= 26) format = view.getUint16(body + 24, true);
57
+ } else if (id === "data") {
58
+ if (!channels || !sampleRate || !bits) throw new Error("WAV data before its format");
59
+ const end = Math.min(input.byteLength, body + size);
60
+ const width = bits / 8;
61
+ const frames = Math.floor((end - body) / (width * channels));
62
+ const samples = new Float32Array(frames);
63
+ for (let frame = 0; frame < frames; frame += 1) {
64
+ let sum = 0;
65
+ for (let channel = 0; channel < channels; channel += 1) {
66
+ const at = body + (frame * channels + channel) * width;
67
+ sum += readSample(view, at, format, bits);
68
+ }
69
+ samples[frame] = sum / channels;
70
+ }
71
+ return { sampleRate, samples };
72
+ }
73
+ offset = body + size + (size % 2);
74
+ }
75
+ throw new Error("WAV file has no data");
76
+ }
77
+
78
+ function readSample(view: DataView, at: number, format: number, bits: number): number {
79
+ if (format === 3 && bits === 32) return view.getFloat32(at, true);
80
+ if (format !== 1) throw new Error(`Unsupported WAV format ${format}`);
81
+ switch (bits) {
82
+ case 8:
83
+ return (view.getUint8(at) - 128) / 128;
84
+ case 16:
85
+ return view.getInt16(at, true) / 32768;
86
+ case 24: {
87
+ const value = view.getUint8(at) | (view.getUint8(at + 1) << 8) | (view.getInt8(at + 2) << 16);
88
+ return value / 8388608;
89
+ }
90
+ case 32:
91
+ return view.getInt32(at, true) / 2147483648;
92
+ default:
93
+ throw new Error(`Unsupported WAV bit depth ${bits}`);
94
+ }
95
+ }
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Push-to-talk key handling for a terminal (pure: timers and clock are
3
+ * injected, so the heuristics are unit-tested without a pty).
4
+ *
5
+ * Terminals have no key-up event in their legacy encoding, so there are
6
+ * two paths:
7
+ *
8
+ * - Kitty keyboard protocol (kitty, WezTerm, Ghostty, foot, recent iTerm2):
9
+ * real press / repeat / release events. The agent core enables the
10
+ * protocol with flags 1|2|4; a plain text key such as Space only reports
11
+ * its release with flag 8 ("all keys as escape codes"), which voice mode
12
+ * turns on while hold-to-talk is armed (see kitty.ts). A press held past
13
+ * HOLD_MS (or auto-repeating) records, the release stops; a quick tap
14
+ * still types a space.
15
+ * - Legacy terminals: the auto-repeat heuristic. A held key auto-repeats;
16
+ * recording starts after WARMUP_EVENTS rapid events, the warm-up spaces
17
+ * are stripped (the first one reached the editor, the rest were held
18
+ * back), and RELEASE_MS without a repeat means the key was released.
19
+ *
20
+ * Modifier combos (alt+m, ctrl+space, …) are not text, so they start on
21
+ * the first event with no warm-up. Tap mode toggles instead of holding.
22
+ */
23
+
24
+ export type VoiceMode = "hold" | "tap";
25
+
26
+ export interface VoiceKey {
27
+ /** Canonical id, e.g. "space", "alt+m", "ctrl+space". */
28
+ id: string;
29
+ /** The key without modifiers: "space" or a single character. */
30
+ base: string;
31
+ ctrl: boolean;
32
+ alt: boolean;
33
+ shift: boolean;
34
+ super: boolean;
35
+ /** Plain text key (types a character), i.e. needs the warm-up. */
36
+ text: boolean;
37
+ /** Kitty codepoint of the base key. */
38
+ codepoint: number;
39
+ }
40
+
41
+ const RESERVED = new Set(["ctrl+c", "ctrl+d", "ctrl+z", "ctrl+m", "ctrl+i", "ctrl+j", "ctrl+[", "enter", "escape", "esc", "tab", "backspace"]);
42
+
43
+ /** Parse a key setting; throws with a user-facing message when unusable. */
44
+ export function parseVoiceKey(raw: string): VoiceKey {
45
+ const value = String(raw || "").trim().toLowerCase().replace(/\s+/g, "");
46
+ if (!value) throw new Error("empty key");
47
+ if (value.includes("capslock") || value.includes("caps")) {
48
+ throw new Error("Caps Lock can't be used: terminals don't deliver it to programs");
49
+ }
50
+ if (RESERVED.has(value)) throw new Error(`${value} is reserved by the prompt`);
51
+ const parts = value.split("+");
52
+ const base = parts.pop() ?? "";
53
+ const mods = new Set(parts);
54
+ for (const mod of mods) {
55
+ if (!["ctrl", "alt", "shift", "super", "meta", "option", "cmd"].includes(mod)) throw new Error(`unknown modifier "${mod}"`);
56
+ }
57
+ const alt = mods.has("alt") || mods.has("meta") || mods.has("option");
58
+ const ctrl = mods.has("ctrl");
59
+ const shift = mods.has("shift");
60
+ const sup = mods.has("super") || mods.has("cmd");
61
+ let codepoint: number;
62
+ if (base === "space") codepoint = 32;
63
+ else if ([...base].length === 1) codepoint = base.codePointAt(0)!;
64
+ else throw new Error(`unsupported key "${base}" (use space or a single character, with ctrl/alt/shift)`);
65
+ const text = !ctrl && !alt && !sup;
66
+ if (text && base !== "space") throw new Error(`a plain "${base}" would stop you typing it — use space or add a modifier (e.g. alt+${base})`);
67
+ if (shift && text) throw new Error("shift+space types a space in most terminals — use space or ctrl/alt+space");
68
+ const canonical = [ctrl && "ctrl", alt && "alt", shift && "shift", sup && "super", base].filter(Boolean).join("+");
69
+ if (RESERVED.has(canonical)) throw new Error(`${canonical} is reserved by the prompt`);
70
+ return { id: canonical, base, ctrl, alt, shift, super: sup, text, codepoint };
71
+ }
72
+
73
+ export interface KittyKeyEvent {
74
+ codepoint: number;
75
+ /** Modifier bits (shift 1, alt 2, ctrl 4, super 8), lock bits removed. */
76
+ modifiers: number;
77
+ event: "press" | "repeat" | "release";
78
+ }
79
+
80
+ const KITTY_CSI_U = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+)(?::(\d+))?)?(?:;[\d:]*)?u$/;
81
+
82
+ /** Parse a Kitty CSI-u key event (`CSI cp[:alts] [; mods[:event]] u`). */
83
+ export function parseKittyKey(data: string): KittyKeyEvent | null {
84
+ const m = KITTY_CSI_U.exec(data);
85
+ if (!m) return null;
86
+ const codepoint = Number(m[1]);
87
+ const mods = m[4] ? Number(m[4]) - 1 : 0;
88
+ const ev = m[5] ? Number(m[5]) : 1;
89
+ return {
90
+ codepoint,
91
+ modifiers: mods & 0b1111,
92
+ event: ev === 3 ? "release" : ev === 2 ? "repeat" : "press",
93
+ };
94
+ }
95
+
96
+ function modifierBits(key: VoiceKey): number {
97
+ return (key.shift ? 1 : 0) | (key.alt ? 2 : 0) | (key.ctrl ? 4 : 0) | (key.super ? 8 : 0);
98
+ }
99
+
100
+ /** Does a Kitty event belong to the voice key? */
101
+ export function kittyMatches(key: VoiceKey, ev: KittyKeyEvent): boolean {
102
+ if (ev.modifiers !== modifierBits(key)) return false;
103
+ if (ev.codepoint === key.codepoint) return true;
104
+ // Some terminals report the shifted codepoint for letters.
105
+ return key.base.length === 1 && ev.codepoint === key.base.toUpperCase().codePointAt(0);
106
+ }
107
+
108
+ /** Legacy / modifyOtherKeys encodings of the voice key (press only). */
109
+ export function legacyMatches(key: VoiceKey, data: string): boolean {
110
+ if (key.text) return data === " " || data === " "; // CJK IME full-width space too
111
+ const mot = /^\x1b\[27;(\d+);(\d+)~$/.exec(data); // xterm modifyOtherKeys
112
+ if (mot) return Number(mot[2]) === key.codepoint && Number(mot[1]) - 1 === modifierBits(key);
113
+ if (key.alt && !key.ctrl && !key.super) {
114
+ const ch = key.base === "space" ? " " : key.shift ? key.base.toUpperCase() : key.base;
115
+ return data === `\x1b${ch}`;
116
+ }
117
+ if (key.ctrl && !key.alt && !key.super && !key.shift) {
118
+ if (key.base === "space") return data === "\x00";
119
+ const code = key.base.toUpperCase().charCodeAt(0);
120
+ if (code >= 64 && code <= 95) return data === String.fromCharCode(code - 64);
121
+ }
122
+ if (key.ctrl && key.alt && !key.super && !key.shift) {
123
+ if (key.base === "space") return data === "\x1b\x00";
124
+ const code = key.base.toUpperCase().charCodeAt(0);
125
+ if (code >= 64 && code <= 95) return data === `\x1b${String.fromCharCode(code - 64)}`;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /** Esc or Ctrl+C in any encoding. */
131
+ export function isCancelKey(data: string): boolean {
132
+ if (data === "\x1b" || data === "\x03") return true;
133
+ const ev = parseKittyKey(data);
134
+ if (!ev || ev.event === "release") return false;
135
+ return (ev.codepoint === 27 && ev.modifiers === 0) || (ev.codepoint === 99 && ev.modifiers === 4);
136
+ }
137
+
138
+ /** Enter in any encoding (tap mode: stop and send). */
139
+ export function isEnterKey(data: string): boolean {
140
+ if (data === "\r") return true;
141
+ const ev = parseKittyKey(data);
142
+ return !!ev && ev.event === "press" && ev.codepoint === 13 && ev.modifiers === 0;
143
+ }
144
+
145
+ export interface Timers {
146
+ set(fn: () => void, ms: number): unknown;
147
+ clear(handle: unknown): void;
148
+ }
149
+
150
+ export const realTimers: Timers = {
151
+ set: (fn, ms) => setTimeout(fn, ms),
152
+ clear: (handle) => clearTimeout(handle as any),
153
+ };
154
+
155
+ export interface KeyTimings {
156
+ /**
157
+ * Legacy: rapid auto-repeats (each within repeatGapMs of the previous)
158
+ * needed before recording starts. The first press types a space; every
159
+ * event after it is held back until the hold is confirmed or abandoned.
160
+ */
161
+ warmupRepeats: number;
162
+ /** Legacy: the first auto-repeat can take this long (OS repeat delay). */
163
+ firstRepeatMs: number;
164
+ /** Legacy: max gap between rapid repeats during the warm-up. */
165
+ repeatGapMs: number;
166
+ /** Legacy: this long without a repeat means released. */
167
+ releaseMs: number;
168
+ /** Legacy combos: release fallback before the first repeat was seen. */
169
+ comboFirstReleaseMs: number;
170
+ /** Kitty: a press held this long starts recording. */
171
+ holdMs: number;
172
+ /** Tap mode: key events closer than this belong to one (auto-repeating) press. */
173
+ tapDebounceMs: number;
174
+ }
175
+
176
+ export const DEFAULT_TIMINGS: KeyTimings = {
177
+ warmupRepeats: 4,
178
+ firstRepeatMs: 700,
179
+ repeatGapMs: 120,
180
+ releaseMs: 150,
181
+ comboFirstReleaseMs: 700,
182
+ holdMs: 200,
183
+ tapDebounceMs: 250,
184
+ };
185
+
186
+ /** What to do with an input: let it through (to the editor) or swallow it. */
187
+ export type KeyAction = { type: "pass" } | { type: "consume" };
188
+
189
+ export interface KeyCallbacks {
190
+ /** Legacy warm-up in progress ("keep holding…"). */
191
+ onWarmup?(): void;
192
+ /** Warm-up abandoned: the held-back spaces must be typed after all. */
193
+ onWarmupAbort?(heldBack: number): void;
194
+ /** Start recording; `strip` spaces already reached the editor and must be removed. */
195
+ onStart(strip: number): void;
196
+ /** Key released (hold) or pressed again (tap): finalize. `send` = Enter in tap mode. */
197
+ onStop(send: boolean): void;
198
+ onCancel(): void;
199
+ /** A Kitty press turned out to be a quick tap: type the key (a space) now. */
200
+ typeKey?(): void;
201
+ }
202
+
203
+ export interface KeyControllerOptions {
204
+ mode: VoiceMode;
205
+ key: VoiceKey;
206
+ /** Kitty release events are available for this key right now. */
207
+ kitty: () => boolean;
208
+ /** May a recording start now (editor focused, no overlay, tap: editor empty for Space). */
209
+ canStart: () => boolean;
210
+ callbacks: KeyCallbacks;
211
+ timings?: Partial<KeyTimings>;
212
+ timers?: Timers;
213
+ now?: () => number;
214
+ }
215
+
216
+ type Phase = "idle" | "warmup" | "armed" | "recording";
217
+
218
+ /**
219
+ * Turns raw terminal input into start/stop/cancel of a recording. Returns
220
+ * what to do with each input (let it through, swallow it, or replace it).
221
+ */
222
+ export class KeyController {
223
+ phase: Phase = "idle";
224
+ private readonly t: KeyTimings;
225
+ private readonly timers: Timers;
226
+ private readonly now: () => number;
227
+ private timer: unknown = null;
228
+ private count = 0;
229
+ private heldBack = 0;
230
+ private sawRepeat = false;
231
+ private lastKeyAt = -Infinity;
232
+
233
+ constructor(private readonly o: KeyControllerOptions) {
234
+ this.t = { ...DEFAULT_TIMINGS, ...(o.timings ?? {}) };
235
+ this.timers = o.timers ?? realTimers;
236
+ this.now = o.now ?? (() => Date.now());
237
+ }
238
+
239
+ get recording(): boolean {
240
+ return this.phase === "recording";
241
+ }
242
+
243
+ /** Reset to idle without callbacks (the extension ended the recording itself). */
244
+ reset(): void {
245
+ this.clearTimer();
246
+ this.phase = "idle";
247
+ this.count = 0;
248
+ this.heldBack = 0;
249
+ this.sawRepeat = false;
250
+ }
251
+
252
+ handle(data: string): KeyAction {
253
+ return this.o.mode === "tap" ? this.handleTap(data) : this.handleHold(data);
254
+ }
255
+
256
+ // ---- hold ---------------------------------------------------------------
257
+
258
+ private handleHold(data: string): KeyAction {
259
+ const key = this.o.key;
260
+ const kittyEvent = parseKittyKey(data);
261
+ const ours = kittyEvent ? kittyMatches(key, kittyEvent) : legacyMatches(key, data);
262
+
263
+ if (this.phase === "recording") {
264
+ if (isCancelKey(data) && !ours) {
265
+ this.reset();
266
+ this.o.callbacks.onCancel();
267
+ return { type: "consume" };
268
+ }
269
+ if (ours) {
270
+ if (kittyEvent && this.o.kitty()) {
271
+ if (kittyEvent.event === "release") {
272
+ this.reset();
273
+ this.o.callbacks.onStop(false);
274
+ }
275
+ return { type: "consume" };
276
+ }
277
+ // Legacy: every repeat pushes the inferred release further out.
278
+ this.sawRepeat = true;
279
+ this.armRelease();
280
+ return { type: "consume" };
281
+ }
282
+ // Other keys while holding: swallowed (releases are dropped too).
283
+ return { type: "consume" };
284
+ }
285
+
286
+ if (this.phase === "armed") {
287
+ if (ours && kittyEvent) {
288
+ if (kittyEvent.event === "repeat") return this.startRecording(0);
289
+ if (kittyEvent.event === "release") {
290
+ // Quick tap: type the key after all.
291
+ this.reset();
292
+ if (key.text) this.o.callbacks.typeKey?.();
293
+ return { type: "consume" };
294
+ }
295
+ return { type: "consume" };
296
+ }
297
+ // Something else interrupted a Kitty press: treat as a tap first.
298
+ this.reset();
299
+ if (key.text) this.o.callbacks.typeKey?.();
300
+ return { type: "pass" };
301
+ }
302
+
303
+ if (this.phase === "warmup") {
304
+ if (ours && !kittyEvent) {
305
+ // The first gap may be the OS repeat delay (or a typed space just
306
+ // before the hold): only a run of rapid repeats confirms a hold.
307
+ const now = this.now();
308
+ const gap = now - this.lastKeyAt;
309
+ this.lastKeyAt = now;
310
+ this.count = gap <= this.t.repeatGapMs ? this.count + 1 : 0;
311
+ if (this.count >= this.t.warmupRepeats) return this.startRecording(1);
312
+ this.heldBack++;
313
+ this.o.callbacks.onWarmup?.();
314
+ // Until two rapid repeats in a row, the next gap may still be the OS
315
+ // repeat delay of a press that followed a typed space.
316
+ this.armWarmupTimeout(this.count >= 2 ? this.t.repeatGapMs : this.t.firstRepeatMs);
317
+ return { type: "consume" };
318
+ }
319
+ // Another key: the spaces were typed on purpose.
320
+ this.abortWarmup();
321
+ return { type: "pass" };
322
+ }
323
+
324
+ // idle
325
+ if (!ours) return { type: "pass" };
326
+ if (kittyEvent && kittyEvent.event === "release") return { type: "consume" };
327
+ if (!this.o.canStart()) return { type: "pass" };
328
+ if (!key.text) {
329
+ // Modifier combo: no warm-up, starts on the first event.
330
+ return this.startRecording(0);
331
+ }
332
+ if (kittyEvent && this.o.kitty()) {
333
+ if (kittyEvent.event === "repeat") return this.startRecording(0);
334
+ this.phase = "armed";
335
+ this.setTimer(() => {
336
+ if (this.phase === "armed") this.startRecording(0);
337
+ }, this.t.holdMs);
338
+ return { type: "consume" };
339
+ }
340
+ // Legacy text key: the first space is typed; repeats decide.
341
+ this.phase = "warmup";
342
+ this.count = 0;
343
+ this.lastKeyAt = this.now();
344
+ this.heldBack = 0;
345
+ this.armWarmupTimeout(this.t.firstRepeatMs);
346
+ return { type: "pass" };
347
+ }
348
+
349
+ private startRecording(strip: number): KeyAction {
350
+ this.clearTimer();
351
+ this.phase = "recording";
352
+ this.count = 0;
353
+ this.heldBack = 0;
354
+ this.sawRepeat = false;
355
+ this.o.callbacks.onStart(strip);
356
+ if (!this.o.kitty() || !this.o.key.text) this.armRelease();
357
+ return { type: "consume" };
358
+ }
359
+
360
+ private armRelease(): void {
361
+ if (this.o.kitty()) {
362
+ // Kitty delivers the release; nothing to infer.
363
+ return;
364
+ }
365
+ const ms = !this.o.key.text && !this.sawRepeat ? this.t.comboFirstReleaseMs : this.t.releaseMs;
366
+ this.setTimer(() => {
367
+ if (this.phase !== "recording") return;
368
+ this.reset();
369
+ this.o.callbacks.onStop(false);
370
+ }, ms);
371
+ }
372
+
373
+ private armWarmupTimeout(ms: number): void {
374
+ this.setTimer(() => {
375
+ if (this.phase === "warmup") this.abortWarmup();
376
+ }, ms);
377
+ }
378
+
379
+ private abortWarmup(): void {
380
+ const heldBack = this.heldBack;
381
+ this.reset();
382
+ if (heldBack > 0) this.o.callbacks.onWarmupAbort?.(heldBack);
383
+ }
384
+
385
+ // ---- tap ----------------------------------------------------------------
386
+
387
+ private handleTap(data: string): KeyAction {
388
+ const key = this.o.key;
389
+ const kittyEvent = parseKittyKey(data);
390
+ const ours = kittyEvent ? kittyMatches(key, kittyEvent) : legacyMatches(key, data);
391
+ const now = this.now();
392
+ if (this.phase === "recording") {
393
+ if (ours) {
394
+ if (kittyEvent && kittyEvent.event !== "press") return { type: "consume" };
395
+ const gap = now - this.lastKeyAt;
396
+ this.lastKeyAt = now;
397
+ if (!kittyEvent && gap < this.t.tapDebounceMs) return { type: "consume" }; // auto-repeat of the starting press
398
+ this.reset();
399
+ this.o.callbacks.onStop(false);
400
+ return { type: "consume" };
401
+ }
402
+ if (isCancelKey(data)) {
403
+ this.reset();
404
+ this.o.callbacks.onCancel();
405
+ return { type: "consume" };
406
+ }
407
+ if (isEnterKey(data)) {
408
+ this.reset();
409
+ this.o.callbacks.onStop(true);
410
+ return { type: "consume" };
411
+ }
412
+ return { type: "consume" };
413
+ }
414
+ if (!ours) return { type: "pass" };
415
+ if (kittyEvent && kittyEvent.event !== "press") return { type: "consume" };
416
+ if (!this.o.canStart()) return { type: "pass" };
417
+ this.lastKeyAt = now;
418
+ this.phase = "recording";
419
+ this.o.callbacks.onStart(0);
420
+ return { type: "consume" };
421
+ }
422
+
423
+ private setTimer(fn: () => void, ms: number): void {
424
+ this.clearTimer();
425
+ this.timer = this.timers.set(() => {
426
+ this.timer = null;
427
+ fn();
428
+ }, ms);
429
+ }
430
+
431
+ private clearTimer(): void {
432
+ if (this.timer !== null) this.timers.clear(this.timer);
433
+ this.timer = null;
434
+ }
435
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Kitty keyboard protocol helpers for hold-to-talk on a text key.
3
+ *
4
+ * The agent core negotiates flags 1|2|4 (disambiguate, event types,
5
+ * alternate keys). With those, plain text keys such as Space are still
6
+ * sent as text and their release is never reported. Flag 8 ("report all
7
+ * keys as escape codes") adds it, so while hold-to-talk on Space is armed
8
+ * voice mode sets flags 15 and translates every other text key back to
9
+ * its legacy bytes before anything else sees it: the editor, the core's
10
+ * keybindings and other extensions keep receiving what they always did.
11
+ * Turning voice off (or leaving) puts the core's flags back.
12
+ */
13
+
14
+ import { parseKittyKey } from "./keys";
15
+
16
+ /** The flags the agent core enables (pi-tui DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS). */
17
+ export const CORE_FLAGS = 7;
18
+ /** Core flags plus "report all keys as escape codes". */
19
+ export const ALL_KEYS_FLAGS = CORE_FLAGS | 8;
20
+
21
+ /** CSI = flags ; 1 u — set the flags of the current stack entry (the core pops it on exit). */
22
+ export function setFlagsSequence(flags: number): string {
23
+ return `\x1b[=${flags};1u`;
24
+ }
25
+
26
+ const CSI_U = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+)(?::(\d+))?)?(?:;[\d:]*)?u$/;
27
+
28
+ /**
29
+ * The legacy bytes of a flag-8 key event: text for printable keys with no
30
+ * modifier but Shift, "\r" / "\t" / "\x7f" for Enter / Tab / Backspace, ""
31
+ * (drop) for releases. Returns undefined for anything that is not a CSI-u
32
+ * key event or keeps its escape form (Ctrl/Alt combos, Esc, functional
33
+ * keys) — the core already understands those since it enabled flag 1.
34
+ */
35
+ export function kittyToLegacy(data: string): string | undefined {
36
+ const m = CSI_U.exec(data);
37
+ if (!m) return undefined;
38
+ const ev = parseKittyKey(data);
39
+ if (!ev) return undefined;
40
+ if (ev.event === "release") return "";
41
+ const shift = (ev.modifiers & 1) !== 0;
42
+ if ((ev.modifiers & ~1) !== 0) return undefined;
43
+ const cp = ev.codepoint;
44
+ if (cp === 13) return shift ? undefined : "\r";
45
+ if (cp === 9) return shift ? "\x1b[Z" : "\t";
46
+ if (cp === 127 || cp === 8) return shift ? undefined : "\x7f";
47
+ if (cp === 27) return undefined;
48
+ // Private-use codepoints are functional keys (keypad, media, modifiers).
49
+ if (cp < 32 || (cp >= 57344 && cp <= 63743)) return undefined;
50
+ let out = cp;
51
+ if (shift) {
52
+ const shifted = m[2] ? Number(m[2]) : NaN;
53
+ if (Number.isFinite(shifted) && shifted > 0) out = shifted;
54
+ else {
55
+ const upper = String.fromCodePoint(cp).toUpperCase();
56
+ if ([...upper].length === 1) out = upper.codePointAt(0)!;
57
+ }
58
+ }
59
+ try {
60
+ return String.fromCodePoint(out);
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }