@ouro.bot/cli 0.1.0-alpha.560 → 0.1.0-alpha.562

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.
@@ -0,0 +1,462 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.TWILIO_PHONE_WEBHOOK_BASE_PATH = exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS = exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS = exports.DEFAULT_TWILIO_PHONE_PORT = void 0;
37
+ exports.computeTwilioSignature = computeTwilioSignature;
38
+ exports.validateTwilioSignature = validateTwilioSignature;
39
+ exports.twilioRecordingMediaUrl = twilioRecordingMediaUrl;
40
+ exports.defaultTwilioRecordingDownloader = defaultTwilioRecordingDownloader;
41
+ exports.createTwilioPhoneBridge = createTwilioPhoneBridge;
42
+ exports.startTwilioPhoneBridgeServer = startTwilioPhoneBridgeServer;
43
+ const crypto = __importStar(require("node:crypto"));
44
+ const fs = __importStar(require("fs/promises"));
45
+ const http = __importStar(require("http"));
46
+ const path = __importStar(require("path"));
47
+ const runtime_1 = require("../../nerves/runtime");
48
+ const playback_1 = require("./playback");
49
+ const turn_1 = require("./turn");
50
+ exports.DEFAULT_TWILIO_PHONE_PORT = 18910;
51
+ exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS = 2;
52
+ exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS = 30;
53
+ exports.TWILIO_PHONE_WEBHOOK_BASE_PATH = "/voice/twilio";
54
+ function bodyText(body) {
55
+ if (body === undefined)
56
+ return "";
57
+ if (typeof body === "string")
58
+ return body;
59
+ return Buffer.from(body).toString("utf8");
60
+ }
61
+ function formParams(rawBody) {
62
+ const parsed = new URLSearchParams(rawBody);
63
+ const params = {};
64
+ for (const [key, value] of parsed) {
65
+ params[key] = value;
66
+ }
67
+ return params;
68
+ }
69
+ function headerValue(headers, name) {
70
+ const wanted = name.toLowerCase();
71
+ for (const [key, value] of Object.entries(headers)) {
72
+ if (key.toLowerCase() !== wanted)
73
+ continue;
74
+ if (Array.isArray(value))
75
+ return value[0] ?? "";
76
+ return value ?? "";
77
+ }
78
+ return "";
79
+ }
80
+ function xmlResponse(body) {
81
+ return {
82
+ statusCode: 200,
83
+ headers: { "content-type": "text/xml; charset=utf-8" },
84
+ body: `<?xml version="1.0" encoding="UTF-8"?><Response>${body}</Response>`,
85
+ };
86
+ }
87
+ function textResponse(statusCode, body) {
88
+ return {
89
+ statusCode,
90
+ headers: { "content-type": "text/plain; charset=utf-8" },
91
+ body,
92
+ };
93
+ }
94
+ function binaryResponse(body, contentType) {
95
+ return {
96
+ statusCode: 200,
97
+ headers: {
98
+ "content-type": contentType,
99
+ "cache-control": "private, max-age=300",
100
+ },
101
+ body,
102
+ };
103
+ }
104
+ function escapeXml(input) {
105
+ return input
106
+ .replace(/&/g, "&amp;")
107
+ .replace(/</g, "&lt;")
108
+ .replace(/>/g, "&gt;")
109
+ .replace(/"/g, "&quot;")
110
+ .replace(/'/g, "&apos;");
111
+ }
112
+ function routeUrl(publicBaseUrl, route) {
113
+ return new URL(route, publicBaseUrl).toString();
114
+ }
115
+ function requestPublicUrl(publicBaseUrl, requestPath) {
116
+ return routeUrl(publicBaseUrl, requestPath);
117
+ }
118
+ function recordTwiml(options) {
119
+ return `<Record action="${escapeXml(routeUrl(options.publicBaseUrl, `${exports.TWILIO_PHONE_WEBHOOK_BASE_PATH}/recording`))}" method="POST" playBeep="false" timeout="${options.timeoutSeconds}" maxLength="${options.maxLengthSeconds}" trim="trim-silence" />`;
120
+ }
121
+ function redirectTwiml(publicBaseUrl) {
122
+ return `<Redirect method="POST">${escapeXml(routeUrl(publicBaseUrl, `${exports.TWILIO_PHONE_WEBHOOK_BASE_PATH}/listen`))}</Redirect>`;
123
+ }
124
+ function sayTwiml(message) {
125
+ return `<Say>${escapeXml(message)}</Say>`;
126
+ }
127
+ function playTwiml(url) {
128
+ return `<Play>${escapeXml(url)}</Play>`;
129
+ }
130
+ function safeSegment(input) {
131
+ const cleaned = input.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
132
+ return cleaned || "unknown";
133
+ }
134
+ function decodeSafeSegment(input) {
135
+ try {
136
+ const decoded = decodeURIComponent(input);
137
+ if (!/^[A-Za-z0-9._-]+$/.test(decoded))
138
+ return null;
139
+ if (decoded === "." || decoded === "..")
140
+ return null;
141
+ return decoded;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ function contentTypeForAudio(fileName) {
148
+ const ext = path.extname(fileName).toLowerCase();
149
+ if (ext === ".mp3")
150
+ return "audio/mpeg";
151
+ if (ext === ".wav")
152
+ return "audio/wav";
153
+ if (ext === ".pcm")
154
+ return "audio/pcm";
155
+ return "application/octet-stream";
156
+ }
157
+ function friendIdFromCaller(from, callSid) {
158
+ const phoneish = from.replace(/[^0-9A-Za-z]+/g, "");
159
+ return phoneish ? `twilio-${phoneish}` : `twilio-${safeSegment(callSid)}`;
160
+ }
161
+ function parseRecordingParams(params) {
162
+ const callSid = params.CallSid?.trim();
163
+ const recordingSid = params.RecordingSid?.trim();
164
+ const recordingUrl = params.RecordingUrl?.trim();
165
+ if (!callSid || !recordingSid || !recordingUrl)
166
+ return null;
167
+ return {
168
+ callSid,
169
+ recordingSid,
170
+ recordingUrl,
171
+ from: params.From?.trim() ?? "",
172
+ };
173
+ }
174
+ function recordAgainResponse(publicBaseUrl, message) {
175
+ return xmlResponse(`${sayTwiml(message)}${recordTwiml({
176
+ publicBaseUrl,
177
+ timeoutSeconds: exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS,
178
+ maxLengthSeconds: exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS,
179
+ })}`);
180
+ }
181
+ function errorMessage(error) {
182
+ return error instanceof Error ? error.message : String(error);
183
+ }
184
+ function computeTwilioSignature(input) {
185
+ const payload = input.url + Object.keys(input.params)
186
+ .sort()
187
+ .map((key) => `${key}${input.params[key]}`)
188
+ .join("");
189
+ return crypto.createHmac("sha1", input.authToken).update(payload).digest("base64");
190
+ }
191
+ function validateTwilioSignature(input) {
192
+ if (!input.authToken.trim())
193
+ return true;
194
+ if (!input.signature.trim())
195
+ return false;
196
+ const expected = Buffer.from(computeTwilioSignature(input));
197
+ const actual = Buffer.from(input.signature);
198
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
199
+ }
200
+ function twilioRecordingMediaUrl(recordingUrl) {
201
+ const url = new URL(recordingUrl);
202
+ if (!/\.[A-Za-z0-9]+$/.test(url.pathname)) {
203
+ url.pathname = `${url.pathname}.wav`;
204
+ }
205
+ return url.toString();
206
+ }
207
+ async function defaultTwilioRecordingDownloader(request) {
208
+ const headers = {};
209
+ if (request.accountSid && request.authToken) {
210
+ headers.Authorization = `Basic ${Buffer.from(`${request.accountSid}:${request.authToken}`).toString("base64")}`;
211
+ }
212
+ const response = await fetch(request.recordingUrl, { headers });
213
+ if (!response.ok) {
214
+ throw new Error(`Twilio recording download failed: ${response.status} ${response.statusText}`.trim());
215
+ }
216
+ return Buffer.from(await response.arrayBuffer());
217
+ }
218
+ function verifyRequest(options, request, params) {
219
+ const authToken = options.twilioAuthToken?.trim();
220
+ if (!authToken)
221
+ return true;
222
+ return validateTwilioSignature({
223
+ authToken,
224
+ url: requestPublicUrl(options.publicBaseUrl, request.path),
225
+ params,
226
+ signature: headerValue(request.headers, "x-twilio-signature"),
227
+ });
228
+ }
229
+ async function handleIncoming(options) {
230
+ const greeting = options.greetingText ?? "Connected to Ouro voice. Speak after the prompt.";
231
+ (0, runtime_1.emitNervesEvent)({
232
+ component: "senses",
233
+ event: "senses.voice_twilio_incoming",
234
+ message: "Twilio voice call connected",
235
+ meta: { agentName: options.agentName },
236
+ });
237
+ return xmlResponse(`${sayTwiml(greeting)}${recordTwiml({
238
+ publicBaseUrl: options.publicBaseUrl,
239
+ timeoutSeconds: options.recordTimeoutSeconds ?? exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS,
240
+ maxLengthSeconds: options.recordMaxLengthSeconds ?? exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS,
241
+ })}`);
242
+ }
243
+ async function handleListen(options) {
244
+ return xmlResponse(recordTwiml({
245
+ publicBaseUrl: options.publicBaseUrl,
246
+ timeoutSeconds: options.recordTimeoutSeconds ?? exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS,
247
+ maxLengthSeconds: options.recordMaxLengthSeconds ?? exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS,
248
+ }));
249
+ }
250
+ async function handleRecording(options, params) {
251
+ const recording = parseRecordingParams(params);
252
+ if (!recording) {
253
+ (0, runtime_1.emitNervesEvent)({
254
+ level: "warn",
255
+ component: "senses",
256
+ event: "senses.voice_twilio_recording_rejected",
257
+ message: "Twilio recording callback was missing required fields",
258
+ meta: { agentName: options.agentName },
259
+ });
260
+ return recordAgainResponse(options.publicBaseUrl, "I did not receive audio. Please try again.");
261
+ }
262
+ const safeCallSid = safeSegment(recording.callSid);
263
+ const safeRecordingSid = safeSegment(recording.recordingSid);
264
+ const callDir = path.join(options.outputDir, safeCallSid);
265
+ const inputPath = path.join(callDir, `${safeRecordingSid}.wav`);
266
+ const utteranceId = `twilio-${safeCallSid}-${safeRecordingSid}`;
267
+ const downloadRecording = options.downloadRecording ?? defaultTwilioRecordingDownloader;
268
+ (0, runtime_1.emitNervesEvent)({
269
+ component: "senses",
270
+ event: "senses.voice_twilio_turn_start",
271
+ message: "starting Twilio voice turn",
272
+ meta: { agentName: options.agentName, callSid: safeCallSid, recordingSid: safeRecordingSid },
273
+ });
274
+ try {
275
+ await fs.mkdir(callDir, { recursive: true });
276
+ const mediaUrl = twilioRecordingMediaUrl(recording.recordingUrl);
277
+ const audio = await downloadRecording({
278
+ recordingUrl: mediaUrl,
279
+ accountSid: options.twilioAccountSid?.trim() || undefined,
280
+ authToken: options.twilioAuthToken?.trim() || undefined,
281
+ });
282
+ await fs.writeFile(inputPath, audio);
283
+ const transcript = await options.transcriber.transcribe({
284
+ utteranceId,
285
+ audioPath: inputPath,
286
+ });
287
+ const turn = await (0, turn_1.runVoiceLoopbackTurn)({
288
+ agentName: options.agentName,
289
+ friendId: options.defaultFriendId?.trim() || friendIdFromCaller(recording.from, recording.callSid),
290
+ sessionKey: `twilio-${safeCallSid}`,
291
+ transcript,
292
+ tts: options.tts,
293
+ runSenseTurn: options.runSenseTurn,
294
+ });
295
+ if (turn.tts.status !== "delivered") {
296
+ return xmlResponse(`${sayTwiml("voice output failed after the text response was captured.")}${redirectTwiml(options.publicBaseUrl)}`);
297
+ }
298
+ const playback = await (0, playback_1.writeVoicePlaybackArtifact)({
299
+ utteranceId,
300
+ delivery: turn.tts,
301
+ outputDir: callDir,
302
+ });
303
+ const audioUrl = routeUrl(options.publicBaseUrl, `${exports.TWILIO_PHONE_WEBHOOK_BASE_PATH}/audio/${encodeURIComponent(safeCallSid)}/${encodeURIComponent(path.basename(playback.audioPath))}`);
304
+ (0, runtime_1.emitNervesEvent)({
305
+ component: "senses",
306
+ event: "senses.voice_twilio_turn_end",
307
+ message: "finished Twilio voice turn",
308
+ meta: { agentName: options.agentName, callSid: safeCallSid, recordingSid: safeRecordingSid, audioPath: playback.audioPath },
309
+ });
310
+ return xmlResponse(`${playTwiml(audioUrl)}${redirectTwiml(options.publicBaseUrl)}`);
311
+ }
312
+ catch (error) {
313
+ (0, runtime_1.emitNervesEvent)({
314
+ level: "error",
315
+ component: "senses",
316
+ event: "senses.voice_twilio_turn_error",
317
+ message: "Twilio voice turn failed",
318
+ meta: {
319
+ agentName: options.agentName,
320
+ callSid: safeCallSid,
321
+ recordingSid: safeRecordingSid,
322
+ error: errorMessage(error),
323
+ },
324
+ });
325
+ return xmlResponse(`${sayTwiml("I could not process that audio. Please try again.")}${redirectTwiml(options.publicBaseUrl)}`);
326
+ }
327
+ }
328
+ async function handleAudio(options, requestPath) {
329
+ const prefix = `${exports.TWILIO_PHONE_WEBHOOK_BASE_PATH}/audio/`;
330
+ const pathOnly = requestPath.split("?")[0];
331
+ const rest = pathOnly.slice(prefix.length);
332
+ const parts = rest.split("/");
333
+ if (parts.length !== 2)
334
+ return textResponse(404, "not found");
335
+ const [callSidPart, fileNamePart] = parts;
336
+ const callSid = decodeSafeSegment(callSidPart);
337
+ const fileName = decodeSafeSegment(fileNamePart);
338
+ if (!callSid || !fileName)
339
+ return textResponse(404, "not found");
340
+ const baseDir = path.resolve(options.outputDir, callSid);
341
+ const audioPath = path.resolve(baseDir, fileName);
342
+ try {
343
+ const audio = await fs.readFile(audioPath);
344
+ (0, runtime_1.emitNervesEvent)({
345
+ component: "senses",
346
+ event: "senses.voice_twilio_audio_served",
347
+ message: "served Twilio voice audio artifact",
348
+ meta: { agentName: options.agentName, callSid, fileName },
349
+ });
350
+ return binaryResponse(audio, contentTypeForAudio(fileName));
351
+ }
352
+ catch {
353
+ return textResponse(404, "not found");
354
+ }
355
+ }
356
+ function createTwilioPhoneBridge(options) {
357
+ new URL(options.publicBaseUrl);
358
+ return {
359
+ async handle(request) {
360
+ const method = request.method.toUpperCase();
361
+ const requestPath = request.path.startsWith("/") ? request.path : `/${request.path}`;
362
+ const routePath = requestPath.split("?")[0];
363
+ if (method === "GET" && requestPath.startsWith(`${exports.TWILIO_PHONE_WEBHOOK_BASE_PATH}/audio/`)) {
364
+ return handleAudio(options, requestPath);
365
+ }
366
+ if (method === "GET" && routePath === `${exports.TWILIO_PHONE_WEBHOOK_BASE_PATH}/health`) {
367
+ return textResponse(200, "ok");
368
+ }
369
+ if (method !== "POST")
370
+ return textResponse(405, "method not allowed");
371
+ const params = formParams(bodyText(request.body));
372
+ if (!verifyRequest(options, { ...request, path: requestPath }, params)) {
373
+ (0, runtime_1.emitNervesEvent)({
374
+ level: "warn",
375
+ component: "senses",
376
+ event: "senses.voice_twilio_signature_rejected",
377
+ message: "rejected Twilio webhook with invalid signature",
378
+ meta: { agentName: options.agentName, path: requestPath },
379
+ });
380
+ return textResponse(403, "invalid Twilio signature");
381
+ }
382
+ if (routePath === `${exports.TWILIO_PHONE_WEBHOOK_BASE_PATH}/incoming`)
383
+ return handleIncoming(options);
384
+ if (routePath === `${exports.TWILIO_PHONE_WEBHOOK_BASE_PATH}/listen`)
385
+ return handleListen(options);
386
+ if (routePath === `${exports.TWILIO_PHONE_WEBHOOK_BASE_PATH}/recording`)
387
+ return handleRecording(options, params);
388
+ return textResponse(404, "not found");
389
+ },
390
+ };
391
+ }
392
+ function readRequestBody(req, limitBytes = 1_000_000) {
393
+ return new Promise((resolve, reject) => {
394
+ const chunks = [];
395
+ let byteLength = 0;
396
+ req.on("data", (chunk) => {
397
+ byteLength += chunk.byteLength;
398
+ if (byteLength > limitBytes) {
399
+ reject(new Error("request body too large"));
400
+ req.destroy();
401
+ return;
402
+ }
403
+ chunks.push(chunk);
404
+ });
405
+ req.on("end", () => resolve(Buffer.concat(chunks)));
406
+ req.on("error", reject);
407
+ });
408
+ }
409
+ async function startTwilioPhoneBridgeServer(options) {
410
+ const port = options.port ?? exports.DEFAULT_TWILIO_PHONE_PORT;
411
+ const host = options.host ?? "127.0.0.1";
412
+ const bridge = createTwilioPhoneBridge(options);
413
+ const server = http.createServer(async (req, res) => {
414
+ try {
415
+ const body = await readRequestBody(req);
416
+ const response = await bridge.handle({
417
+ method: req.method,
418
+ path: req.url,
419
+ headers: req.headers,
420
+ body,
421
+ });
422
+ res.writeHead(response.statusCode, response.headers);
423
+ res.end(response.body);
424
+ }
425
+ catch (error) {
426
+ (0, runtime_1.emitNervesEvent)({
427
+ level: "error",
428
+ component: "senses",
429
+ event: "senses.voice_twilio_server_error",
430
+ message: "Twilio voice bridge server failed a request",
431
+ meta: { agentName: options.agentName, error: errorMessage(error) },
432
+ });
433
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
434
+ res.end("internal server error");
435
+ }
436
+ });
437
+ await new Promise((resolve, reject) => {
438
+ const onError = (error) => {
439
+ server.off("listening", onListening);
440
+ reject(error);
441
+ };
442
+ const onListening = () => {
443
+ server.off("error", onError);
444
+ resolve();
445
+ };
446
+ server.once("error", onError);
447
+ server.once("listening", onListening);
448
+ server.listen(port, host);
449
+ });
450
+ (0, runtime_1.emitNervesEvent)({
451
+ component: "senses",
452
+ event: "senses.voice_twilio_server_start",
453
+ message: "Twilio voice bridge server started",
454
+ meta: { agentName: options.agentName, host, port, publicBaseUrl: options.publicBaseUrl },
455
+ });
456
+ const actualPort = server.address().port;
457
+ return {
458
+ bridge,
459
+ server,
460
+ localUrl: `http://${host}:${actualPort}`,
461
+ };
462
+ }
@@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.parseWhisperCppTranscriptJson = parseWhisperCppTranscriptJson;
37
+ exports.createNodeWhisperCppProcessRunner = createNodeWhisperCppProcessRunner;
37
38
  exports.createWhisperCppTranscriber = createWhisperCppTranscriber;
38
39
  const fs = __importStar(require("fs/promises"));
39
40
  const os = __importStar(require("os"));
40
41
  const path = __importStar(require("path"));
42
+ const child_process_1 = require("child_process");
41
43
  const runtime_1 = require("../../nerves/runtime");
42
44
  const transcript_1 = require("./transcript");
43
45
  function parseWhisperCppTranscriptJson(raw) {
@@ -68,11 +70,37 @@ async function defaultMakeTempDir() {
68
70
  async function defaultRemoveDir(dir) {
69
71
  await fs.rm(dir, { recursive: true, force: true });
70
72
  }
73
+ function createNodeWhisperCppProcessRunner() {
74
+ return (command, args, options) => new Promise((resolve, reject) => {
75
+ const child = (0, child_process_1.spawn)(command, args, { stdio: ["ignore", "pipe", "pipe"] });
76
+ const stdout = [];
77
+ const stderr = [];
78
+ const timer = setTimeout(() => {
79
+ child.kill("SIGTERM");
80
+ reject(new Error(`command timed out after ${options.timeoutMs}ms`));
81
+ }, options.timeoutMs);
82
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
83
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
84
+ child.on("error", (error) => {
85
+ clearTimeout(timer);
86
+ reject(error);
87
+ });
88
+ child.on("close", (exitCode) => {
89
+ clearTimeout(timer);
90
+ resolve({
91
+ stdout: Buffer.concat(stdout).toString("utf8"),
92
+ stderr: Buffer.concat(stderr).toString("utf8"),
93
+ exitCode: exitCode ?? 0,
94
+ });
95
+ });
96
+ });
97
+ }
71
98
  function createWhisperCppTranscriber(options) {
72
99
  const timeoutMs = options.timeoutMs ?? 120_000;
73
100
  const readFile = options.readFile ?? fs.readFile;
74
101
  const makeTempDir = options.makeTempDir ?? defaultMakeTempDir;
75
102
  const removeDir = options.removeDir ?? defaultRemoveDir;
103
+ const processRunner = options.processRunner ?? createNodeWhisperCppProcessRunner();
76
104
  return {
77
105
  async transcribe(request) {
78
106
  const workDir = await makeTempDir();
@@ -94,7 +122,7 @@ function createWhisperCppTranscriber(options) {
94
122
  meta: { utteranceId: request.utteranceId, audioPath: request.audioPath },
95
123
  });
96
124
  try {
97
- const result = await options.processRunner(options.whisperCliPath, args, { timeoutMs });
125
+ const result = await processRunner(options.whisperCliPath, args, { timeoutMs });
98
126
  if (typeof result.exitCode === "number" && result.exitCode !== 0) {
99
127
  throw new Error(`exit ${result.exitCode}${result.stderr ? `: ${result.stderr}` : ""}`);
100
128
  }