@oh-my-pi/pi-coding-agent 17.1.0 → 17.1.2

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 (196) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/cli.js +6325 -4420
  3. package/dist/types/cli/bench-cli.d.ts +1 -0
  4. package/dist/types/config/model-resolver.d.ts +6 -3
  5. package/dist/types/config/settings-schema.d.ts +62 -0
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +3 -0
  7. package/dist/types/launch/broker-list-order.test.d.ts +1 -0
  8. package/dist/types/live/attestation.d.ts +2 -0
  9. package/dist/types/live/controller.d.ts +10 -2
  10. package/dist/types/live/protocol.d.ts +1 -1
  11. package/dist/types/live/transport.d.ts +6 -19
  12. package/dist/types/live/visualizer.d.ts +8 -11
  13. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  14. package/dist/types/modes/components/custom-message.d.ts +1 -1
  15. package/dist/types/modes/components/message-frame.d.ts +8 -4
  16. package/dist/types/modes/components/session-account-selector.d.ts +11 -0
  17. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  18. package/dist/types/modes/interactive-mode.d.ts +3 -6
  19. package/dist/types/modes/types.d.ts +9 -5
  20. package/dist/types/modes/utils/ui-helpers.d.ts +1 -6
  21. package/dist/types/session/agent-session-types.d.ts +8 -1
  22. package/dist/types/session/agent-session.d.ts +20 -1
  23. package/dist/types/session/auth-storage.d.ts +1 -1
  24. package/dist/types/session/eval-runner.d.ts +2 -0
  25. package/dist/types/session/messages.d.ts +2 -0
  26. package/dist/types/session/session-tools.d.ts +15 -0
  27. package/dist/types/session/streaming-output.d.ts +8 -0
  28. package/dist/types/slash-commands/helpers/session-pin.d.ts +9 -0
  29. package/dist/types/stt/index.d.ts +0 -2
  30. package/dist/types/stt/stt-controller.d.ts +7 -0
  31. package/dist/types/task/executor.d.ts +3 -1
  32. package/dist/types/task/structured-subagent.d.ts +3 -0
  33. package/dist/types/task/types.d.ts +11 -11
  34. package/dist/types/thinking.d.ts +13 -0
  35. package/dist/types/tiny/title-client.d.ts +10 -0
  36. package/dist/types/tools/builtin-names.d.ts +1 -1
  37. package/dist/types/tools/computer/protocol.d.ts +43 -0
  38. package/dist/types/tools/computer/supervisor.d.ts +32 -0
  39. package/dist/types/tools/computer/worker-entry.d.ts +1 -0
  40. package/dist/types/tools/computer/worker.d.ts +15 -0
  41. package/dist/types/tools/computer-renderer.d.ts +22 -0
  42. package/dist/types/tools/computer.d.ts +71 -0
  43. package/dist/types/tools/context.d.ts +2 -0
  44. package/dist/types/tools/default-renderer.d.ts +21 -0
  45. package/dist/types/tools/eval-format/index.d.ts +7 -0
  46. package/dist/types/tools/eval-format/javascript.d.ts +2 -0
  47. package/dist/types/tools/eval-format/julia.d.ts +2 -0
  48. package/dist/types/tools/eval-format/python.d.ts +2 -0
  49. package/dist/types/tools/eval-format/ruby.d.ts +2 -0
  50. package/dist/types/tools/index.d.ts +2 -0
  51. package/dist/types/tools/resolve.d.ts +7 -0
  52. package/dist/types/tools/todo.d.ts +4 -1
  53. package/dist/types/tts/streaming-player.d.ts +10 -43
  54. package/dist/types/utils/tools-manager.d.ts +1 -2
  55. package/dist/types/web/search/providers/base.d.ts +16 -0
  56. package/dist/types/web/search/providers/brave.d.ts +2 -0
  57. package/dist/types/web/search/providers/firecrawl.d.ts +2 -0
  58. package/dist/types/web/search/providers/gemini.d.ts +3 -0
  59. package/dist/types/web/search/providers/jina.d.ts +2 -0
  60. package/dist/types/web/search/providers/kagi.d.ts +2 -0
  61. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  62. package/dist/types/web/search/providers/parallel.d.ts +2 -0
  63. package/dist/types/web/search/providers/perplexity.d.ts +3 -0
  64. package/dist/types/web/search/providers/searxng.d.ts +10 -0
  65. package/dist/types/web/search/providers/tavily.d.ts +8 -0
  66. package/dist/types/web/search/query.d.ts +190 -0
  67. package/package.json +12 -12
  68. package/src/cli/args.ts +1 -0
  69. package/src/cli/bench-cli.ts +12 -1
  70. package/src/cli/setup-cli.ts +2 -14
  71. package/src/cli/web-search-cli.ts +7 -0
  72. package/src/cli.ts +8 -0
  73. package/src/config/model-registry.ts +6 -0
  74. package/src/config/model-resolver.ts +17 -6
  75. package/src/config/settings-schema.ts +66 -0
  76. package/src/debug/raw-sse-buffer.ts +157 -30
  77. package/src/eval/executor-base.ts +1 -0
  78. package/src/eval/js/executor.ts +2 -0
  79. package/src/exec/bash-executor.ts +1 -0
  80. package/src/export/share.ts +4 -3
  81. package/src/extensibility/extensions/wrapper.ts +68 -12
  82. package/src/extensibility/legacy-pi-coding-agent-shim.ts +7 -1
  83. package/src/launch/broker-list-order.test.ts +89 -0
  84. package/src/launch/broker.ts +49 -8
  85. package/src/live/attestation.ts +91 -0
  86. package/src/live/controller.ts +76 -23
  87. package/src/live/protocol.test.ts +3 -3
  88. package/src/live/protocol.ts +1 -1
  89. package/src/live/transport.ts +72 -140
  90. package/src/live/visualizer.ts +114 -134
  91. package/src/modes/components/assistant-message.ts +7 -2
  92. package/src/modes/components/custom-message.ts +4 -1
  93. package/src/modes/components/message-frame.ts +14 -8
  94. package/src/modes/components/session-account-selector.ts +62 -0
  95. package/src/modes/components/tool-execution.ts +17 -110
  96. package/src/modes/controllers/input-controller.ts +55 -47
  97. package/src/modes/controllers/live-command-controller.ts +82 -5
  98. package/src/modes/controllers/selector-controller.ts +62 -0
  99. package/src/modes/interactive-mode.ts +79 -5
  100. package/src/modes/types.ts +10 -4
  101. package/src/modes/utils/ui-helpers.ts +7 -8
  102. package/src/prompts/system/computer-safety.md +14 -0
  103. package/src/prompts/system/resolve-device-reminder.md +1 -1
  104. package/src/prompts/system/workflow-notice.md +1 -1
  105. package/src/prompts/tools/ast-edit.md +1 -0
  106. package/src/prompts/tools/bash.md +1 -0
  107. package/src/prompts/tools/computer.md +26 -0
  108. package/src/prompts/tools/eval.md +2 -2
  109. package/src/prompts/tools/task.md +5 -2
  110. package/src/prompts/tools/web-search.md +2 -0
  111. package/src/sdk.ts +13 -4
  112. package/src/session/agent-session-types.ts +9 -0
  113. package/src/session/agent-session.ts +70 -0
  114. package/src/session/auth-storage.ts +1 -0
  115. package/src/session/eval-runner.ts +5 -0
  116. package/src/session/messages.ts +3 -0
  117. package/src/session/queued-messages.ts +7 -1
  118. package/src/session/session-tools.ts +37 -0
  119. package/src/session/streaming-output.ts +52 -5
  120. package/src/slash-commands/builtin-registry.ts +165 -9
  121. package/src/slash-commands/helpers/session-pin.ts +44 -0
  122. package/src/stt/downloader.ts +0 -2
  123. package/src/stt/index.ts +0 -2
  124. package/src/stt/stt-controller.ts +57 -146
  125. package/src/system-prompt.ts +4 -0
  126. package/src/task/executor.ts +17 -9
  127. package/src/task/index.ts +14 -34
  128. package/src/task/structured-subagent.ts +4 -0
  129. package/src/task/types.ts +15 -13
  130. package/src/thinking.ts +27 -0
  131. package/src/tiny/title-client.ts +22 -0
  132. package/src/tools/ast-edit.ts +4 -1
  133. package/src/tools/bash-interactive.ts +90 -86
  134. package/src/tools/bash.ts +13 -0
  135. package/src/tools/browser/render.ts +2 -1
  136. package/src/tools/builtin-names.ts +1 -0
  137. package/src/tools/computer/protocol.ts +28 -0
  138. package/src/tools/computer/supervisor.ts +258 -0
  139. package/src/tools/computer/worker-entry.ts +25 -0
  140. package/src/tools/computer/worker.ts +135 -0
  141. package/src/tools/computer-renderer.ts +108 -0
  142. package/src/tools/computer.ts +433 -0
  143. package/src/tools/context.ts +2 -0
  144. package/src/tools/default-renderer.ts +139 -0
  145. package/src/tools/essential-tools.ts +1 -0
  146. package/src/tools/eval-format/index.ts +24 -0
  147. package/src/tools/eval-format/javascript.ts +952 -0
  148. package/src/tools/eval-format/julia.ts +446 -0
  149. package/src/tools/eval-format/python.ts +544 -0
  150. package/src/tools/eval-format/ruby.ts +380 -0
  151. package/src/tools/eval-render.ts +12 -6
  152. package/src/tools/index.ts +5 -0
  153. package/src/tools/renderers.ts +2 -0
  154. package/src/tools/resolve.ts +10 -1
  155. package/src/tools/todo.ts +58 -6
  156. package/src/tools/xdev.ts +54 -26
  157. package/src/tts/streaming-player.ts +81 -340
  158. package/src/utils/clipboard.ts +1 -30
  159. package/src/utils/mac-file-urls.applescript +37 -0
  160. package/src/utils/tool-choice.ts +14 -0
  161. package/src/utils/tools-manager.ts +1 -19
  162. package/src/web/search/index.ts +28 -5
  163. package/src/web/search/providers/anthropic.ts +62 -4
  164. package/src/web/search/providers/base.ts +16 -0
  165. package/src/web/search/providers/brave.ts +30 -3
  166. package/src/web/search/providers/codex.ts +14 -1
  167. package/src/web/search/providers/duckduckgo.ts +23 -1
  168. package/src/web/search/providers/ecosia.ts +5 -1
  169. package/src/web/search/providers/exa.ts +28 -1
  170. package/src/web/search/providers/firecrawl.ts +37 -3
  171. package/src/web/search/providers/gemini.ts +12 -2
  172. package/src/web/search/providers/google.ts +2 -1
  173. package/src/web/search/providers/jina.ts +31 -8
  174. package/src/web/search/providers/kagi.ts +10 -1
  175. package/src/web/search/providers/kimi.ts +16 -1
  176. package/src/web/search/providers/mojeek.ts +14 -1
  177. package/src/web/search/providers/parallel.ts +48 -2
  178. package/src/web/search/providers/perplexity.ts +94 -6
  179. package/src/web/search/providers/searxng.ts +145 -9
  180. package/src/web/search/providers/startpage.ts +8 -2
  181. package/src/web/search/providers/synthetic.ts +7 -1
  182. package/src/web/search/providers/tavily.ts +52 -3
  183. package/src/web/search/providers/tinyfish.ts +6 -1
  184. package/src/web/search/providers/xai.ts +49 -2
  185. package/src/web/search/providers/zai.ts +19 -1
  186. package/src/web/search/query.ts +850 -0
  187. package/dist/types/stt/recorder.d.ts +0 -30
  188. package/dist/types/stt/transcriber.d.ts +0 -14
  189. package/dist/types/stt/wav.d.ts +0 -29
  190. package/dist/types/tts/player.d.ts +0 -32
  191. package/src/live/audio-worklet.txt +0 -59
  192. package/src/live/browser-runtime.txt +0 -221
  193. package/src/stt/recorder.ts +0 -551
  194. package/src/stt/transcriber.ts +0 -60
  195. package/src/stt/wav.ts +0 -173
  196. package/src/tts/player.ts +0 -137
@@ -1,551 +0,0 @@
1
- import * as fs from "node:fs/promises";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import { $which, logger, Snowflake } from "@oh-my-pi/pi-utils";
5
- import { $, type Subprocess } from "bun";
6
- import { ensureTool, getToolPath } from "../utils/tools-manager";
7
- import { decodePcmS16LE } from "./wav";
8
-
9
- export interface RecordingHandle {
10
- stop(): Promise<void>;
11
- }
12
-
13
- const isWindows = process.platform === "win32";
14
- const linuxFFmpegFormats = new Map<string, "pulse" | "alsa">();
15
- const ffmpegCaptureFlags = ["-hide_banner", "-loglevel", "error", "-nostats"];
16
-
17
- /**
18
- * Returns available recording tools in priority order.
19
- */
20
- export function detectRecordingTools(): string[] {
21
- return [...new Set(detectRecorders().map(recorder => recorder.tool))];
22
- }
23
-
24
- // ── ffmpeg dshow device detection ──────────────────────────────────
25
-
26
- async function detectWindowsAudioDevice(bin: string): Promise<string> {
27
- const result = await $`${bin} -f dshow -list_devices true -i dummy`.quiet().nothrow();
28
- const output = result.stderr.toString();
29
- const audioDevices: string[] = [];
30
- const re = /"([^"]+)"\s*\(audio\)/gi;
31
- for (const match of output.matchAll(re)) {
32
- audioDevices.push(match[1]);
33
- }
34
- if (audioDevices.length === 0) {
35
- throw new Error("No audio input device found via ffmpeg dshow. Ensure a microphone is connected.");
36
- }
37
- logger.debug("Detected dshow audio devices", { devices: audioDevices });
38
- return audioDevices[0];
39
- }
40
-
41
- async function ffmpegInputArgs(bin: string): Promise<string[]> {
42
- if (isWindows) {
43
- return ["-f", "dshow", "-i", `audio=${await detectWindowsAudioDevice(bin)}`];
44
- }
45
- if (process.platform === "darwin") {
46
- return ["-f", "avfoundation", "-i", ":default"];
47
- }
48
-
49
- let format = linuxFFmpegFormats.get(bin);
50
- if (!format) {
51
- const result = await $`${bin} -hide_banner -demuxers`.quiet().nothrow();
52
- if (result.exitCode !== 0) {
53
- const stderr = result.stderr.toString().trim();
54
- throw new Error(
55
- `Could not inspect ffmpeg input formats (code ${result.exitCode}): ${stderr || "(no output)"}`,
56
- );
57
- }
58
- const demuxers = result.stdout.toString();
59
- if (/^\s*D\s+(?:d\s+)?pulse(?:\s|$)/m.test(demuxers)) {
60
- format = "pulse";
61
- } else if (/^\s*D\s+(?:d\s+)?alsa(?:\s|$)/m.test(demuxers)) {
62
- format = "alsa";
63
- } else {
64
- throw new Error("ffmpeg supports neither PulseAudio nor ALSA input on Linux");
65
- }
66
- linuxFFmpegFormats.set(bin, format);
67
- }
68
- return ["-f", format, "-i", "default"];
69
- }
70
-
71
- // ── Recording implementations ──────────────────────────────────────
72
-
73
- async function startSoxRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
74
- // On Windows, "-d" (default device) often fails. Use "-t waveaudio 0" for the first input.
75
- const inputArgs = isWindows ? ["-t", "waveaudio", "0"] : ["-d"];
76
-
77
- const proc = Bun.spawn([bin, ...inputArgs, "-r", "16000", "-c", "1", "-b", "16", "-t", "wav", outputPath], {
78
- stdout: "pipe",
79
- stderr: "pipe",
80
- });
81
- await verifyProcessAlive(proc, "sox");
82
- return {
83
- async stop() {
84
- proc.kill("SIGTERM");
85
- await proc.exited;
86
- },
87
- };
88
- }
89
-
90
- async function startFFmpegRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
91
- const args = [
92
- bin,
93
- ...ffmpegCaptureFlags,
94
- ...(await ffmpegInputArgs(bin)),
95
- "-ar",
96
- "16000",
97
- "-ac",
98
- "1",
99
- "-sample_fmt",
100
- "s16",
101
- "-y",
102
- outputPath,
103
- ];
104
-
105
- const proc = Bun.spawn(args, {
106
- stdin: "pipe",
107
- stdout: "pipe",
108
- stderr: "pipe",
109
- });
110
- await verifyProcessAlive(proc, "ffmpeg");
111
-
112
- return {
113
- async stop() {
114
- try {
115
- proc.stdin.write("q");
116
- proc.stdin.end();
117
- } catch {
118
- // stdin may already be closed
119
- }
120
- const killTimer = setTimeout(() => proc.kill(), 3000);
121
- await proc.exited;
122
- clearTimeout(killTimer);
123
- },
124
- };
125
- }
126
-
127
- async function startArecordRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
128
- const proc = Bun.spawn([bin, "-f", "S16_LE", "-r", "16000", "-c", "1", outputPath], {
129
- stdout: "pipe",
130
- stderr: "pipe",
131
- });
132
- await verifyProcessAlive(proc, "arecord");
133
- return {
134
- async stop() {
135
- proc.kill("SIGTERM");
136
- await proc.exited;
137
- },
138
- };
139
- }
140
-
141
- // ── PowerShell mci recorder (Windows zero-dep fallback) ────────────
142
-
143
- const PS_RECORD_SCRIPT = `
144
- param([string]$outPath)
145
-
146
- if ($outPath -match '["\r\n]') {
147
- [Console]::Error.WriteLine("Invalid output path: $outPath")
148
- exit 1
149
- }
150
-
151
-
152
- Add-Type @"
153
- using System;
154
- using System.Runtime.InteropServices;
155
- using System.Text;
156
- public class MciAudio {
157
- [DllImport("winmm.dll", CharSet=CharSet.Auto)]
158
- public static extern int mciSendString(
159
- string command, StringBuilder buffer, int bufferSize, IntPtr callback);
160
- }
161
- "@
162
-
163
- function Mci([string]$cmd) {
164
- $buf = New-Object System.Text.StringBuilder 256
165
- $r = [MciAudio]::mciSendString($cmd, $buf, 256, [IntPtr]::Zero)
166
- if ($r -ne 0) {
167
- [Console]::Error.WriteLine("MCI error $r for: $cmd")
168
- }
169
- return $r
170
- }
171
-
172
- $r = Mci "open new type waveaudio alias omp_rec"
173
- if ($r -ne 0) { exit 1 }
174
-
175
- Mci "set omp_rec channels 1 samplespersec 16000 bitspersample 16"
176
-
177
- $r = Mci "record omp_rec"
178
- if ($r -ne 0) {
179
- Mci "close omp_rec"
180
- exit 1
181
- }
182
-
183
- Write-Output "RECORDING"
184
- [Console]::Out.Flush()
185
-
186
- # Block until parent closes stdin or writes a line
187
- try { [Console]::In.ReadLine() | Out-Null } catch {}
188
-
189
- # Stop and save
190
- Mci "stop omp_rec"
191
- $saveCmd = 'save omp_rec "' + $outPath + '"'
192
- $r = Mci $saveCmd
193
- if ($r -ne 0) {
194
- [Console]::Error.WriteLine("Save failed for: $saveCmd")
195
- }
196
- Mci "close omp_rec"
197
-
198
- if (Test-Path $outPath) {
199
- Write-Output "SAVED"
200
- } else {
201
- Write-Error "Output file was not created: $outPath"
202
- exit 1
203
- }
204
- `;
205
-
206
- async function startPowerShellRecording(outputPath: string): Promise<RecordingHandle> {
207
- // Write script to temp file — avoids quoting/escaping issues with -Command
208
- const scriptPath = path.join(os.tmpdir(), `omp-stt-record-${Snowflake.next()}.ps1`);
209
- await Bun.write(scriptPath, PS_RECORD_SCRIPT);
210
-
211
- const proc = Bun.spawn(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath, outputPath], {
212
- stdin: "pipe",
213
- stdout: "pipe",
214
- stderr: "ignore",
215
- });
216
-
217
- proc.exited.then(() => {
218
- fs.unlink(scriptPath).catch(() => {});
219
- });
220
-
221
- // Wait for "RECORDING" on stdout to confirm it started
222
- const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
223
- const decoder = new TextDecoder();
224
- let output = "";
225
- const deadline = Date.now() + 8000; // PowerShell + Add-Type is slow
226
-
227
- while (Date.now() < deadline) {
228
- const readPromise = reader.read();
229
- const timeoutPromise = Bun.sleep(deadline - Date.now()).then(() => ({ done: true, value: undefined }));
230
- const { done, value } = await Promise.race([readPromise, timeoutPromise]);
231
- if (done || !value) break;
232
- output += decoder.decode(value, { stream: true });
233
- if (output.includes("RECORDING")) break;
234
- }
235
- reader.releaseLock();
236
-
237
- if (!output.includes("RECORDING")) {
238
- proc.kill();
239
- await proc.exited;
240
- let stderrText = "";
241
- if (proc.stderr && typeof proc.stderr !== "number") {
242
- stderrText = await new Response(proc.stderr as ReadableStream).text();
243
- }
244
- // Clean up temp script
245
- fs.unlink(scriptPath).catch(() => {});
246
- throw new Error(
247
- `PowerShell audio recording failed to start: ${stderrText.trim() || output.trim() || "(no output)"}`,
248
- );
249
- }
250
-
251
- return {
252
- async stop() {
253
- try {
254
- proc.stdin.write("stop\n");
255
- proc.stdin.end();
256
- } catch {
257
- // stdin may already be closed
258
- }
259
- // Give PowerShell time to save the file
260
- const killTimer = setTimeout(() => proc.kill(), 8000);
261
- await proc.exited;
262
- clearTimeout(killTimer);
263
- // Clean up temp script
264
- fs.unlink(scriptPath).catch(() => {});
265
- },
266
- };
267
- }
268
-
269
- // ── Health check ───────────────────────────────────────────────────
270
-
271
- type RecorderProcess = Subprocess<"ignore" | "pipe", "pipe", "pipe">;
272
-
273
- async function verifyProcessAlive(proc: RecorderProcess, tool: string): Promise<void> {
274
- await Bun.sleep(300);
275
-
276
- const exited = await Promise.race([proc.exited.then(code => code), Bun.sleep(0).then(() => "running" as const)]);
277
- if (exited === "running") {
278
- void proc.stderr.pipeTo(new WritableStream<Uint8Array>()).catch(() => {});
279
- return;
280
- }
281
-
282
- const stderr = await new Response(proc.stderr).text();
283
- throw new Error(`${tool} exited immediately (code ${exited}): ${stderr.trim() || "(no output)"}`);
284
- }
285
-
286
- // ── Public API ─────────────────────────────────────────────────────
287
-
288
- export interface ResolvedRecorder {
289
- tool: "sox" | "ffmpeg" | "arecord" | "powershell";
290
- bin: string;
291
- }
292
-
293
- /**
294
- * Resolve a usable recorder without triggering any download. Priority:
295
- * sox (PATH) → ffmpeg (PATH or previously-downloaded static binary) →
296
- * arecord (PATH, non-Windows) → PowerShell mci fallback (Windows) → none.
297
- */
298
- function detectRecorders(): ResolvedRecorder[] {
299
- const recorders: ResolvedRecorder[] = [];
300
- const sox = $which("sox");
301
- if (sox) recorders.push({ tool: "sox", bin: sox });
302
-
303
- const pathFfmpeg = $which("ffmpeg");
304
- if (pathFfmpeg) recorders.push({ tool: "ffmpeg", bin: pathFfmpeg });
305
- const bundledFfmpeg = getToolPath("ffmpeg");
306
- if (bundledFfmpeg && bundledFfmpeg !== pathFfmpeg) recorders.push({ tool: "ffmpeg", bin: bundledFfmpeg });
307
-
308
- if (!isWindows) {
309
- const arecord = $which("arecord");
310
- if (arecord) recorders.push({ tool: "arecord", bin: arecord });
311
- }
312
-
313
- if (isWindows) recorders.push({ tool: "powershell", bin: "powershell" });
314
- return recorders;
315
- }
316
-
317
- export function detectRecorder(): ResolvedRecorder | null {
318
- return detectRecorders()[0] ?? null;
319
- }
320
-
321
- /**
322
- * Ensure a recorder is available, downloading the static ffmpeg binary when
323
- * nothing is already present. Returns the resolved recorder.
324
- */
325
- export async function ensureRecorder(
326
- onProgress?: (p: { stage: string; percent?: number }) => void,
327
- signal?: AbortSignal,
328
- ): Promise<ResolvedRecorder> {
329
- const existing = detectRecorder();
330
- if (existing) return existing;
331
-
332
- const bin = await ensureTool("ffmpeg", { signal, notify: m => onProgress?.({ stage: m }) });
333
- if (bin) return { tool: "ffmpeg", bin };
334
-
335
- if (isWindows) return { tool: "powershell", bin: "powershell" };
336
-
337
- throw new Error(
338
- "No audio recorder available and automatic ffmpeg download failed. " +
339
- "Install SoX or FFmpeg manually and add it to PATH.",
340
- );
341
- }
342
-
343
- function recorderFailure(recorder: ResolvedRecorder, error: unknown): string {
344
- const message = error instanceof Error ? error.message : String(error);
345
- return `${recorder.tool} (${recorder.bin}): ${message}`;
346
- }
347
-
348
- async function startRecordingWithRecorder(recorder: ResolvedRecorder, outputPath: string): Promise<RecordingHandle> {
349
- logger.debug("Starting audio recording", { tool: recorder.tool, bin: recorder.bin, outputPath });
350
- switch (recorder.tool) {
351
- case "sox":
352
- return startSoxRecording(recorder.bin, outputPath);
353
- case "ffmpeg":
354
- return startFFmpegRecording(recorder.bin, outputPath);
355
- case "arecord":
356
- return startArecordRecording(recorder.bin, outputPath);
357
- case "powershell":
358
- return startPowerShellRecording(outputPath);
359
- }
360
- }
361
-
362
- export async function startRecording(outputPath: string): Promise<RecordingHandle> {
363
- const recorders = detectRecorders();
364
- if (recorders.length === 0) {
365
- throw new Error("No audio recorder available — run `omp setup speech`");
366
- }
367
-
368
- const failures: string[] = [];
369
- for (const recorder of recorders) {
370
- try {
371
- return await startRecordingWithRecorder(recorder, outputPath);
372
- } catch (error) {
373
- const failure = recorderFailure(recorder, error);
374
- failures.push(failure);
375
- logger.warn("STT recorder failed to start; trying fallback", {
376
- recorder: recorder.tool,
377
- bin: recorder.bin,
378
- error: failure,
379
- });
380
- }
381
- }
382
- throw new Error(`No audio recorder could start — run \`omp setup speech\`.\n${failures.join("\n")}`);
383
- }
384
-
385
- /**
386
- * Verify a recorded audio file is usable.
387
- * Returns the file size in bytes, or throws.
388
- */
389
- export async function verifyRecordingFile(filePath: string): Promise<number> {
390
- try {
391
- const stat = await fs.stat(filePath);
392
- if (stat.size < 100) {
393
- throw new Error(
394
- `Recording file is too small (${stat.size} bytes) — audio may not have been captured. ` +
395
- "Check that a microphone is connected and permissions are granted.",
396
- );
397
- }
398
- return stat.size;
399
- } catch (err) {
400
- if (err instanceof Error && err.message.includes("too small")) throw err;
401
- throw new Error(
402
- "Recording file was not created. The recording process may have failed silently. " +
403
- "Check that a microphone is connected.",
404
- );
405
- }
406
- }
407
-
408
- // ── Streaming (live) capture ───────────────────────────────────────
409
-
410
- export interface StreamingRecordingHandle {
411
- stop(): Promise<void>;
412
- }
413
-
414
- /** Build the argv for a recorder that emits raw 16 kHz mono s16le PCM to stdout. */
415
- async function streamingRecorderArgs(recorder: ResolvedRecorder): Promise<string[]> {
416
- const { tool, bin } = recorder;
417
- switch (tool) {
418
- case "sox": {
419
- const input = isWindows ? ["-t", "waveaudio", "0"] : ["-d"];
420
- return [bin, ...input, "-r", "16000", "-c", "1", "-b", "16", "-e", "signed-integer", "-t", "raw", "-"];
421
- }
422
- case "arecord":
423
- return [bin, "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw", "-"];
424
- case "ffmpeg": {
425
- return [
426
- bin,
427
- ...ffmpegCaptureFlags,
428
- ...(await ffmpegInputArgs(bin)),
429
- "-ar",
430
- "16000",
431
- "-ac",
432
- "1",
433
- "-f",
434
- "s16le",
435
- "pipe:1",
436
- ];
437
- }
438
- case "powershell":
439
- throw new Error("PowerShell recorder cannot stream PCM to a pipe");
440
- }
441
- }
442
-
443
- /**
444
- * Start a recorder that streams raw 16 kHz mono s16le PCM to stdout, decoding it
445
- * to float frames delivered through `onAudio` as they arrive. Returns `null`
446
- * when the only available recorder (Windows PowerShell mci) records to a file
447
- * and cannot pipe — the caller then falls back to file-based batch capture.
448
- */
449
- async function startStreamingRecordingWithRecorder(
450
- recorder: ResolvedRecorder,
451
- onAudio: (samples: Float32Array) => void,
452
- ): Promise<StreamingRecordingHandle> {
453
- const args = await streamingRecorderArgs(recorder);
454
- logger.debug("Starting streaming audio recording", { tool: recorder.tool, bin: recorder.bin });
455
- const proc = Bun.spawn(args, { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
456
-
457
- // Read s16le bytes off stdout, carrying any trailing odd byte across chunk
458
- // boundaries so a sample is never split. Runs until the process closes stdout.
459
- const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
460
- let leftover: Uint8Array | null = null;
461
- const pump = async (): Promise<void> => {
462
- try {
463
- for (;;) {
464
- const { done, value } = await reader.read();
465
- if (done) break;
466
- if (!value || value.length === 0) continue;
467
- let bytes = value;
468
- if (leftover) {
469
- const merged = new Uint8Array(leftover.length + value.length);
470
- merged.set(leftover, 0);
471
- merged.set(value, leftover.length);
472
- bytes = merged;
473
- leftover = null;
474
- }
475
- const usable = bytes.length - (bytes.length % 2);
476
- if (usable < bytes.length) leftover = bytes.slice(usable);
477
- if (usable > 0) onAudio(decodePcmS16LE(bytes.subarray(0, usable)));
478
- }
479
- } catch (error) {
480
- logger.debug("stt: streaming recorder read ended", {
481
- error: error instanceof Error ? error.message : String(error),
482
- });
483
- }
484
- };
485
- void pump();
486
-
487
- try {
488
- await verifyProcessAlive(proc, recorder.tool);
489
- } catch (error) {
490
- try {
491
- proc.kill("SIGKILL");
492
- } catch {
493
- // Already gone.
494
- }
495
- throw error;
496
- }
497
-
498
- let stopped = false;
499
- return {
500
- async stop() {
501
- if (stopped) return;
502
- stopped = true;
503
- if (recorder.tool === "ffmpeg") {
504
- try {
505
- proc.stdin.write("q");
506
- proc.stdin.end();
507
- } catch {
508
- // stdin may already be closed.
509
- }
510
- const killTimer = setTimeout(() => proc.kill(), 3000);
511
- await proc.exited;
512
- clearTimeout(killTimer);
513
- } else {
514
- proc.kill("SIGTERM");
515
- await proc.exited;
516
- }
517
- try {
518
- await reader.cancel();
519
- } catch {
520
- // Reader already released when stdout closed.
521
- }
522
- },
523
- };
524
- }
525
-
526
- export async function startStreamingRecording(
527
- onAudio: (samples: Float32Array) => void,
528
- ): Promise<StreamingRecordingHandle | null> {
529
- const recorders = detectRecorders();
530
- if (recorders.length === 0) {
531
- throw new Error("No audio recorder available — run `omp setup speech`");
532
- }
533
- const streamingRecorders = recorders.filter(recorder => recorder.tool !== "powershell");
534
- if (streamingRecorders.length === 0) return null;
535
-
536
- const failures: string[] = [];
537
- for (const recorder of streamingRecorders) {
538
- try {
539
- return await startStreamingRecordingWithRecorder(recorder, onAudio);
540
- } catch (error) {
541
- const failure = recorderFailure(recorder, error);
542
- failures.push(failure);
543
- logger.warn("STT streaming recorder failed to start; trying fallback", {
544
- recorder: recorder.tool,
545
- bin: recorder.bin,
546
- error: failure,
547
- });
548
- }
549
- }
550
- throw new Error(`No streaming audio recorder could start.\n${failures.join("\n")}`);
551
- }
@@ -1,60 +0,0 @@
1
- import { logger } from "@oh-my-pi/pi-utils";
2
- import { sttClient } from "./asr-client";
3
- import { resolveSttModelSpec } from "./models";
4
- import { decodeWavToMono16k } from "./wav";
5
-
6
- export interface TranscribeOptions {
7
- modelName?: string;
8
- language?: string;
9
- signal?: AbortSignal;
10
- }
11
-
12
- const TRANSCRIBE_TIMEOUT_MS = 120_000;
13
-
14
- /**
15
- * Transcribe a WAV file using the local ONNX Whisper worker.
16
- *
17
- * Decodes the WAV to a 16 kHz mono Float32Array in-process (no Python, no
18
- * ffmpeg) and routes it to the warm speech worker, which keeps the model loaded
19
- * across calls. Honors `options.signal` (abort) and applies an internal timeout
20
- * with the same semantics as the previous Python path.
21
- */
22
- export async function transcribe(audioPath: string, options?: TranscribeOptions): Promise<string> {
23
- const audioFile = Bun.file(audioPath);
24
- if (audioFile.size < 100) {
25
- throw new Error(`Audio file is empty or too small (${audioFile.size} bytes). Check microphone.`);
26
- }
27
- options?.signal?.throwIfAborted();
28
-
29
- const spec = resolveSttModelSpec(options?.modelName);
30
- const language = options?.language || undefined;
31
- const audio = decodeWavToMono16k(await audioFile.arrayBuffer());
32
- if (audio.length === 0) return "";
33
-
34
- logger.debug("Transcribing with local ONNX whisper", {
35
- audioPath,
36
- modelKey: spec.key,
37
- repo: spec.repo,
38
- language,
39
- samples: audio.length,
40
- });
41
-
42
- // Bound runaway inference. Abort the request on timeout; the warm worker
43
- // keeps the model loaded (the request promise just rejects).
44
- const timeout = new AbortController();
45
- const timer = setTimeout(() => timeout.abort(), TRANSCRIBE_TIMEOUT_MS);
46
- const signal = options?.signal ? AbortSignal.any([options.signal, timeout.signal]) : timeout.signal;
47
- try {
48
- const text = (await sttClient.transcribe(spec.key, audio, { language, signal })).trim();
49
- logger.debug("Transcription complete", { length: text.length });
50
- return text;
51
- } catch (error) {
52
- if (timeout.signal.aborted && !options?.signal?.aborted) {
53
- logger.error("Local whisper transcription timed out", { timeoutMs: TRANSCRIBE_TIMEOUT_MS });
54
- throw new Error(`Transcription timed out after ${Math.round(TRANSCRIBE_TIMEOUT_MS / 1000)}s`);
55
- }
56
- throw error;
57
- } finally {
58
- clearTimeout(timer);
59
- }
60
- }