@kolisachint/hoocode-agent 0.4.103 → 0.4.105
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/CHANGELOG.md +29 -0
- package/dist/config.d.ts +0 -6
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -14
- package/dist/config.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +2 -1
- package/dist/migrations.js.map +1 -1
- package/dist/modes/interactive/components/voice-panel.d.ts +56 -0
- package/dist/modes/interactive/components/voice-panel.d.ts.map +1 -0
- package/dist/modes/interactive/components/voice-panel.js +195 -0
- package/dist/modes/interactive/components/voice-panel.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +42 -3
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +260 -16
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/voice-transcribe.d.ts +68 -1
- package/dist/modes/interactive/voice-transcribe.d.ts.map +1 -1
- package/dist/modes/interactive/voice-transcribe.js +163 -2
- package/dist/modes/interactive/voice-transcribe.js.map +1 -1
- package/dist/utils/tools-manager.d.ts +1 -1
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +23 -0
- package/dist/utils/tools-manager.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
* ERROR no model found # fatal error; process exits non-zero
|
|
10
10
|
* ```
|
|
11
11
|
*
|
|
12
|
+
* `voicetools serve` (see `VoiceDaemon` below) reuses this same line protocol
|
|
13
|
+
* plus a few daemon-only events (READY, LEVEL, PHASE).
|
|
14
|
+
*
|
|
12
15
|
* The caller wires `onSegment` to inject text into the editor (via bracketed
|
|
13
16
|
* paste) and `onStatus` / `onError` to surface feedback.
|
|
14
17
|
*/
|
|
15
|
-
export type VoiceStatus = "recording" | "transcribing" | "done" | string;
|
|
18
|
+
export type VoiceStatus = "recording" | "transcribing" | "done" | "listening" | string;
|
|
16
19
|
export interface VoiceTranscribeHandlers {
|
|
17
20
|
/** A decoded chunk of text. Injected into the editor by the caller. */
|
|
18
21
|
onSegment: (text: string) => void;
|
|
@@ -29,5 +32,69 @@ export interface VoiceSession {
|
|
|
29
32
|
stop(): void;
|
|
30
33
|
readonly running: boolean;
|
|
31
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Spawn `voicetools transcribe` for a single capture. This is the fallback
|
|
37
|
+
* path for binaries that don't support `serve` (see `VoiceDaemon`): every
|
|
38
|
+
* push-to-talk press pays the model load cold start.
|
|
39
|
+
*/
|
|
32
40
|
export declare function startVoiceTranscribe(bin: string, handlers: VoiceTranscribeHandlers): VoiceSession;
|
|
41
|
+
/**
|
|
42
|
+
* Handlers for a persistent `voicetools serve` daemon. Extends the base
|
|
43
|
+
* transcribe handlers with the daemon-only events:
|
|
44
|
+
* - `onReady` fires once after models finish loading.
|
|
45
|
+
* - `onLevel` per-audio-chunk RMS, for a live meter/waveform.
|
|
46
|
+
* - `onPhase` phase markers (e.g. `"silence"` when trailing silence begins).
|
|
47
|
+
* - `onPartial` interim transcript while the user speaks — the FULL growing
|
|
48
|
+
* hypothesis each time (supersedes the previous), never committed.
|
|
49
|
+
* - `onFinal` the complete committed transcript for the utterance, emitted
|
|
50
|
+
* once before DONE — this is the text to inject into the editor.
|
|
51
|
+
* - `onCrash` the process died after having been ready (caller should drop
|
|
52
|
+
* the reference and respawn lazily on the next push-to-talk).
|
|
53
|
+
*/
|
|
54
|
+
export interface VoiceDaemonHandlers extends VoiceTranscribeHandlers {
|
|
55
|
+
onReady?: () => void;
|
|
56
|
+
onLevel?: (rms: number) => void;
|
|
57
|
+
onPhase?: (phase: string) => void;
|
|
58
|
+
onPartial?: (text: string) => void;
|
|
59
|
+
onFinal?: (text: string) => void;
|
|
60
|
+
onCrash?: (message: string) => void;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Outcome of {@link VoiceDaemon.spawn}. `reason: "unsupported"` means the
|
|
64
|
+
* process exited before READY with no ERROR line at all — the signature of
|
|
65
|
+
* an old binary rejecting the unrecognized `serve` subcommand — and the
|
|
66
|
+
* caller should silently fall back to `startVoiceTranscribe`. `reason:
|
|
67
|
+
* "error"` means a genuine ERROR line (or OS-level spawn failure) was seen;
|
|
68
|
+
* `handlers.onError` has already been called with it, and the caller should
|
|
69
|
+
* surface that (not retry with the legacy path, which would just hit the
|
|
70
|
+
* same failure) while leaving daemon mode available to retry next press.
|
|
71
|
+
*/
|
|
72
|
+
export type VoiceDaemonSpawnResult = {
|
|
73
|
+
ok: true;
|
|
74
|
+
daemon: VoiceDaemon;
|
|
75
|
+
} | {
|
|
76
|
+
ok: false;
|
|
77
|
+
reason: "unsupported" | "error";
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* A persistent `voicetools serve` process: models are loaded once and stay
|
|
81
|
+
* warm across captures. Only one capture runs at a time; call `startCapture`
|
|
82
|
+
* to open the mic and `cancel` to stop early. `spawn` doubles as the support
|
|
83
|
+
* probe for old binaries (see {@link VoiceDaemonSpawnResult}).
|
|
84
|
+
*/
|
|
85
|
+
export declare class VoiceDaemon {
|
|
86
|
+
private readonly proc;
|
|
87
|
+
private readonly handlers;
|
|
88
|
+
private closed;
|
|
89
|
+
private constructor();
|
|
90
|
+
get isReady(): boolean;
|
|
91
|
+
static spawn(bin: string, handlers: VoiceDaemonHandlers): Promise<VoiceDaemonSpawnResult>;
|
|
92
|
+
private handleLine;
|
|
93
|
+
/** Begin a capture: opens the mic, streams PARTIAL/FINAL (or SEGMENT), ends with DONE. */
|
|
94
|
+
startCapture(): void;
|
|
95
|
+
/** Cancel the in-flight capture, if any. Idempotent. */
|
|
96
|
+
cancel(): void;
|
|
97
|
+
/** Ask the daemon to exit gracefully, force-killing if it doesn't within 1s. */
|
|
98
|
+
shutdown(): void;
|
|
99
|
+
}
|
|
33
100
|
//# sourceMappingURL=voice-transcribe.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"voice-transcribe.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/voice-transcribe.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;GAaG;AAEH,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,cAAc,GAAG,MAAM,GAAG,MAAM,CAAC;AAEzE,MAAM,WAAW,uBAAuB;IACvC,uEAAuE;IACvE,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,4EAA4E;IAC5E,QAAQ,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAC;IACxC,2EAA2E;IAC3E,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC5B,IAAI,IAAI,IAAI,CAAC;IACb,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC1B;AAED,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,uBAAuB,GAAG,YAAY,CAoEjG","sourcesContent":["import { createInterface, type Interface } from \"node:readline\";\nimport { type ChildProcess, spawn } from \"child_process\";\n\n/**\n * Drives the external `voicetools` binary and streams its stdout line protocol\n * into callbacks. One line per event on stdout (stderr is free for debug logs):\n *\n * ```text\n * STATUS recording # state transition (recording | transcribing | ...)\n * SEGMENT hello world # a chunk of decoded text\n * DONE # finished successfully\n * ERROR no model found # fatal error; process exits non-zero\n * ```\n *\n * The caller wires `onSegment` to inject text into the editor (via bracketed\n * paste) and `onStatus` / `onError` to surface feedback.\n */\n\nexport type VoiceStatus = \"recording\" | \"transcribing\" | \"done\" | string;\n\nexport interface VoiceTranscribeHandlers {\n\t/** A decoded chunk of text. Injected into the editor by the caller. */\n\tonSegment: (text: string) => void;\n\t/** A state transition reported by the binary, or `\"done\"` on completion. */\n\tonStatus: (status: VoiceStatus) => void;\n\t/** A fatal error: spawn failure, protocol ERROR line, or non-zero exit. */\n\tonError: (message: string) => void;\n}\n\n/**\n * A running voice-transcribe session. Call `stop()` to cancel early (e.g. the\n * user pressing the shortcut again). `stop()` is idempotent.\n */\nexport interface VoiceSession {\n\tstop(): void;\n\treadonly running: boolean;\n}\n\nexport function startVoiceTranscribe(bin: string, handlers: VoiceTranscribeHandlers): VoiceSession {\n\tlet proc: ChildProcess;\n\ttry {\n\t\tproc = spawn(bin, [\"transcribe\"], {\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t} catch (err) {\n\t\thandlers.onError(err instanceof Error ? err.message : String(err));\n\t\treturn { stop: () => {}, running: false };\n\t}\n\n\tlet stopped = false;\n\tlet finished = false;\n\tlet rl: Interface | undefined;\n\n\tconst finish = (): void => {\n\t\tif (finished) return;\n\t\tfinished = true;\n\t\trl?.close();\n\t};\n\n\tif (!proc.stdout) {\n\t\tproc.kill();\n\t\thandlers.onError(\"failed to capture voicetools stdout\");\n\t\treturn { stop: () => {}, running: false };\n\t}\n\n\trl = createInterface({ input: proc.stdout });\n\trl.on(\"line\", (line) => {\n\t\tif (line.startsWith(\"STATUS \")) {\n\t\t\thandlers.onStatus(line.slice(7).trim());\n\t\t} else if (line.startsWith(\"SEGMENT \")) {\n\t\t\thandlers.onSegment(line.slice(8));\n\t\t} else if (line === \"DONE\") {\n\t\t\thandlers.onStatus(\"done\");\n\t\t\tfinish();\n\t\t} else if (line.startsWith(\"ERROR \")) {\n\t\t\thandlers.onError(line.slice(6).trim());\n\t\t\tfinish();\n\t\t}\n\t});\n\n\tproc.on(\"error\", (err) => {\n\t\tif (stopped || finished) return;\n\t\tfinish();\n\t\thandlers.onError(err.message);\n\t});\n\n\tproc.on(\"close\", (code) => {\n\t\tconst wasFinished = finished;\n\t\tfinish();\n\t\tif (stopped || wasFinished) return;\n\t\tif (code && code !== 0) {\n\t\t\thandlers.onError(`voicetools exited with code ${code}`);\n\t\t}\n\t});\n\n\treturn {\n\t\tstop: () => {\n\t\t\tif (stopped) return;\n\t\t\tstopped = true;\n\t\t\tfinish();\n\t\t\tproc.kill();\n\t\t},\n\t\tget running() {\n\t\t\treturn !finished && !stopped;\n\t\t},\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"voice-transcribe.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/voice-transcribe.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,cAAc,GAAG,MAAM,GAAG,WAAW,GAAG,MAAM,CAAC;AAEvF,MAAM,WAAW,uBAAuB;IACvC,uEAAuE;IACvE,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,4EAA4E;IAC5E,QAAQ,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAC;IACxC,2EAA2E;IAC3E,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC5B,IAAI,IAAI,IAAI,CAAC;IACb,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC1B;AAUD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,uBAAuB,GAAG,YAAY,CAoEjG;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,mBAAoB,SAAQ,uBAAuB;IACnE,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,sBAAsB,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAA;CAAE,CAAC;AAExH;;;;;GAKG;AACH,qBAAa,WAAW;IAItB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAJ1B,OAAO,CAAC,MAAM,CAAS;IAEvB,OAAO,eAGH;IAEJ,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,mBAAmB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CA0ExF;IAED,OAAO,CAAC,UAAU;IAqBlB,0FAA0F;IAC1F,YAAY,IAAI,IAAI,CAGnB;IAED,wDAAwD;IACxD,MAAM,IAAI,IAAI,CAGb;IAED,gFAAgF;IAChF,QAAQ,IAAI,IAAI,CAaf;CACD","sourcesContent":["import { createInterface, type Interface } from \"node:readline\";\nimport { type ChildProcess, spawn } from \"child_process\";\n\n/**\n * Drives the external `voicetools` binary and streams its stdout line protocol\n * into callbacks. One line per event on stdout (stderr is free for debug logs):\n *\n * ```text\n * STATUS recording # state transition (recording | transcribing | ...)\n * SEGMENT hello world # a chunk of decoded text\n * DONE # finished successfully\n * ERROR no model found # fatal error; process exits non-zero\n * ```\n *\n * `voicetools serve` (see `VoiceDaemon` below) reuses this same line protocol\n * plus a few daemon-only events (READY, LEVEL, PHASE).\n *\n * The caller wires `onSegment` to inject text into the editor (via bracketed\n * paste) and `onStatus` / `onError` to surface feedback.\n */\n\nexport type VoiceStatus = \"recording\" | \"transcribing\" | \"done\" | \"listening\" | string;\n\nexport interface VoiceTranscribeHandlers {\n\t/** A decoded chunk of text. Injected into the editor by the caller. */\n\tonSegment: (text: string) => void;\n\t/** A state transition reported by the binary, or `\"done\"` on completion. */\n\tonStatus: (status: VoiceStatus) => void;\n\t/** A fatal error: spawn failure, protocol ERROR line, or non-zero exit. */\n\tonError: (message: string) => void;\n}\n\n/**\n * A running voice-transcribe session. Call `stop()` to cancel early (e.g. the\n * user pressing the shortcut again). `stop()` is idempotent.\n */\nexport interface VoiceSession {\n\tstop(): void;\n\treadonly running: boolean;\n}\n\n/** Build a friendly message for a spawn failure, calling out a missing binary. */\nfunction describeSpawnError(err: unknown, bin: string): string {\n\tif (err && typeof err === \"object\" && (err as NodeJS.ErrnoException).code === \"ENOENT\") {\n\t\treturn `voicetools binary not found (tried \"${bin}\"). Install it or set VOICETOOLS_BIN to its path.`;\n\t}\n\treturn err instanceof Error ? err.message : String(err);\n}\n\n/**\n * Spawn `voicetools transcribe` for a single capture. This is the fallback\n * path for binaries that don't support `serve` (see `VoiceDaemon`): every\n * push-to-talk press pays the model load cold start.\n */\nexport function startVoiceTranscribe(bin: string, handlers: VoiceTranscribeHandlers): VoiceSession {\n\tlet proc: ChildProcess;\n\ttry {\n\t\tproc = spawn(bin, [\"transcribe\"], {\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t} catch (err) {\n\t\thandlers.onError(describeSpawnError(err, bin));\n\t\treturn { stop: () => {}, running: false };\n\t}\n\n\tlet stopped = false;\n\tlet finished = false;\n\tlet rl: Interface | undefined;\n\n\tconst finish = (): void => {\n\t\tif (finished) return;\n\t\tfinished = true;\n\t\trl?.close();\n\t};\n\n\tif (!proc.stdout) {\n\t\tproc.kill();\n\t\thandlers.onError(\"failed to capture voicetools stdout\");\n\t\treturn { stop: () => {}, running: false };\n\t}\n\n\trl = createInterface({ input: proc.stdout });\n\trl.on(\"line\", (line) => {\n\t\tif (line.startsWith(\"STATUS \")) {\n\t\t\thandlers.onStatus(line.slice(7).trim());\n\t\t} else if (line.startsWith(\"SEGMENT \")) {\n\t\t\thandlers.onSegment(line.slice(8));\n\t\t} else if (line === \"DONE\") {\n\t\t\thandlers.onStatus(\"done\");\n\t\t\tfinish();\n\t\t} else if (line.startsWith(\"ERROR \")) {\n\t\t\thandlers.onError(line.slice(6).trim());\n\t\t\tfinish();\n\t\t}\n\t});\n\n\tproc.on(\"error\", (err) => {\n\t\tif (stopped || finished) return;\n\t\tfinish();\n\t\thandlers.onError(describeSpawnError(err, bin));\n\t});\n\n\tproc.on(\"close\", (code) => {\n\t\tconst wasFinished = finished;\n\t\tfinish();\n\t\tif (stopped || wasFinished) return;\n\t\tif (code && code !== 0) {\n\t\t\thandlers.onError(`voicetools exited with code ${code}`);\n\t\t}\n\t});\n\n\treturn {\n\t\tstop: () => {\n\t\t\tif (stopped) return;\n\t\t\tstopped = true;\n\t\t\tfinish();\n\t\t\tproc.kill();\n\t\t},\n\t\tget running() {\n\t\t\treturn !finished && !stopped;\n\t\t},\n\t};\n}\n\n/**\n * Handlers for a persistent `voicetools serve` daemon. Extends the base\n * transcribe handlers with the daemon-only events:\n * - `onReady` fires once after models finish loading.\n * - `onLevel` per-audio-chunk RMS, for a live meter/waveform.\n * - `onPhase` phase markers (e.g. `\"silence\"` when trailing silence begins).\n * - `onPartial` interim transcript while the user speaks — the FULL growing\n * hypothesis each time (supersedes the previous), never committed.\n * - `onFinal` the complete committed transcript for the utterance, emitted\n * once before DONE — this is the text to inject into the editor.\n * - `onCrash` the process died after having been ready (caller should drop\n * the reference and respawn lazily on the next push-to-talk).\n */\nexport interface VoiceDaemonHandlers extends VoiceTranscribeHandlers {\n\tonReady?: () => void;\n\tonLevel?: (rms: number) => void;\n\tonPhase?: (phase: string) => void;\n\tonPartial?: (text: string) => void;\n\tonFinal?: (text: string) => void;\n\tonCrash?: (message: string) => void;\n}\n\n/**\n * Outcome of {@link VoiceDaemon.spawn}. `reason: \"unsupported\"` means the\n * process exited before READY with no ERROR line at all — the signature of\n * an old binary rejecting the unrecognized `serve` subcommand — and the\n * caller should silently fall back to `startVoiceTranscribe`. `reason:\n * \"error\"` means a genuine ERROR line (or OS-level spawn failure) was seen;\n * `handlers.onError` has already been called with it, and the caller should\n * surface that (not retry with the legacy path, which would just hit the\n * same failure) while leaving daemon mode available to retry next press.\n */\nexport type VoiceDaemonSpawnResult = { ok: true; daemon: VoiceDaemon } | { ok: false; reason: \"unsupported\" | \"error\" };\n\n/**\n * A persistent `voicetools serve` process: models are loaded once and stay\n * warm across captures. Only one capture runs at a time; call `startCapture`\n * to open the mic and `cancel` to stop early. `spawn` doubles as the support\n * probe for old binaries (see {@link VoiceDaemonSpawnResult}).\n */\nexport class VoiceDaemon {\n\tprivate closed = false;\n\n\tprivate constructor(\n\t\tprivate readonly proc: ChildProcess,\n\t\tprivate readonly handlers: VoiceDaemonHandlers,\n\t) {}\n\n\tget isReady(): boolean {\n\t\treturn !this.closed;\n\t}\n\n\tstatic spawn(bin: string, handlers: VoiceDaemonHandlers): Promise<VoiceDaemonSpawnResult> {\n\t\treturn new Promise((resolve) => {\n\t\t\tlet proc: ChildProcess;\n\t\t\ttry {\n\t\t\t\tproc = spawn(bin, [\"serve\"], { stdio: [\"pipe\", \"pipe\", \"ignore\"] });\n\t\t\t} catch (err) {\n\t\t\t\thandlers.onError(describeSpawnError(err, bin));\n\t\t\t\tresolve({ ok: false, reason: \"error\" });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!proc.stdout || !proc.stdin) {\n\t\t\t\tproc.kill();\n\t\t\t\thandlers.onError(\"failed to open voicetools serve stdio\");\n\t\t\t\tresolve({ ok: false, reason: \"error\" });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlet settled = false;\n\t\t\tlet daemon: VoiceDaemon | undefined;\n\t\t\tlet sawPreReadyError = false;\n\t\t\tconst rl = createInterface({ input: proc.stdout });\n\n\t\t\tconst fail = (reason: \"unsupported\" | \"error\"): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\trl.close();\n\t\t\t\tresolve({ ok: false, reason });\n\t\t\t};\n\n\t\t\trl.on(\"line\", (line) => {\n\t\t\t\tif (daemon) {\n\t\t\t\t\tdaemon.handleLine(line);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (settled) return;\n\t\t\t\tif (line === \"READY\") {\n\t\t\t\t\tsettled = true;\n\t\t\t\t\tdaemon = new VoiceDaemon(proc, handlers);\n\t\t\t\t\thandlers.onReady?.();\n\t\t\t\t\tresolve({ ok: true, daemon });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (line.startsWith(\"ERROR \")) {\n\t\t\t\t\t// Loading can fail before READY (e.g. no model installed yet).\n\t\t\t\t\t// Surface it now; the process still exits right after.\n\t\t\t\t\tsawPreReadyError = true;\n\t\t\t\t\thandlers.onError(line.slice(6).trim());\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tproc.on(\"error\", (err) => {\n\t\t\t\tif (daemon) {\n\t\t\t\t\tif (!daemon.closed) {\n\t\t\t\t\t\tdaemon.closed = true;\n\t\t\t\t\t\thandlers.onCrash?.(describeSpawnError(err, bin));\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (!sawPreReadyError) handlers.onError(describeSpawnError(err, bin));\n\t\t\t\tfail(\"error\");\n\t\t\t});\n\n\t\t\tproc.on(\"close\", (code) => {\n\t\t\t\tif (daemon) {\n\t\t\t\t\tif (!daemon.closed) {\n\t\t\t\t\t\tdaemon.closed = true;\n\t\t\t\t\t\thandlers.onCrash?.(`voicetools serve exited with code ${code ?? \"unknown\"}`);\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tfail(sawPreReadyError ? \"error\" : \"unsupported\");\n\t\t\t});\n\t\t});\n\t}\n\n\tprivate handleLine(line: string): void {\n\t\tif (line.startsWith(\"STATUS \")) {\n\t\t\tthis.handlers.onStatus(line.slice(7).trim());\n\t\t} else if (line.startsWith(\"PARTIAL \")) {\n\t\t\tthis.handlers.onPartial?.(line.slice(8));\n\t\t} else if (line.startsWith(\"FINAL \")) {\n\t\t\tthis.handlers.onFinal?.(line.slice(6));\n\t\t} else if (line.startsWith(\"SEGMENT \")) {\n\t\t\tthis.handlers.onSegment(line.slice(8));\n\t\t} else if (line.startsWith(\"LEVEL \")) {\n\t\t\tconst rms = Number.parseFloat(line.slice(6).trim());\n\t\t\tif (!Number.isNaN(rms)) this.handlers.onLevel?.(rms);\n\t\t} else if (line.startsWith(\"PHASE \")) {\n\t\t\tthis.handlers.onPhase?.(line.slice(6).trim());\n\t\t} else if (line === \"DONE\") {\n\t\t\tthis.handlers.onStatus(\"done\");\n\t\t} else if (line.startsWith(\"ERROR \")) {\n\t\t\tthis.handlers.onError(line.slice(6).trim());\n\t\t}\n\t}\n\n\t/** Begin a capture: opens the mic, streams PARTIAL/FINAL (or SEGMENT), ends with DONE. */\n\tstartCapture(): void {\n\t\tif (this.closed) return;\n\t\tthis.proc.stdin?.write(\"START\\n\");\n\t}\n\n\t/** Cancel the in-flight capture, if any. Idempotent. */\n\tcancel(): void {\n\t\tif (this.closed) return;\n\t\tthis.proc.stdin?.write(\"CANCEL\\n\");\n\t}\n\n\t/** Ask the daemon to exit gracefully, force-killing if it doesn't within 1s. */\n\tshutdown(): void {\n\t\tif (this.closed) return;\n\t\tthis.closed = true;\n\t\ttry {\n\t\t\tthis.proc.stdin?.write(\"SHUTDOWN\\n\");\n\t\t} catch {\n\t\t\t// stdin may already be gone (process died); force-kill below covers it.\n\t\t}\n\t\tconst proc = this.proc;\n\t\tconst killTimer = setTimeout(() => {\n\t\t\tif (!proc.killed) proc.kill();\n\t\t}, 1000);\n\t\tproc.once(\"close\", () => clearTimeout(killTimer));\n\t}\n}\n"]}
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
|
+
/** Build a friendly message for a spawn failure, calling out a missing binary. */
|
|
4
|
+
function describeSpawnError(err, bin) {
|
|
5
|
+
if (err && typeof err === "object" && err.code === "ENOENT") {
|
|
6
|
+
return `voicetools binary not found (tried "${bin}"). Install it or set VOICETOOLS_BIN to its path.`;
|
|
7
|
+
}
|
|
8
|
+
return err instanceof Error ? err.message : String(err);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Spawn `voicetools transcribe` for a single capture. This is the fallback
|
|
12
|
+
* path for binaries that don't support `serve` (see `VoiceDaemon`): every
|
|
13
|
+
* push-to-talk press pays the model load cold start.
|
|
14
|
+
*/
|
|
3
15
|
export function startVoiceTranscribe(bin, handlers) {
|
|
4
16
|
let proc;
|
|
5
17
|
try {
|
|
@@ -8,7 +20,7 @@ export function startVoiceTranscribe(bin, handlers) {
|
|
|
8
20
|
});
|
|
9
21
|
}
|
|
10
22
|
catch (err) {
|
|
11
|
-
handlers.onError(err
|
|
23
|
+
handlers.onError(describeSpawnError(err, bin));
|
|
12
24
|
return { stop: () => { }, running: false };
|
|
13
25
|
}
|
|
14
26
|
let stopped = false;
|
|
@@ -46,7 +58,7 @@ export function startVoiceTranscribe(bin, handlers) {
|
|
|
46
58
|
if (stopped || finished)
|
|
47
59
|
return;
|
|
48
60
|
finish();
|
|
49
|
-
handlers.onError(err
|
|
61
|
+
handlers.onError(describeSpawnError(err, bin));
|
|
50
62
|
});
|
|
51
63
|
proc.on("close", (code) => {
|
|
52
64
|
const wasFinished = finished;
|
|
@@ -70,4 +82,153 @@ export function startVoiceTranscribe(bin, handlers) {
|
|
|
70
82
|
},
|
|
71
83
|
};
|
|
72
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* A persistent `voicetools serve` process: models are loaded once and stay
|
|
87
|
+
* warm across captures. Only one capture runs at a time; call `startCapture`
|
|
88
|
+
* to open the mic and `cancel` to stop early. `spawn` doubles as the support
|
|
89
|
+
* probe for old binaries (see {@link VoiceDaemonSpawnResult}).
|
|
90
|
+
*/
|
|
91
|
+
export class VoiceDaemon {
|
|
92
|
+
proc;
|
|
93
|
+
handlers;
|
|
94
|
+
closed = false;
|
|
95
|
+
constructor(proc, handlers) {
|
|
96
|
+
this.proc = proc;
|
|
97
|
+
this.handlers = handlers;
|
|
98
|
+
}
|
|
99
|
+
get isReady() {
|
|
100
|
+
return !this.closed;
|
|
101
|
+
}
|
|
102
|
+
static spawn(bin, handlers) {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
let proc;
|
|
105
|
+
try {
|
|
106
|
+
proc = spawn(bin, ["serve"], { stdio: ["pipe", "pipe", "ignore"] });
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
handlers.onError(describeSpawnError(err, bin));
|
|
110
|
+
resolve({ ok: false, reason: "error" });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!proc.stdout || !proc.stdin) {
|
|
114
|
+
proc.kill();
|
|
115
|
+
handlers.onError("failed to open voicetools serve stdio");
|
|
116
|
+
resolve({ ok: false, reason: "error" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let settled = false;
|
|
120
|
+
let daemon;
|
|
121
|
+
let sawPreReadyError = false;
|
|
122
|
+
const rl = createInterface({ input: proc.stdout });
|
|
123
|
+
const fail = (reason) => {
|
|
124
|
+
if (settled)
|
|
125
|
+
return;
|
|
126
|
+
settled = true;
|
|
127
|
+
rl.close();
|
|
128
|
+
resolve({ ok: false, reason });
|
|
129
|
+
};
|
|
130
|
+
rl.on("line", (line) => {
|
|
131
|
+
if (daemon) {
|
|
132
|
+
daemon.handleLine(line);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (settled)
|
|
136
|
+
return;
|
|
137
|
+
if (line === "READY") {
|
|
138
|
+
settled = true;
|
|
139
|
+
daemon = new VoiceDaemon(proc, handlers);
|
|
140
|
+
handlers.onReady?.();
|
|
141
|
+
resolve({ ok: true, daemon });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (line.startsWith("ERROR ")) {
|
|
145
|
+
// Loading can fail before READY (e.g. no model installed yet).
|
|
146
|
+
// Surface it now; the process still exits right after.
|
|
147
|
+
sawPreReadyError = true;
|
|
148
|
+
handlers.onError(line.slice(6).trim());
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
proc.on("error", (err) => {
|
|
152
|
+
if (daemon) {
|
|
153
|
+
if (!daemon.closed) {
|
|
154
|
+
daemon.closed = true;
|
|
155
|
+
handlers.onCrash?.(describeSpawnError(err, bin));
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!sawPreReadyError)
|
|
160
|
+
handlers.onError(describeSpawnError(err, bin));
|
|
161
|
+
fail("error");
|
|
162
|
+
});
|
|
163
|
+
proc.on("close", (code) => {
|
|
164
|
+
if (daemon) {
|
|
165
|
+
if (!daemon.closed) {
|
|
166
|
+
daemon.closed = true;
|
|
167
|
+
handlers.onCrash?.(`voicetools serve exited with code ${code ?? "unknown"}`);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
fail(sawPreReadyError ? "error" : "unsupported");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
handleLine(line) {
|
|
176
|
+
if (line.startsWith("STATUS ")) {
|
|
177
|
+
this.handlers.onStatus(line.slice(7).trim());
|
|
178
|
+
}
|
|
179
|
+
else if (line.startsWith("PARTIAL ")) {
|
|
180
|
+
this.handlers.onPartial?.(line.slice(8));
|
|
181
|
+
}
|
|
182
|
+
else if (line.startsWith("FINAL ")) {
|
|
183
|
+
this.handlers.onFinal?.(line.slice(6));
|
|
184
|
+
}
|
|
185
|
+
else if (line.startsWith("SEGMENT ")) {
|
|
186
|
+
this.handlers.onSegment(line.slice(8));
|
|
187
|
+
}
|
|
188
|
+
else if (line.startsWith("LEVEL ")) {
|
|
189
|
+
const rms = Number.parseFloat(line.slice(6).trim());
|
|
190
|
+
if (!Number.isNaN(rms))
|
|
191
|
+
this.handlers.onLevel?.(rms);
|
|
192
|
+
}
|
|
193
|
+
else if (line.startsWith("PHASE ")) {
|
|
194
|
+
this.handlers.onPhase?.(line.slice(6).trim());
|
|
195
|
+
}
|
|
196
|
+
else if (line === "DONE") {
|
|
197
|
+
this.handlers.onStatus("done");
|
|
198
|
+
}
|
|
199
|
+
else if (line.startsWith("ERROR ")) {
|
|
200
|
+
this.handlers.onError(line.slice(6).trim());
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Begin a capture: opens the mic, streams PARTIAL/FINAL (or SEGMENT), ends with DONE. */
|
|
204
|
+
startCapture() {
|
|
205
|
+
if (this.closed)
|
|
206
|
+
return;
|
|
207
|
+
this.proc.stdin?.write("START\n");
|
|
208
|
+
}
|
|
209
|
+
/** Cancel the in-flight capture, if any. Idempotent. */
|
|
210
|
+
cancel() {
|
|
211
|
+
if (this.closed)
|
|
212
|
+
return;
|
|
213
|
+
this.proc.stdin?.write("CANCEL\n");
|
|
214
|
+
}
|
|
215
|
+
/** Ask the daemon to exit gracefully, force-killing if it doesn't within 1s. */
|
|
216
|
+
shutdown() {
|
|
217
|
+
if (this.closed)
|
|
218
|
+
return;
|
|
219
|
+
this.closed = true;
|
|
220
|
+
try {
|
|
221
|
+
this.proc.stdin?.write("SHUTDOWN\n");
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// stdin may already be gone (process died); force-kill below covers it.
|
|
225
|
+
}
|
|
226
|
+
const proc = this.proc;
|
|
227
|
+
const killTimer = setTimeout(() => {
|
|
228
|
+
if (!proc.killed)
|
|
229
|
+
proc.kill();
|
|
230
|
+
}, 1000);
|
|
231
|
+
proc.once("close", () => clearTimeout(killTimer));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
73
234
|
//# sourceMappingURL=voice-transcribe.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"voice-transcribe.js","sourceRoot":"","sources":["../../../src/modes/interactive/voice-transcribe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAkB,MAAM,eAAe,CAAC;AAChE,OAAO,EAAqB,KAAK,EAAE,MAAM,eAAe,CAAC;AAqCzD,MAAM,UAAU,oBAAoB,CAAC,GAAW,EAAE,QAAiC,EAAgB;IAClG,IAAI,IAAkB,CAAC;IACvB,IAAI,CAAC;QACJ,IAAI,GAAG,KAAK,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,EAAE;YACjC,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC;SACnC,CAAC,CAAC;IACJ,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACd,QAAQ,CAAC,OAAO,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;QACnE,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,EAAC,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IAC3C,CAAC;IAED,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,EAAyB,CAAC;IAE9B,MAAM,MAAM,GAAG,GAAS,EAAE,CAAC;QAC1B,IAAI,QAAQ;YAAE,OAAO;QACrB,QAAQ,GAAG,IAAI,CAAC;QAChB,EAAE,EAAE,KAAK,EAAE,CAAC;IAAA,CACZ,CAAC;IAEF,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;QAClB,IAAI,CAAC,IAAI,EAAE,CAAC;QACZ,QAAQ,CAAC,OAAO,CAAC,qCAAqC,CAAC,CAAC;QACxD,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,EAAC,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IAC3C,CAAC;IAED,EAAE,GAAG,eAAe,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;IAC7C,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC;QACvB,IAAI,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;YAChC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;QACzC,CAAC;aAAM,IAAI,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;YACxC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QACnC,CAAC;aAAM,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;YAC5B,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;YAC1B,MAAM,EAAE,CAAC;QACV,CAAC;aAAM,IAAI,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YACtC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;YACvC,MAAM,EAAE,CAAC;QACV,CAAC;IAAA,CACD,CAAC,CAAC;IAEH,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC;QACzB,IAAI,OAAO,IAAI,QAAQ;YAAE,OAAO;QAChC,MAAM,EAAE,CAAC;QACT,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAAA,CAC9B,CAAC,CAAC;IAEH,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC;QAC1B,MAAM,WAAW,GAAG,QAAQ,CAAC;QAC7B,MAAM,EAAE,CAAC;QACT,IAAI,OAAO,IAAI,WAAW;YAAE,OAAO;QACnC,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC;YACxB,QAAQ,CAAC,OAAO,CAAC,+BAA+B,IAAI,EAAE,CAAC,CAAC;QACzD,CAAC;IAAA,CACD,CAAC,CAAC;IAEH,OAAO;QACN,IAAI,EAAE,GAAG,EAAE,CAAC;YACX,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,MAAM,EAAE,CAAC;YACT,IAAI,CAAC,IAAI,EAAE,CAAC;QAAA,CACZ;QACD,IAAI,OAAO,GAAG;YACb,OAAO,CAAC,QAAQ,IAAI,CAAC,OAAO,CAAC;QAAA,CAC7B;KACD,CAAC;AAAA,CACF","sourcesContent":["import { createInterface, type Interface } from \"node:readline\";\nimport { type ChildProcess, spawn } from \"child_process\";\n\n/**\n * Drives the external `voicetools` binary and streams its stdout line protocol\n * into callbacks. One line per event on stdout (stderr is free for debug logs):\n *\n * ```text\n * STATUS recording # state transition (recording | transcribing | ...)\n * SEGMENT hello world # a chunk of decoded text\n * DONE # finished successfully\n * ERROR no model found # fatal error; process exits non-zero\n * ```\n *\n * The caller wires `onSegment` to inject text into the editor (via bracketed\n * paste) and `onStatus` / `onError` to surface feedback.\n */\n\nexport type VoiceStatus = \"recording\" | \"transcribing\" | \"done\" | string;\n\nexport interface VoiceTranscribeHandlers {\n\t/** A decoded chunk of text. Injected into the editor by the caller. */\n\tonSegment: (text: string) => void;\n\t/** A state transition reported by the binary, or `\"done\"` on completion. */\n\tonStatus: (status: VoiceStatus) => void;\n\t/** A fatal error: spawn failure, protocol ERROR line, or non-zero exit. */\n\tonError: (message: string) => void;\n}\n\n/**\n * A running voice-transcribe session. Call `stop()` to cancel early (e.g. the\n * user pressing the shortcut again). `stop()` is idempotent.\n */\nexport interface VoiceSession {\n\tstop(): void;\n\treadonly running: boolean;\n}\n\nexport function startVoiceTranscribe(bin: string, handlers: VoiceTranscribeHandlers): VoiceSession {\n\tlet proc: ChildProcess;\n\ttry {\n\t\tproc = spawn(bin, [\"transcribe\"], {\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t} catch (err) {\n\t\thandlers.onError(err instanceof Error ? err.message : String(err));\n\t\treturn { stop: () => {}, running: false };\n\t}\n\n\tlet stopped = false;\n\tlet finished = false;\n\tlet rl: Interface | undefined;\n\n\tconst finish = (): void => {\n\t\tif (finished) return;\n\t\tfinished = true;\n\t\trl?.close();\n\t};\n\n\tif (!proc.stdout) {\n\t\tproc.kill();\n\t\thandlers.onError(\"failed to capture voicetools stdout\");\n\t\treturn { stop: () => {}, running: false };\n\t}\n\n\trl = createInterface({ input: proc.stdout });\n\trl.on(\"line\", (line) => {\n\t\tif (line.startsWith(\"STATUS \")) {\n\t\t\thandlers.onStatus(line.slice(7).trim());\n\t\t} else if (line.startsWith(\"SEGMENT \")) {\n\t\t\thandlers.onSegment(line.slice(8));\n\t\t} else if (line === \"DONE\") {\n\t\t\thandlers.onStatus(\"done\");\n\t\t\tfinish();\n\t\t} else if (line.startsWith(\"ERROR \")) {\n\t\t\thandlers.onError(line.slice(6).trim());\n\t\t\tfinish();\n\t\t}\n\t});\n\n\tproc.on(\"error\", (err) => {\n\t\tif (stopped || finished) return;\n\t\tfinish();\n\t\thandlers.onError(err.message);\n\t});\n\n\tproc.on(\"close\", (code) => {\n\t\tconst wasFinished = finished;\n\t\tfinish();\n\t\tif (stopped || wasFinished) return;\n\t\tif (code && code !== 0) {\n\t\t\thandlers.onError(`voicetools exited with code ${code}`);\n\t\t}\n\t});\n\n\treturn {\n\t\tstop: () => {\n\t\t\tif (stopped) return;\n\t\t\tstopped = true;\n\t\t\tfinish();\n\t\t\tproc.kill();\n\t\t},\n\t\tget running() {\n\t\t\treturn !finished && !stopped;\n\t\t},\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"voice-transcribe.js","sourceRoot":"","sources":["../../../src/modes/interactive/voice-transcribe.ts"],"names":[],"mappings":"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","sourcesContent":["import { createInterface, type Interface } from \"node:readline\";\nimport { type ChildProcess, spawn } from \"child_process\";\n\n/**\n * Drives the external `voicetools` binary and streams its stdout line protocol\n * into callbacks. One line per event on stdout (stderr is free for debug logs):\n *\n * ```text\n * STATUS recording # state transition (recording | transcribing | ...)\n * SEGMENT hello world # a chunk of decoded text\n * DONE # finished successfully\n * ERROR no model found # fatal error; process exits non-zero\n * ```\n *\n * `voicetools serve` (see `VoiceDaemon` below) reuses this same line protocol\n * plus a few daemon-only events (READY, LEVEL, PHASE).\n *\n * The caller wires `onSegment` to inject text into the editor (via bracketed\n * paste) and `onStatus` / `onError` to surface feedback.\n */\n\nexport type VoiceStatus = \"recording\" | \"transcribing\" | \"done\" | \"listening\" | string;\n\nexport interface VoiceTranscribeHandlers {\n\t/** A decoded chunk of text. Injected into the editor by the caller. */\n\tonSegment: (text: string) => void;\n\t/** A state transition reported by the binary, or `\"done\"` on completion. */\n\tonStatus: (status: VoiceStatus) => void;\n\t/** A fatal error: spawn failure, protocol ERROR line, or non-zero exit. */\n\tonError: (message: string) => void;\n}\n\n/**\n * A running voice-transcribe session. Call `stop()` to cancel early (e.g. the\n * user pressing the shortcut again). `stop()` is idempotent.\n */\nexport interface VoiceSession {\n\tstop(): void;\n\treadonly running: boolean;\n}\n\n/** Build a friendly message for a spawn failure, calling out a missing binary. */\nfunction describeSpawnError(err: unknown, bin: string): string {\n\tif (err && typeof err === \"object\" && (err as NodeJS.ErrnoException).code === \"ENOENT\") {\n\t\treturn `voicetools binary not found (tried \"${bin}\"). Install it or set VOICETOOLS_BIN to its path.`;\n\t}\n\treturn err instanceof Error ? err.message : String(err);\n}\n\n/**\n * Spawn `voicetools transcribe` for a single capture. This is the fallback\n * path for binaries that don't support `serve` (see `VoiceDaemon`): every\n * push-to-talk press pays the model load cold start.\n */\nexport function startVoiceTranscribe(bin: string, handlers: VoiceTranscribeHandlers): VoiceSession {\n\tlet proc: ChildProcess;\n\ttry {\n\t\tproc = spawn(bin, [\"transcribe\"], {\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t} catch (err) {\n\t\thandlers.onError(describeSpawnError(err, bin));\n\t\treturn { stop: () => {}, running: false };\n\t}\n\n\tlet stopped = false;\n\tlet finished = false;\n\tlet rl: Interface | undefined;\n\n\tconst finish = (): void => {\n\t\tif (finished) return;\n\t\tfinished = true;\n\t\trl?.close();\n\t};\n\n\tif (!proc.stdout) {\n\t\tproc.kill();\n\t\thandlers.onError(\"failed to capture voicetools stdout\");\n\t\treturn { stop: () => {}, running: false };\n\t}\n\n\trl = createInterface({ input: proc.stdout });\n\trl.on(\"line\", (line) => {\n\t\tif (line.startsWith(\"STATUS \")) {\n\t\t\thandlers.onStatus(line.slice(7).trim());\n\t\t} else if (line.startsWith(\"SEGMENT \")) {\n\t\t\thandlers.onSegment(line.slice(8));\n\t\t} else if (line === \"DONE\") {\n\t\t\thandlers.onStatus(\"done\");\n\t\t\tfinish();\n\t\t} else if (line.startsWith(\"ERROR \")) {\n\t\t\thandlers.onError(line.slice(6).trim());\n\t\t\tfinish();\n\t\t}\n\t});\n\n\tproc.on(\"error\", (err) => {\n\t\tif (stopped || finished) return;\n\t\tfinish();\n\t\thandlers.onError(describeSpawnError(err, bin));\n\t});\n\n\tproc.on(\"close\", (code) => {\n\t\tconst wasFinished = finished;\n\t\tfinish();\n\t\tif (stopped || wasFinished) return;\n\t\tif (code && code !== 0) {\n\t\t\thandlers.onError(`voicetools exited with code ${code}`);\n\t\t}\n\t});\n\n\treturn {\n\t\tstop: () => {\n\t\t\tif (stopped) return;\n\t\t\tstopped = true;\n\t\t\tfinish();\n\t\t\tproc.kill();\n\t\t},\n\t\tget running() {\n\t\t\treturn !finished && !stopped;\n\t\t},\n\t};\n}\n\n/**\n * Handlers for a persistent `voicetools serve` daemon. Extends the base\n * transcribe handlers with the daemon-only events:\n * - `onReady` fires once after models finish loading.\n * - `onLevel` per-audio-chunk RMS, for a live meter/waveform.\n * - `onPhase` phase markers (e.g. `\"silence\"` when trailing silence begins).\n * - `onPartial` interim transcript while the user speaks — the FULL growing\n * hypothesis each time (supersedes the previous), never committed.\n * - `onFinal` the complete committed transcript for the utterance, emitted\n * once before DONE — this is the text to inject into the editor.\n * - `onCrash` the process died after having been ready (caller should drop\n * the reference and respawn lazily on the next push-to-talk).\n */\nexport interface VoiceDaemonHandlers extends VoiceTranscribeHandlers {\n\tonReady?: () => void;\n\tonLevel?: (rms: number) => void;\n\tonPhase?: (phase: string) => void;\n\tonPartial?: (text: string) => void;\n\tonFinal?: (text: string) => void;\n\tonCrash?: (message: string) => void;\n}\n\n/**\n * Outcome of {@link VoiceDaemon.spawn}. `reason: \"unsupported\"` means the\n * process exited before READY with no ERROR line at all — the signature of\n * an old binary rejecting the unrecognized `serve` subcommand — and the\n * caller should silently fall back to `startVoiceTranscribe`. `reason:\n * \"error\"` means a genuine ERROR line (or OS-level spawn failure) was seen;\n * `handlers.onError` has already been called with it, and the caller should\n * surface that (not retry with the legacy path, which would just hit the\n * same failure) while leaving daemon mode available to retry next press.\n */\nexport type VoiceDaemonSpawnResult = { ok: true; daemon: VoiceDaemon } | { ok: false; reason: \"unsupported\" | \"error\" };\n\n/**\n * A persistent `voicetools serve` process: models are loaded once and stay\n * warm across captures. Only one capture runs at a time; call `startCapture`\n * to open the mic and `cancel` to stop early. `spawn` doubles as the support\n * probe for old binaries (see {@link VoiceDaemonSpawnResult}).\n */\nexport class VoiceDaemon {\n\tprivate closed = false;\n\n\tprivate constructor(\n\t\tprivate readonly proc: ChildProcess,\n\t\tprivate readonly handlers: VoiceDaemonHandlers,\n\t) {}\n\n\tget isReady(): boolean {\n\t\treturn !this.closed;\n\t}\n\n\tstatic spawn(bin: string, handlers: VoiceDaemonHandlers): Promise<VoiceDaemonSpawnResult> {\n\t\treturn new Promise((resolve) => {\n\t\t\tlet proc: ChildProcess;\n\t\t\ttry {\n\t\t\t\tproc = spawn(bin, [\"serve\"], { stdio: [\"pipe\", \"pipe\", \"ignore\"] });\n\t\t\t} catch (err) {\n\t\t\t\thandlers.onError(describeSpawnError(err, bin));\n\t\t\t\tresolve({ ok: false, reason: \"error\" });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!proc.stdout || !proc.stdin) {\n\t\t\t\tproc.kill();\n\t\t\t\thandlers.onError(\"failed to open voicetools serve stdio\");\n\t\t\t\tresolve({ ok: false, reason: \"error\" });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlet settled = false;\n\t\t\tlet daemon: VoiceDaemon | undefined;\n\t\t\tlet sawPreReadyError = false;\n\t\t\tconst rl = createInterface({ input: proc.stdout });\n\n\t\t\tconst fail = (reason: \"unsupported\" | \"error\"): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\trl.close();\n\t\t\t\tresolve({ ok: false, reason });\n\t\t\t};\n\n\t\t\trl.on(\"line\", (line) => {\n\t\t\t\tif (daemon) {\n\t\t\t\t\tdaemon.handleLine(line);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (settled) return;\n\t\t\t\tif (line === \"READY\") {\n\t\t\t\t\tsettled = true;\n\t\t\t\t\tdaemon = new VoiceDaemon(proc, handlers);\n\t\t\t\t\thandlers.onReady?.();\n\t\t\t\t\tresolve({ ok: true, daemon });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (line.startsWith(\"ERROR \")) {\n\t\t\t\t\t// Loading can fail before READY (e.g. no model installed yet).\n\t\t\t\t\t// Surface it now; the process still exits right after.\n\t\t\t\t\tsawPreReadyError = true;\n\t\t\t\t\thandlers.onError(line.slice(6).trim());\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tproc.on(\"error\", (err) => {\n\t\t\t\tif (daemon) {\n\t\t\t\t\tif (!daemon.closed) {\n\t\t\t\t\t\tdaemon.closed = true;\n\t\t\t\t\t\thandlers.onCrash?.(describeSpawnError(err, bin));\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (!sawPreReadyError) handlers.onError(describeSpawnError(err, bin));\n\t\t\t\tfail(\"error\");\n\t\t\t});\n\n\t\t\tproc.on(\"close\", (code) => {\n\t\t\t\tif (daemon) {\n\t\t\t\t\tif (!daemon.closed) {\n\t\t\t\t\t\tdaemon.closed = true;\n\t\t\t\t\t\thandlers.onCrash?.(`voicetools serve exited with code ${code ?? \"unknown\"}`);\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tfail(sawPreReadyError ? \"error\" : \"unsupported\");\n\t\t\t});\n\t\t});\n\t}\n\n\tprivate handleLine(line: string): void {\n\t\tif (line.startsWith(\"STATUS \")) {\n\t\t\tthis.handlers.onStatus(line.slice(7).trim());\n\t\t} else if (line.startsWith(\"PARTIAL \")) {\n\t\t\tthis.handlers.onPartial?.(line.slice(8));\n\t\t} else if (line.startsWith(\"FINAL \")) {\n\t\t\tthis.handlers.onFinal?.(line.slice(6));\n\t\t} else if (line.startsWith(\"SEGMENT \")) {\n\t\t\tthis.handlers.onSegment(line.slice(8));\n\t\t} else if (line.startsWith(\"LEVEL \")) {\n\t\t\tconst rms = Number.parseFloat(line.slice(6).trim());\n\t\t\tif (!Number.isNaN(rms)) this.handlers.onLevel?.(rms);\n\t\t} else if (line.startsWith(\"PHASE \")) {\n\t\t\tthis.handlers.onPhase?.(line.slice(6).trim());\n\t\t} else if (line === \"DONE\") {\n\t\t\tthis.handlers.onStatus(\"done\");\n\t\t} else if (line.startsWith(\"ERROR \")) {\n\t\t\tthis.handlers.onError(line.slice(6).trim());\n\t\t}\n\t}\n\n\t/** Begin a capture: opens the mic, streams PARTIAL/FINAL (or SEGMENT), ends with DONE. */\n\tstartCapture(): void {\n\t\tif (this.closed) return;\n\t\tthis.proc.stdin?.write(\"START\\n\");\n\t}\n\n\t/** Cancel the in-flight capture, if any. Idempotent. */\n\tcancel(): void {\n\t\tif (this.closed) return;\n\t\tthis.proc.stdin?.write(\"CANCEL\\n\");\n\t}\n\n\t/** Ask the daemon to exit gracefully, force-killing if it doesn't within 1s. */\n\tshutdown(): void {\n\t\tif (this.closed) return;\n\t\tthis.closed = true;\n\t\ttry {\n\t\t\tthis.proc.stdin?.write(\"SHUTDOWN\\n\");\n\t\t} catch {\n\t\t\t// stdin may already be gone (process died); force-kill below covers it.\n\t\t}\n\t\tconst proc = this.proc;\n\t\tconst killTimer = setTimeout(() => {\n\t\t\tif (!proc.killed) proc.kill();\n\t\t}, 1000);\n\t\tproc.once(\"close\", () => clearTimeout(killTimer));\n\t}\n}\n"]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Tools whose binaries hoocode can resolve from PATH or download on demand. */
|
|
2
|
-
export type ManagedTool = "fd" | "rg" | "webtools" | "filetools" | "browsertools";
|
|
2
|
+
export type ManagedTool = "fd" | "rg" | "webtools" | "filetools" | "browsertools" | "voicetools";
|
|
3
3
|
export declare function getToolPath(tool: ManagedTool): string | null;
|
|
4
4
|
export declare function downloadFile(url: string, dest: string): Promise<void>;
|
|
5
5
|
export declare function ensureTool(tool: ManagedTool, silent?: boolean): Promise<string | undefined>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tools-manager.d.ts","sourceRoot":"","sources":["../../src/utils/tools-manager.ts"],"names":[],"mappings":"AA+BA,gFAAgF;AAChF,MAAM,MAAM,WAAW,GAAG,IAAI,GAAG,IAAI,GAAG,UAAU,GAAG,WAAW,GAAG,cAAc,CAAC;AAsIlF,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CA0C5D;AAqDD,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoC3E;AA4ID,wBAAsB,UAAU,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,GAAE,OAAe,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA2CxG","sourcesContent":["import chalk from \"chalk\";\nimport { spawnSync } from \"child_process\";\nimport { createHash, randomBytes } from \"crypto\";\nimport extractZip from \"extract-zip\";\nimport {\n\tchmodSync,\n\tcreateWriteStream,\n\texistsSync,\n\tmkdirSync,\n\treaddirSync,\n\treadFileSync,\n\trenameSync,\n\trmSync,\n\tstatSync,\n} from \"fs\";\nimport { arch, platform } from \"os\";\nimport { join } from \"path\";\nimport { Readable } from \"stream\";\nimport { pipeline } from \"stream/promises\";\nimport { APP_NAME, getBinDir } from \"../config.js\";\n\nconst TOOLS_DIR = getBinDir();\nconst NETWORK_TIMEOUT_MS = 10_000;\nconst DOWNLOAD_TIMEOUT_MS = 120_000;\n\nfunction isOfflineModeEnabled(): boolean {\n\tconst value = process.env.HOOCODE_OFFLINE ?? process.env.PI_OFFLINE;\n\tif (!value) return false;\n\treturn value === \"1\" || value.toLowerCase() === \"true\" || value.toLowerCase() === \"yes\";\n}\n\n/** Tools whose binaries hoocode can resolve from PATH or download on demand. */\nexport type ManagedTool = \"fd\" | \"rg\" | \"webtools\" | \"filetools\" | \"browsertools\";\n\ninterface ToolConfig {\n\tname: string;\n\trepo: string; // GitHub repo (e.g., \"sharkdp/fd\")\n\tbinaryName: string; // Name of the binary inside the archive\n\tsystemBinaryNames?: string[]; // Alternative system command names to try before downloading\n\ttagPrefix: string; // Prefix for tags (e.g., \"v\" for v1.0.0, \"\" for 1.0.0)\n\tgetAssetName: (version: string, plat: string, architecture: string) => string | null;\n}\n\nconst TOOLS: Record<string, ToolConfig> = {\n\tfd: {\n\t\tname: \"fd\",\n\t\trepo: \"sharkdp/fd\",\n\t\tbinaryName: \"fd\",\n\t\tsystemBinaryNames: [\"fd\", \"fdfind\"],\n\t\ttagPrefix: \"v\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\trg: {\n\t\tname: \"ripgrep\",\n\t\trepo: \"BurntSushi/ripgrep\",\n\t\tbinaryName: \"rg\",\n\t\ttagPrefix: \"\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tif (architecture === \"arm64\") {\n\t\t\t\t\treturn `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;\n\t\t\t\t}\n\t\t\t\treturn `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\twebtools: {\n\t\tname: \"webtools\",\n\t\trepo: \"kolisachint/webtools\",\n\t\tbinaryName: \"webtools\",\n\t\ttagPrefix: \"v\",\n\t\t// Release assets follow Rust target triples: webtools-<arch>-<target>.<ext>.\n\t\t// Some platforms may not be published yet; a missing asset 404s and ensureTool\n\t\t// degrades gracefully (returns undefined, tools fall back to an error message).\n\t\tgetAssetName: (_version, plat, architecture) => {\n\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\treturn `webtools-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\treturn `webtools-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\treturn `webtools-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\tbrowsertools: {\n\t\tname: \"browsertools\",\n\t\trepo: \"kolisachint/browsertools\",\n\t\tbinaryName: \"browsertools\",\n\t\ttagPrefix: \"v\",\n\t\t// Release archives follow Rust target triples: browsertools-<arch>-<target>.<ext>\n\t\t// (release.yml builds gnu + musl for linux; we prefer the gnu variant to match\n\t\t// webtools/filetools). A missing platform asset 404s and ensureTool degrades\n\t\t// gracefully (returns undefined; the browser tools then surface an error).\n\t\tgetAssetName: (_version, plat, architecture) => {\n\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\treturn `browsertools-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\treturn `browsertools-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\treturn `browsertools-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\tfiletools: {\n\t\tname: \"filetools\",\n\t\trepo: \"kolisachint/filetools\",\n\t\tbinaryName: \"filetools\",\n\t\ttagPrefix: \"v\",\n\t\t// Release archives are named `filetools-<target-triple>.<ext>` (see the repo's\n\t\t// release.yml `archive: filetools-$target`). A missing platform asset 404s and\n\t\t// ensureTool degrades gracefully (returns undefined; the doc tools then surface\n\t\t// an error result).\n\t\tgetAssetName: (_version, plat, architecture) => {\n\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\treturn `filetools-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\treturn `filetools-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\treturn `filetools-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n};\n\n// Check if a command exists in PATH by trying to run it\nfunction commandExists(cmd: string): boolean {\n\ttry {\n\t\tconst result = spawnSync(cmd, [\"--version\"], { stdio: \"pipe\" });\n\t\t// Check for ENOENT error (command not found)\n\t\treturn result.error === undefined || result.error === null;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n// Resolved tool paths are stable for the life of the process. Cache the first\n// successful resolution so we never re-run the synchronous spawnSync probe in\n// commandExists() on every grep/find/glob invocation, which blocks the event loop.\nconst resolvedToolPathCache = new Map<ManagedTool, string>();\n\n// Get the path to a tool (system-wide or in our tools dir)\nexport function getToolPath(tool: ManagedTool): string | null {\n\tconst config = TOOLS[tool];\n\tif (!config) return null;\n\n\t// Explicit binary override (per-tool env var, e.g. HOOCODE_BROWSERTOOLS_BINARY).\n\t// Lets a developer point at a locally built binary that predates a release,\n\t// bypassing the tools-dir/PATH resolution and download. Authoritative when set\n\t// and the path exists.\n\tconst overrideEnv = `HOOCODE_${tool.toUpperCase()}_BINARY`;\n\tconst override = process.env[overrideEnv]?.trim();\n\tif (override && existsSync(override)) {\n\t\treturn override;\n\t}\n\n\t// Reuse a previously resolved path. A bare command name resolves via PATH;\n\t// an absolute path must still exist (revalidate cheaply with existsSync).\n\tconst cached = resolvedToolPathCache.get(tool);\n\tif (cached !== undefined) {\n\t\tconst isAbsolutePath = cached.includes(\"/\") || cached.includes(\"\\\\\");\n\t\tif (!isAbsolutePath || existsSync(cached)) {\n\t\t\treturn cached;\n\t\t}\n\t\tresolvedToolPathCache.delete(tool);\n\t}\n\n\t// Check our tools directory first\n\tconst localPath = join(TOOLS_DIR, config.binaryName + (platform() === \"win32\" ? \".exe\" : \"\"));\n\tif (existsSync(localPath)) {\n\t\tresolvedToolPathCache.set(tool, localPath);\n\t\treturn localPath;\n\t}\n\n\t// Check system PATH - if found, just return the command name (it's in PATH)\n\tconst systemBinaryNames = config.systemBinaryNames ?? [config.binaryName];\n\tfor (const systemBinaryName of systemBinaryNames) {\n\t\tif (commandExists(systemBinaryName)) {\n\t\t\tresolvedToolPathCache.set(tool, systemBinaryName);\n\t\t\treturn systemBinaryName;\n\t\t}\n\t}\n\n\treturn null;\n}\n\n// Fetch latest release version from GitHub\nasync function getLatestVersion(repo: string): Promise<string> {\n\tconst response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {\n\t\theaders: { \"User-Agent\": `${APP_NAME}-coding-agent` },\n\t\tsignal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(`GitHub API error: ${response.status}`);\n\t}\n\n\tconst data = (await response.json()) as { tag_name: string };\n\treturn data.tag_name.replace(/^v/, \"\");\n}\n\n// Best-effort SHA-256 verification: fetch \"<downloadUrl>.sha256\" and, when it is\n// served (HTTP 200), verify the downloaded file against it. A 404 (or any other\n// non-200 / network error) means no published checksum, so verification is\n// skipped rather than treated as a failure. A genuine mismatch throws.\nasync function verifyChecksum(downloadUrl: string, filePath: string): Promise<void> {\n\tlet checksumResponse: Awaited<ReturnType<typeof fetch>>;\n\ttry {\n\t\tchecksumResponse = await fetch(`${downloadUrl}.sha256`, {\n\t\t\tsignal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),\n\t\t});\n\t} catch {\n\t\t// Network error fetching the checksum is non-fatal for best-effort verification.\n\t\treturn;\n\t}\n\n\tif (checksumResponse.status !== 200) {\n\t\treturn;\n\t}\n\n\t// sha256 files are commonly \"<hex> <filename>\"; take the leading token.\n\tconst expectedHash = (await checksumResponse.text()).trim().split(/\\s+/)[0]?.toLowerCase();\n\tif (!expectedHash || !/^[0-9a-f]{64}$/.test(expectedHash)) {\n\t\t// Unusable checksum body: skip rather than fail (still best-effort).\n\t\treturn;\n\t}\n\n\tconst actualHash = createHash(\"sha256\").update(readFileSync(filePath)).digest(\"hex\");\n\tif (actualHash !== expectedHash) {\n\t\tthrow new Error(`Checksum mismatch for ${downloadUrl}: expected ${expectedHash}, got ${actualHash}`);\n\t}\n}\n\n// Download a file from URL into `dest`, validating integrity. Throws (and removes\n// the partial file) on a truncated transfer (bytes written != Content-Length when\n// the header is present) or a SHA-256 mismatch, so a corrupt artifact is never\n// left behind. Exported for tests.\nexport async function downloadFile(url: string, dest: string): Promise<void> {\n\ttry {\n\t\tconst response = await fetch(url, {\n\t\t\tsignal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),\n\t\t});\n\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`Failed to download: ${response.status}`);\n\t\t}\n\n\t\tif (!response.body) {\n\t\t\tthrow new Error(\"No response body\");\n\t\t}\n\n\t\tconst contentLengthHeader = response.headers.get(\"content-length\");\n\t\tconst expectedBytes =\n\t\t\tcontentLengthHeader !== null && contentLengthHeader.trim() !== \"\" ? Number(contentLengthHeader) : null;\n\n\t\tconst fileStream = createWriteStream(dest);\n\t\tawait pipeline(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]), fileStream);\n\n\t\tif (expectedBytes !== null && Number.isFinite(expectedBytes)) {\n\t\t\tconst bytesWritten = statSync(dest).size;\n\t\t\tif (bytesWritten !== expectedBytes) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Truncated download from ${url}: expected ${expectedBytes} bytes, received ${bytesWritten}`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tawait verifyChecksum(url, dest);\n\t} catch (e) {\n\t\t// Never leave a partial/corrupt file behind on any failure.\n\t\trmSync(dest, { force: true });\n\t\tthrow e;\n\t}\n}\n\nfunction findBinaryRecursively(rootDir: string, binaryFileName: string): string | null {\n\tconst stack: string[] = [rootDir];\n\n\twhile (stack.length > 0) {\n\t\tconst currentDir = stack.pop();\n\t\tif (!currentDir) continue;\n\n\t\tconst entries = readdirSync(currentDir, { withFileTypes: true });\n\t\tfor (const entry of entries) {\n\t\t\tconst fullPath = join(currentDir, entry.name);\n\t\t\tif (entry.isFile() && entry.name === binaryFileName) {\n\t\t\t\treturn fullPath;\n\t\t\t}\n\t\t\tif (entry.isDirectory()) {\n\t\t\t\tstack.push(fullPath);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn null;\n}\n\n// Download and install a tool\nasync function downloadTool(tool: ManagedTool): Promise<string> {\n\tconst config = TOOLS[tool];\n\tif (!config) throw new Error(`Unknown tool: ${tool}`);\n\n\tconst plat = platform();\n\tconst architecture = arch();\n\n\t// Get latest version\n\tconst version = await getLatestVersion(config.repo);\n\n\t// Get asset name for this platform\n\tconst assetName = config.getAssetName(version, plat, architecture);\n\tif (!assetName) {\n\t\tthrow new Error(`Unsupported platform: ${plat}/${architecture}`);\n\t}\n\n\t// Create tools directory\n\tmkdirSync(TOOLS_DIR, { recursive: true });\n\n\tconst downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;\n\tconst archivePath = join(TOOLS_DIR, assetName);\n\tconst binaryExt = plat === \"win32\" ? \".exe\" : \"\";\n\tconst binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);\n\n\t// Download to a unique temp path, validate, then atomically rename into place.\n\t// Writing the shared archive path directly would leave a corrupt partial behind\n\t// if the transfer fails or is truncated. fd/rg/webtools can also download\n\t// concurrently at startup, so the per-attempt temp name must be unique.\n\tconst tempArchivePath = `${archivePath}.${process.pid}.${randomBytes(6).toString(\"hex\")}.part`;\n\n\t// Extract into a unique temp directory. fd and rg downloads can run concurrently\n\t// during startup, so sharing a fixed directory causes races.\n\tconst extractDir = join(\n\t\tTOOLS_DIR,\n\t\t`extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,\n\t);\n\n\ttry {\n\t\t// One retry (2 attempts total) around download + integrity verification.\n\t\t// downloadFile removes its own partial on failure, so each attempt is clean.\n\t\tlet lastError: unknown;\n\t\tlet downloaded = false;\n\t\tfor (let attempt = 1; attempt <= 2 && !downloaded; attempt++) {\n\t\t\ttry {\n\t\t\t\tawait downloadFile(downloadUrl, tempArchivePath);\n\t\t\t\tdownloaded = true;\n\t\t\t} catch (e) {\n\t\t\t\tlastError = e;\n\t\t\t\trmSync(tempArchivePath, { force: true });\n\t\t\t}\n\t\t}\n\t\tif (!downloaded) {\n\t\t\tthrow lastError instanceof Error ? lastError : new Error(String(lastError));\n\t\t}\n\n\t\t// Atomic publish of the verified archive, then extract.\n\t\trenameSync(tempArchivePath, archivePath);\n\t\tmkdirSync(extractDir, { recursive: true });\n\n\t\tif (assetName.endsWith(\".tar.gz\")) {\n\t\t\tconst extractResult = spawnSync(\"tar\", [\"xzf\", archivePath, \"-C\", extractDir], { stdio: \"pipe\" });\n\t\t\tif (extractResult.error || extractResult.status !== 0) {\n\t\t\t\tconst errMsg = extractResult.error?.message ?? extractResult.stderr?.toString().trim() ?? \"unknown error\";\n\t\t\t\tthrow new Error(`Failed to extract ${assetName}: ${errMsg}`);\n\t\t\t}\n\t\t} else if (assetName.endsWith(\".zip\")) {\n\t\t\tawait extractZip(archivePath, { dir: extractDir });\n\t\t} else {\n\t\t\tthrow new Error(`Unsupported archive format: ${assetName}`);\n\t\t}\n\n\t\t// Find the binary in extracted files. Some archives contain files directly\n\t\t// at root, others nest under a versioned subdirectory.\n\t\tconst binaryFileName = config.binaryName + binaryExt;\n\t\tconst extractedDir = join(extractDir, assetName.replace(/\\.(tar\\.gz|zip)$/, \"\"));\n\t\tconst extractedBinaryCandidates = [join(extractedDir, binaryFileName), join(extractDir, binaryFileName)];\n\t\tlet extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate));\n\n\t\tif (!extractedBinary) {\n\t\t\textractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined;\n\t\t}\n\n\t\tif (extractedBinary) {\n\t\t\trenameSync(extractedBinary, binaryPath);\n\t\t} else {\n\t\t\tthrow new Error(`Binary not found in archive: expected ${binaryFileName} under ${extractDir}`);\n\t\t}\n\n\t\t// Make executable (Unix only)\n\t\tif (plat !== \"win32\") {\n\t\t\tchmodSync(binaryPath, 0o755);\n\t\t}\n\t} finally {\n\t\t// Guaranteed cleanup of every transient artifact on ANY outcome: the temp\n\t\t// download (if a failure left it before the rename), the published archive,\n\t\t// and the temp extract dir.\n\t\trmSync(tempArchivePath, { force: true });\n\t\trmSync(archivePath, { force: true });\n\t\trmSync(extractDir, { recursive: true, force: true });\n\t}\n\n\treturn binaryPath;\n}\n\n// Termux package names for tools\nconst TERMUX_PACKAGES: Record<string, string> = {\n\tfd: \"fd\",\n\trg: \"ripgrep\",\n\twebtools: \"webtools\",\n\tfiletools: \"filetools\",\n\tbrowsertools: \"browsertools\",\n};\n\n// Ensure a tool is available, downloading if necessary\n// Returns the path to the tool, or null if unavailable\nexport async function ensureTool(tool: ManagedTool, silent: boolean = false): Promise<string | undefined> {\n\tconst existingPath = getToolPath(tool);\n\tif (existingPath) {\n\t\treturn existingPath;\n\t}\n\n\tconst config = TOOLS[tool];\n\tif (!config) return undefined;\n\n\tif (isOfflineModeEnabled()) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.\n\t// Users must install via pkg.\n\tif (platform() === \"android\") {\n\t\tconst pkgName = TERMUX_PACKAGES[tool] ?? tool;\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// Tool not found - download it\n\tif (!silent) {\n\t\tconsole.log(chalk.dim(`${config.name} not found. Downloading...`));\n\t}\n\n\ttry {\n\t\tconst path = await downloadTool(tool);\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.dim(`${config.name} installed to ${path}`));\n\t\t}\n\t\treturn path;\n\t} catch (e) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));\n\t\t}\n\t\treturn undefined;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"tools-manager.d.ts","sourceRoot":"","sources":["../../src/utils/tools-manager.ts"],"names":[],"mappings":"AA+BA,gFAAgF;AAChF,MAAM,MAAM,WAAW,GAAG,IAAI,GAAG,IAAI,GAAG,UAAU,GAAG,WAAW,GAAG,cAAc,GAAG,YAAY,CAAC;AA0JjG,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CA0C5D;AAqDD,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoC3E;AA6ID,wBAAsB,UAAU,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,GAAE,OAAe,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA2CxG","sourcesContent":["import chalk from \"chalk\";\nimport { spawnSync } from \"child_process\";\nimport { createHash, randomBytes } from \"crypto\";\nimport extractZip from \"extract-zip\";\nimport {\n\tchmodSync,\n\tcreateWriteStream,\n\texistsSync,\n\tmkdirSync,\n\treaddirSync,\n\treadFileSync,\n\trenameSync,\n\trmSync,\n\tstatSync,\n} from \"fs\";\nimport { arch, platform } from \"os\";\nimport { join } from \"path\";\nimport { Readable } from \"stream\";\nimport { pipeline } from \"stream/promises\";\nimport { APP_NAME, getBinDir } from \"../config.js\";\n\nconst TOOLS_DIR = getBinDir();\nconst NETWORK_TIMEOUT_MS = 10_000;\nconst DOWNLOAD_TIMEOUT_MS = 120_000;\n\nfunction isOfflineModeEnabled(): boolean {\n\tconst value = process.env.HOOCODE_OFFLINE ?? process.env.PI_OFFLINE;\n\tif (!value) return false;\n\treturn value === \"1\" || value.toLowerCase() === \"true\" || value.toLowerCase() === \"yes\";\n}\n\n/** Tools whose binaries hoocode can resolve from PATH or download on demand. */\nexport type ManagedTool = \"fd\" | \"rg\" | \"webtools\" | \"filetools\" | \"browsertools\" | \"voicetools\";\n\ninterface ToolConfig {\n\tname: string;\n\trepo: string; // GitHub repo (e.g., \"sharkdp/fd\")\n\tbinaryName: string; // Name of the binary inside the archive\n\tsystemBinaryNames?: string[]; // Alternative system command names to try before downloading\n\ttagPrefix: string; // Prefix for tags (e.g., \"v\" for v1.0.0, \"\" for 1.0.0)\n\tgetAssetName: (version: string, plat: string, architecture: string) => string | null;\n}\n\nconst TOOLS: Record<string, ToolConfig> = {\n\tfd: {\n\t\tname: \"fd\",\n\t\trepo: \"sharkdp/fd\",\n\t\tbinaryName: \"fd\",\n\t\tsystemBinaryNames: [\"fd\", \"fdfind\"],\n\t\ttagPrefix: \"v\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\trg: {\n\t\tname: \"ripgrep\",\n\t\trepo: \"BurntSushi/ripgrep\",\n\t\tbinaryName: \"rg\",\n\t\ttagPrefix: \"\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tif (architecture === \"arm64\") {\n\t\t\t\t\treturn `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;\n\t\t\t\t}\n\t\t\t\treturn `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\twebtools: {\n\t\tname: \"webtools\",\n\t\trepo: \"kolisachint/webtools\",\n\t\tbinaryName: \"webtools\",\n\t\ttagPrefix: \"v\",\n\t\t// Release assets follow Rust target triples: webtools-<arch>-<target>.<ext>.\n\t\t// Some platforms may not be published yet; a missing asset 404s and ensureTool\n\t\t// degrades gracefully (returns undefined, tools fall back to an error message).\n\t\tgetAssetName: (_version, plat, architecture) => {\n\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\treturn `webtools-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\treturn `webtools-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\treturn `webtools-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\tbrowsertools: {\n\t\tname: \"browsertools\",\n\t\trepo: \"kolisachint/browsertools\",\n\t\tbinaryName: \"browsertools\",\n\t\ttagPrefix: \"v\",\n\t\t// Release archives follow Rust target triples: browsertools-<arch>-<target>.<ext>\n\t\t// (release.yml builds gnu + musl for linux; we prefer the gnu variant to match\n\t\t// webtools/filetools). A missing platform asset 404s and ensureTool degrades\n\t\t// gracefully (returns undefined; the browser tools then surface an error).\n\t\tgetAssetName: (_version, plat, architecture) => {\n\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\treturn `browsertools-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\treturn `browsertools-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\treturn `browsertools-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\tvoicetools: {\n\t\tname: \"voicetools\",\n\t\trepo: \"kolisachint/voicetools\",\n\t\tbinaryName: \"voicetools\",\n\t\ttagPrefix: \"v\",\n\t\t// Release archives follow Rust target triples: voicetools-<arch>-<target>.<ext>.\n\t\t// A missing platform asset 404s and ensureTool degrades gracefully (returns\n\t\t// undefined; the voice-transcribe caller then surfaces an error message).\n\t\tgetAssetName: (_version, plat, architecture) => {\n\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\treturn `voicetools-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\treturn `voicetools-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\treturn `voicetools-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\tfiletools: {\n\t\tname: \"filetools\",\n\t\trepo: \"kolisachint/filetools\",\n\t\tbinaryName: \"filetools\",\n\t\ttagPrefix: \"v\",\n\t\t// Release archives are named `filetools-<target-triple>.<ext>` (see the repo's\n\t\t// release.yml `archive: filetools-$target`). A missing platform asset 404s and\n\t\t// ensureTool degrades gracefully (returns undefined; the doc tools then surface\n\t\t// an error result).\n\t\tgetAssetName: (_version, plat, architecture) => {\n\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\treturn `filetools-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\treturn `filetools-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\treturn `filetools-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n};\n\n// Check if a command exists in PATH by trying to run it\nfunction commandExists(cmd: string): boolean {\n\ttry {\n\t\tconst result = spawnSync(cmd, [\"--version\"], { stdio: \"pipe\" });\n\t\t// Check for ENOENT error (command not found)\n\t\treturn result.error === undefined || result.error === null;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n// Resolved tool paths are stable for the life of the process. Cache the first\n// successful resolution so we never re-run the synchronous spawnSync probe in\n// commandExists() on every grep/find/glob invocation, which blocks the event loop.\nconst resolvedToolPathCache = new Map<ManagedTool, string>();\n\n// Get the path to a tool (system-wide or in our tools dir)\nexport function getToolPath(tool: ManagedTool): string | null {\n\tconst config = TOOLS[tool];\n\tif (!config) return null;\n\n\t// Explicit binary override (per-tool env var, e.g. HOOCODE_BROWSERTOOLS_BINARY).\n\t// Lets a developer point at a locally built binary that predates a release,\n\t// bypassing the tools-dir/PATH resolution and download. Authoritative when set\n\t// and the path exists.\n\tconst overrideEnv = `HOOCODE_${tool.toUpperCase()}_BINARY`;\n\tconst override = process.env[overrideEnv]?.trim();\n\tif (override && existsSync(override)) {\n\t\treturn override;\n\t}\n\n\t// Reuse a previously resolved path. A bare command name resolves via PATH;\n\t// an absolute path must still exist (revalidate cheaply with existsSync).\n\tconst cached = resolvedToolPathCache.get(tool);\n\tif (cached !== undefined) {\n\t\tconst isAbsolutePath = cached.includes(\"/\") || cached.includes(\"\\\\\");\n\t\tif (!isAbsolutePath || existsSync(cached)) {\n\t\t\treturn cached;\n\t\t}\n\t\tresolvedToolPathCache.delete(tool);\n\t}\n\n\t// Check our tools directory first\n\tconst localPath = join(TOOLS_DIR, config.binaryName + (platform() === \"win32\" ? \".exe\" : \"\"));\n\tif (existsSync(localPath)) {\n\t\tresolvedToolPathCache.set(tool, localPath);\n\t\treturn localPath;\n\t}\n\n\t// Check system PATH - if found, just return the command name (it's in PATH)\n\tconst systemBinaryNames = config.systemBinaryNames ?? [config.binaryName];\n\tfor (const systemBinaryName of systemBinaryNames) {\n\t\tif (commandExists(systemBinaryName)) {\n\t\t\tresolvedToolPathCache.set(tool, systemBinaryName);\n\t\t\treturn systemBinaryName;\n\t\t}\n\t}\n\n\treturn null;\n}\n\n// Fetch latest release version from GitHub\nasync function getLatestVersion(repo: string): Promise<string> {\n\tconst response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {\n\t\theaders: { \"User-Agent\": `${APP_NAME}-coding-agent` },\n\t\tsignal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(`GitHub API error: ${response.status}`);\n\t}\n\n\tconst data = (await response.json()) as { tag_name: string };\n\treturn data.tag_name.replace(/^v/, \"\");\n}\n\n// Best-effort SHA-256 verification: fetch \"<downloadUrl>.sha256\" and, when it is\n// served (HTTP 200), verify the downloaded file against it. A 404 (or any other\n// non-200 / network error) means no published checksum, so verification is\n// skipped rather than treated as a failure. A genuine mismatch throws.\nasync function verifyChecksum(downloadUrl: string, filePath: string): Promise<void> {\n\tlet checksumResponse: Awaited<ReturnType<typeof fetch>>;\n\ttry {\n\t\tchecksumResponse = await fetch(`${downloadUrl}.sha256`, {\n\t\t\tsignal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),\n\t\t});\n\t} catch {\n\t\t// Network error fetching the checksum is non-fatal for best-effort verification.\n\t\treturn;\n\t}\n\n\tif (checksumResponse.status !== 200) {\n\t\treturn;\n\t}\n\n\t// sha256 files are commonly \"<hex> <filename>\"; take the leading token.\n\tconst expectedHash = (await checksumResponse.text()).trim().split(/\\s+/)[0]?.toLowerCase();\n\tif (!expectedHash || !/^[0-9a-f]{64}$/.test(expectedHash)) {\n\t\t// Unusable checksum body: skip rather than fail (still best-effort).\n\t\treturn;\n\t}\n\n\tconst actualHash = createHash(\"sha256\").update(readFileSync(filePath)).digest(\"hex\");\n\tif (actualHash !== expectedHash) {\n\t\tthrow new Error(`Checksum mismatch for ${downloadUrl}: expected ${expectedHash}, got ${actualHash}`);\n\t}\n}\n\n// Download a file from URL into `dest`, validating integrity. Throws (and removes\n// the partial file) on a truncated transfer (bytes written != Content-Length when\n// the header is present) or a SHA-256 mismatch, so a corrupt artifact is never\n// left behind. Exported for tests.\nexport async function downloadFile(url: string, dest: string): Promise<void> {\n\ttry {\n\t\tconst response = await fetch(url, {\n\t\t\tsignal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),\n\t\t});\n\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`Failed to download: ${response.status}`);\n\t\t}\n\n\t\tif (!response.body) {\n\t\t\tthrow new Error(\"No response body\");\n\t\t}\n\n\t\tconst contentLengthHeader = response.headers.get(\"content-length\");\n\t\tconst expectedBytes =\n\t\t\tcontentLengthHeader !== null && contentLengthHeader.trim() !== \"\" ? Number(contentLengthHeader) : null;\n\n\t\tconst fileStream = createWriteStream(dest);\n\t\tawait pipeline(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]), fileStream);\n\n\t\tif (expectedBytes !== null && Number.isFinite(expectedBytes)) {\n\t\t\tconst bytesWritten = statSync(dest).size;\n\t\t\tif (bytesWritten !== expectedBytes) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Truncated download from ${url}: expected ${expectedBytes} bytes, received ${bytesWritten}`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tawait verifyChecksum(url, dest);\n\t} catch (e) {\n\t\t// Never leave a partial/corrupt file behind on any failure.\n\t\trmSync(dest, { force: true });\n\t\tthrow e;\n\t}\n}\n\nfunction findBinaryRecursively(rootDir: string, binaryFileName: string): string | null {\n\tconst stack: string[] = [rootDir];\n\n\twhile (stack.length > 0) {\n\t\tconst currentDir = stack.pop();\n\t\tif (!currentDir) continue;\n\n\t\tconst entries = readdirSync(currentDir, { withFileTypes: true });\n\t\tfor (const entry of entries) {\n\t\t\tconst fullPath = join(currentDir, entry.name);\n\t\t\tif (entry.isFile() && entry.name === binaryFileName) {\n\t\t\t\treturn fullPath;\n\t\t\t}\n\t\t\tif (entry.isDirectory()) {\n\t\t\t\tstack.push(fullPath);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn null;\n}\n\n// Download and install a tool\nasync function downloadTool(tool: ManagedTool): Promise<string> {\n\tconst config = TOOLS[tool];\n\tif (!config) throw new Error(`Unknown tool: ${tool}`);\n\n\tconst plat = platform();\n\tconst architecture = arch();\n\n\t// Get latest version\n\tconst version = await getLatestVersion(config.repo);\n\n\t// Get asset name for this platform\n\tconst assetName = config.getAssetName(version, plat, architecture);\n\tif (!assetName) {\n\t\tthrow new Error(`Unsupported platform: ${plat}/${architecture}`);\n\t}\n\n\t// Create tools directory\n\tmkdirSync(TOOLS_DIR, { recursive: true });\n\n\tconst downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;\n\tconst archivePath = join(TOOLS_DIR, assetName);\n\tconst binaryExt = plat === \"win32\" ? \".exe\" : \"\";\n\tconst binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);\n\n\t// Download to a unique temp path, validate, then atomically rename into place.\n\t// Writing the shared archive path directly would leave a corrupt partial behind\n\t// if the transfer fails or is truncated. fd/rg/webtools can also download\n\t// concurrently at startup, so the per-attempt temp name must be unique.\n\tconst tempArchivePath = `${archivePath}.${process.pid}.${randomBytes(6).toString(\"hex\")}.part`;\n\n\t// Extract into a unique temp directory. fd and rg downloads can run concurrently\n\t// during startup, so sharing a fixed directory causes races.\n\tconst extractDir = join(\n\t\tTOOLS_DIR,\n\t\t`extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,\n\t);\n\n\ttry {\n\t\t// One retry (2 attempts total) around download + integrity verification.\n\t\t// downloadFile removes its own partial on failure, so each attempt is clean.\n\t\tlet lastError: unknown;\n\t\tlet downloaded = false;\n\t\tfor (let attempt = 1; attempt <= 2 && !downloaded; attempt++) {\n\t\t\ttry {\n\t\t\t\tawait downloadFile(downloadUrl, tempArchivePath);\n\t\t\t\tdownloaded = true;\n\t\t\t} catch (e) {\n\t\t\t\tlastError = e;\n\t\t\t\trmSync(tempArchivePath, { force: true });\n\t\t\t}\n\t\t}\n\t\tif (!downloaded) {\n\t\t\tthrow lastError instanceof Error ? lastError : new Error(String(lastError));\n\t\t}\n\n\t\t// Atomic publish of the verified archive, then extract.\n\t\trenameSync(tempArchivePath, archivePath);\n\t\tmkdirSync(extractDir, { recursive: true });\n\n\t\tif (assetName.endsWith(\".tar.gz\")) {\n\t\t\tconst extractResult = spawnSync(\"tar\", [\"xzf\", archivePath, \"-C\", extractDir], { stdio: \"pipe\" });\n\t\t\tif (extractResult.error || extractResult.status !== 0) {\n\t\t\t\tconst errMsg = extractResult.error?.message ?? extractResult.stderr?.toString().trim() ?? \"unknown error\";\n\t\t\t\tthrow new Error(`Failed to extract ${assetName}: ${errMsg}`);\n\t\t\t}\n\t\t} else if (assetName.endsWith(\".zip\")) {\n\t\t\tawait extractZip(archivePath, { dir: extractDir });\n\t\t} else {\n\t\t\tthrow new Error(`Unsupported archive format: ${assetName}`);\n\t\t}\n\n\t\t// Find the binary in extracted files. Some archives contain files directly\n\t\t// at root, others nest under a versioned subdirectory.\n\t\tconst binaryFileName = config.binaryName + binaryExt;\n\t\tconst extractedDir = join(extractDir, assetName.replace(/\\.(tar\\.gz|zip)$/, \"\"));\n\t\tconst extractedBinaryCandidates = [join(extractedDir, binaryFileName), join(extractDir, binaryFileName)];\n\t\tlet extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate));\n\n\t\tif (!extractedBinary) {\n\t\t\textractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined;\n\t\t}\n\n\t\tif (extractedBinary) {\n\t\t\trenameSync(extractedBinary, binaryPath);\n\t\t} else {\n\t\t\tthrow new Error(`Binary not found in archive: expected ${binaryFileName} under ${extractDir}`);\n\t\t}\n\n\t\t// Make executable (Unix only)\n\t\tif (plat !== \"win32\") {\n\t\t\tchmodSync(binaryPath, 0o755);\n\t\t}\n\t} finally {\n\t\t// Guaranteed cleanup of every transient artifact on ANY outcome: the temp\n\t\t// download (if a failure left it before the rename), the published archive,\n\t\t// and the temp extract dir.\n\t\trmSync(tempArchivePath, { force: true });\n\t\trmSync(archivePath, { force: true });\n\t\trmSync(extractDir, { recursive: true, force: true });\n\t}\n\n\treturn binaryPath;\n}\n\n// Termux package names for tools\nconst TERMUX_PACKAGES: Record<string, string> = {\n\tfd: \"fd\",\n\trg: \"ripgrep\",\n\twebtools: \"webtools\",\n\tfiletools: \"filetools\",\n\tbrowsertools: \"browsertools\",\n\tvoicetools: \"voicetools\",\n};\n\n// Ensure a tool is available, downloading if necessary\n// Returns the path to the tool, or null if unavailable\nexport async function ensureTool(tool: ManagedTool, silent: boolean = false): Promise<string | undefined> {\n\tconst existingPath = getToolPath(tool);\n\tif (existingPath) {\n\t\treturn existingPath;\n\t}\n\n\tconst config = TOOLS[tool];\n\tif (!config) return undefined;\n\n\tif (isOfflineModeEnabled()) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.\n\t// Users must install via pkg.\n\tif (platform() === \"android\") {\n\t\tconst pkgName = TERMUX_PACKAGES[tool] ?? tool;\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// Tool not found - download it\n\tif (!silent) {\n\t\tconsole.log(chalk.dim(`${config.name} not found. Downloading...`));\n\t}\n\n\ttry {\n\t\tconst path = await downloadTool(tool);\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.dim(`${config.name} installed to ${path}`));\n\t\t}\n\t\treturn path;\n\t} catch (e) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));\n\t\t}\n\t\treturn undefined;\n\t}\n}\n"]}
|
|
@@ -108,6 +108,28 @@ const TOOLS = {
|
|
|
108
108
|
return null;
|
|
109
109
|
},
|
|
110
110
|
},
|
|
111
|
+
voicetools: {
|
|
112
|
+
name: "voicetools",
|
|
113
|
+
repo: "kolisachint/voicetools",
|
|
114
|
+
binaryName: "voicetools",
|
|
115
|
+
tagPrefix: "v",
|
|
116
|
+
// Release archives follow Rust target triples: voicetools-<arch>-<target>.<ext>.
|
|
117
|
+
// A missing platform asset 404s and ensureTool degrades gracefully (returns
|
|
118
|
+
// undefined; the voice-transcribe caller then surfaces an error message).
|
|
119
|
+
getAssetName: (_version, plat, architecture) => {
|
|
120
|
+
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
|
121
|
+
if (plat === "darwin") {
|
|
122
|
+
return `voicetools-${archStr}-apple-darwin.tar.gz`;
|
|
123
|
+
}
|
|
124
|
+
else if (plat === "linux") {
|
|
125
|
+
return `voicetools-${archStr}-unknown-linux-gnu.tar.gz`;
|
|
126
|
+
}
|
|
127
|
+
else if (plat === "win32") {
|
|
128
|
+
return `voicetools-${archStr}-pc-windows-msvc.zip`;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
111
133
|
filetools: {
|
|
112
134
|
name: "filetools",
|
|
113
135
|
repo: "kolisachint/filetools",
|
|
@@ -379,6 +401,7 @@ const TERMUX_PACKAGES = {
|
|
|
379
401
|
webtools: "webtools",
|
|
380
402
|
filetools: "filetools",
|
|
381
403
|
browsertools: "browsertools",
|
|
404
|
+
voicetools: "voicetools",
|
|
382
405
|
};
|
|
383
406
|
// Ensure a tool is available, downloading if necessary
|
|
384
407
|
// Returns the path to the tool, or null if unavailable
|