neoagent 3.2.1-beta.2 → 3.2.1-beta.4

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 (145) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +511 -89
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +29 -20
  6. package/flutter_app/lib/main_models.dart +3 -0
  7. package/flutter_app/lib/main_operations.dart +334 -321
  8. package/flutter_app/lib/main_settings.dart +4 -3
  9. package/flutter_app/lib/main_shared.dart +14 -11
  10. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  11. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  12. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  13. package/lib/manager.js +106 -89
  14. package/lib/schema_migrations.js +67 -13
  15. package/package.json +20 -13
  16. package/runtime/paths.js +49 -5
  17. package/server/guest_agent.js +52 -30
  18. package/server/http/middleware.js +24 -0
  19. package/server/http/routes.js +4 -6
  20. package/server/public/.last_build_id +1 -1
  21. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  22. package/server/public/flutter_bootstrap.js +1 -1
  23. package/server/public/main.dart.js +30803 -30805
  24. package/server/routes/admin.js +1 -1
  25. package/server/routes/android.js +30 -34
  26. package/server/routes/browser.js +23 -15
  27. package/server/routes/desktop.js +18 -1
  28. package/server/routes/integrations.js +5 -1
  29. package/server/routes/memory.js +1 -0
  30. package/server/routes/settings.js +16 -5
  31. package/server/routes/social_reach.js +12 -3
  32. package/server/routes/social_video.js +4 -0
  33. package/server/services/ai/compaction.js +7 -2
  34. package/server/services/ai/history.js +44 -5
  35. package/server/services/ai/integrated_tools/http_request.js +8 -0
  36. package/server/services/ai/loop/agent_engine_core.js +347 -162
  37. package/server/services/ai/loop/callbacks.js +1 -0
  38. package/server/services/ai/loop/completion_judge.js +12 -0
  39. package/server/services/ai/loop/conversation_loop.js +348 -242
  40. package/server/services/ai/loop/messaging_delivery.js +129 -57
  41. package/server/services/ai/loop/model_call_guard.js +91 -0
  42. package/server/services/ai/loop/model_io.js +8 -41
  43. package/server/services/ai/loop/tool_dispatch.js +19 -8
  44. package/server/services/ai/loopPolicy.js +24 -19
  45. package/server/services/ai/model_discovery.js +227 -0
  46. package/server/services/ai/model_identity.js +71 -0
  47. package/server/services/ai/models.js +67 -162
  48. package/server/services/ai/providerRetry.js +17 -59
  49. package/server/services/ai/provider_selector.js +111 -0
  50. package/server/services/ai/providers/anthropic.js +2 -2
  51. package/server/services/ai/providers/claudeCode.js +15 -28
  52. package/server/services/ai/providers/githubCopilot.js +36 -16
  53. package/server/services/ai/providers/google.js +23 -5
  54. package/server/services/ai/providers/grok.js +4 -3
  55. package/server/services/ai/providers/grokOauth.js +13 -22
  56. package/server/services/ai/providers/nvidia.js +10 -5
  57. package/server/services/ai/providers/ollama.js +102 -82
  58. package/server/services/ai/providers/ollama_stream.js +142 -0
  59. package/server/services/ai/providers/openai.js +39 -5
  60. package/server/services/ai/providers/openaiCodex.js +11 -4
  61. package/server/services/ai/providers/openrouter.js +29 -7
  62. package/server/services/ai/providers/provider_error.js +36 -0
  63. package/server/services/ai/settings.js +26 -2
  64. package/server/services/ai/taskAnalysis.js +5 -1
  65. package/server/services/ai/terminal_reply.js +45 -0
  66. package/server/services/ai/toolEvidence.js +58 -29
  67. package/server/services/ai/tools.js +124 -111
  68. package/server/services/android/controller.js +770 -237
  69. package/server/services/android/process.js +140 -0
  70. package/server/services/android/sdk_download.js +143 -0
  71. package/server/services/android/uia.js +6 -5
  72. package/server/services/artifacts/store.js +24 -0
  73. package/server/services/browser/controller.js +736 -385
  74. package/server/services/browser/extension/gateway.js +40 -16
  75. package/server/services/browser/extension/protocol.js +12 -1
  76. package/server/services/browser/extension/provider.js +59 -47
  77. package/server/services/browser/extension/registry.js +155 -34
  78. package/server/services/cli/executor.js +62 -9
  79. package/server/services/desktop/gateway.js +41 -4
  80. package/server/services/desktop/protocol.js +3 -0
  81. package/server/services/desktop/provider.js +39 -42
  82. package/server/services/desktop/registry.js +137 -52
  83. package/server/services/integrations/figma/provider.js +78 -12
  84. package/server/services/integrations/github/common.js +11 -6
  85. package/server/services/integrations/github/provider.js +52 -53
  86. package/server/services/integrations/google/provider.js +55 -19
  87. package/server/services/integrations/home_assistant/network.js +17 -20
  88. package/server/services/integrations/home_assistant/provider.js +7 -5
  89. package/server/services/integrations/home_assistant/tools.js +17 -5
  90. package/server/services/integrations/http.js +51 -0
  91. package/server/services/integrations/manager.js +158 -53
  92. package/server/services/integrations/microsoft/provider.js +80 -13
  93. package/server/services/integrations/neoarchive/provider.js +55 -29
  94. package/server/services/integrations/neorecall/client.js +17 -10
  95. package/server/services/integrations/neorecall/provider.js +20 -11
  96. package/server/services/integrations/notion/provider.js +16 -13
  97. package/server/services/integrations/oauth_provider.js +115 -51
  98. package/server/services/integrations/slack/provider.js +98 -9
  99. package/server/services/integrations/spotify/provider.js +67 -71
  100. package/server/services/integrations/trello/provider.js +21 -7
  101. package/server/services/integrations/weather/provider.js +18 -12
  102. package/server/services/integrations/whatsapp/provider.js +76 -16
  103. package/server/services/manager.js +87 -1
  104. package/server/services/memory/embedding_index.js +20 -8
  105. package/server/services/memory/embeddings.js +151 -90
  106. package/server/services/memory/ingestion.js +50 -9
  107. package/server/services/memory/ingestion_documents.js +13 -3
  108. package/server/services/memory/manager.js +52 -19
  109. package/server/services/messaging/automation.js +84 -9
  110. package/server/services/messaging/http_platforms.js +33 -13
  111. package/server/services/messaging/inbound_queue.js +78 -24
  112. package/server/services/messaging/inbound_store.js +224 -0
  113. package/server/services/messaging/manager.js +326 -51
  114. package/server/services/messaging/typing_keepalive.js +5 -2
  115. package/server/services/messaging/whatsapp.js +22 -14
  116. package/server/services/network/http.js +210 -0
  117. package/server/services/network/safe_request.js +307 -0
  118. package/server/services/runtime/backends/local-vm.js +214 -66
  119. package/server/services/runtime/manager.js +17 -12
  120. package/server/services/social_reach/channels/github.js +10 -4
  121. package/server/services/social_reach/channels/reddit.js +4 -4
  122. package/server/services/social_reach/channels/rss.js +2 -2
  123. package/server/services/social_reach/channels/social_video.js +12 -7
  124. package/server/services/social_reach/channels/v2ex.js +21 -8
  125. package/server/services/social_reach/channels/x.js +2 -2
  126. package/server/services/social_reach/channels/xueqiu.js +5 -5
  127. package/server/services/social_reach/service.js +9 -6
  128. package/server/services/social_reach/utils.js +65 -14
  129. package/server/services/social_video/service.js +160 -50
  130. package/server/services/tasks/integration_runtime.js +18 -8
  131. package/server/services/tasks/runtime.js +39 -4
  132. package/server/services/voice/agentBridge.js +17 -4
  133. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  134. package/server/services/voice/liveSession.js +31 -0
  135. package/server/services/voice/openaiSpeech.js +33 -8
  136. package/server/services/voice/providers.js +233 -151
  137. package/server/services/voice/runtimeManager.js +118 -20
  138. package/server/services/voice/turnRunner.js +6 -0
  139. package/server/services/wearable/firmware_manifest.js +51 -13
  140. package/server/services/wearable/service.js +1 -0
  141. package/server/utils/abort.js +96 -0
  142. package/server/utils/cloud-security.js +110 -3
  143. package/server/utils/files.js +31 -0
  144. package/server/utils/image_payload.js +95 -0
  145. package/server/utils/retry.js +107 -0
@@ -6,6 +6,12 @@ const { AGENT_DATA_DIR } = require('../../../runtime/paths');
6
6
  const { getOpenAiClient } = require('./openaiClient');
7
7
  const { synthesizeSpeechBuffer } = require('./openaiSpeech');
8
8
  const { decryptLocalValue } = require('../../utils/local_secrets');
9
+ const { fetchResponseBuffer, fetchResponseText } = require('../network/http');
10
+ const {
11
+ createAbortError,
12
+ runWithAbortTimeout,
13
+ throwIfAborted,
14
+ } = require('../../utils/abort');
9
15
 
10
16
  const DEEPGRAM_STT_MODEL = process.env.DEEPGRAM_MODEL || 'nova-3';
11
17
  const DEEPGRAM_STT_LANGUAGE = process.env.DEEPGRAM_LANGUAGE || 'multi';
@@ -16,6 +22,7 @@ async function transcribeChunkWithDeepgram({
16
22
  mimeType,
17
23
  detectLanguage = DEEPGRAM_STT_LANGUAGE,
18
24
  model = DEEPGRAM_STT_MODEL,
25
+ signal = null,
19
26
  } = {}) {
20
27
  if (!(audioBytes instanceof Uint8Array) || audioBytes.byteLength === 0) {
21
28
  throw new Error('Audio payload is empty.');
@@ -30,7 +37,7 @@ async function transcribeChunkWithDeepgram({
30
37
  utterances: 'true',
31
38
  diarize: 'false',
32
39
  });
33
- const response = await fetch(
40
+ return fetchJsonOrThrow(
34
41
  `${DEEPGRAM_BASE_URL.replace(/\/$/, '')}/v1/listen?${query.toString()}`,
35
42
  {
36
43
  method: 'POST',
@@ -39,14 +46,11 @@ async function transcribeChunkWithDeepgram({
39
46
  'Content-Type': mimeType || 'application/octet-stream',
40
47
  },
41
48
  body: audioBytes,
49
+ signal,
42
50
  },
51
+ 'Deepgram request failed',
52
+ { maxResponseBytes: 10 * 1024 * 1024, timeoutMs: 60000 },
43
53
  );
44
-
45
- if (!response.ok) {
46
- await throwResponseError(response, 'Deepgram request failed');
47
- }
48
-
49
- return response.json();
50
54
  }
51
55
 
52
56
  const DEFAULT_STT_PROVIDER = 'openai';
@@ -92,25 +96,10 @@ const WEARABLE_SAFE_AUDIO_FORMAT = Object.freeze({
92
96
  });
93
97
  const MIN_STREAM_PCM_CHUNK_BYTES = 24000;
94
98
  const MAX_STREAM_PCM_CHUNK_BYTES = 48000;
95
-
96
- function withTimeout(promise, timeoutMs, label) {
97
- const normalizedTimeout = Number(timeoutMs);
98
- if (!Number.isFinite(normalizedTimeout) || normalizedTimeout <= 0) {
99
- return promise;
100
- }
101
- let timer = null;
102
- const timeoutPromise = new Promise((_, reject) => {
103
- timer = setTimeout(() => {
104
- reject(new Error(`${label} timed out after ${normalizedTimeout}ms.`));
105
- }, normalizedTimeout);
106
- timer.unref?.();
107
- });
108
- return Promise.race([promise, timeoutPromise]).finally(() => {
109
- if (timer) {
110
- clearTimeout(timer);
111
- }
112
- });
113
- }
99
+ const DEFAULT_STT_TIMEOUT_MS = 60000;
100
+ const DEFAULT_TTS_TIMEOUT_MS = 30000;
101
+ const MAX_JSON_RESPONSE_BYTES = 40 * 1024 * 1024;
102
+ const MAX_AUDIO_RESPONSE_BYTES = 32 * 1024 * 1024;
114
103
 
115
104
  function sanitizeSpeechText(value) {
116
105
  const text = String(value || '');
@@ -212,46 +201,71 @@ function requireApiKey(settingLabel, candidates = []) {
212
201
  return apiKey;
213
202
  }
214
203
 
215
- async function throwResponseError(response, prefix) {
216
- const body = await response.text();
217
- throw new Error(`${prefix} (${response.status}): ${body || 'empty response'}`);
218
- }
219
-
220
- async function fetchJsonOrThrow(url, init, errorPrefix) {
221
- const response = await fetch(url, init);
222
- if (!response.ok) {
223
- await throwResponseError(response, errorPrefix);
224
- }
225
- return response.json();
226
- }
227
-
228
- async function fetchAudioOrThrow(url, init, errorPrefix, defaultMimeType = 'audio/mpeg') {
229
- const response = await fetch(url, init);
204
+ function responseError(prefix, status, body) {
205
+ const error = new Error(
206
+ `${prefix} (${status}): ${String(body || 'empty response').slice(0, 2000)}`,
207
+ );
208
+ error.status = status;
209
+ return error;
210
+ }
211
+
212
+ async function fetchJsonOrThrow(url, init, errorPrefix, options = {}) {
213
+ const { response, text } = await fetchResponseText(url, {
214
+ ...init,
215
+ timeoutMs: options.timeoutMs || DEFAULT_STT_TIMEOUT_MS,
216
+ maxResponseBytes: options.maxResponseBytes || MAX_JSON_RESPONSE_BYTES,
217
+ serviceName: errorPrefix,
218
+ timeoutCode: 'VOICE_PROVIDER_TIMEOUT',
219
+ tooLargeCode: 'VOICE_PROVIDER_RESPONSE_TOO_LARGE',
220
+ });
221
+ if (!response.ok) throw responseError(errorPrefix, response.status, text);
222
+ try {
223
+ return JSON.parse(text);
224
+ } catch (cause) {
225
+ throw new Error(`${errorPrefix}: provider returned malformed JSON.`, { cause });
226
+ }
227
+ }
228
+
229
+ async function fetchAudioOrThrow(
230
+ url,
231
+ init,
232
+ errorPrefix,
233
+ defaultMimeType = 'audio/mpeg',
234
+ options = {},
235
+ ) {
236
+ const { response, body } = await fetchResponseBuffer(url, {
237
+ ...init,
238
+ timeoutMs: options.timeoutMs || DEFAULT_TTS_TIMEOUT_MS,
239
+ maxResponseBytes: options.maxResponseBytes || MAX_AUDIO_RESPONSE_BYTES,
240
+ serviceName: errorPrefix,
241
+ timeoutCode: 'VOICE_PROVIDER_TIMEOUT',
242
+ tooLargeCode: 'VOICE_PROVIDER_RESPONSE_TOO_LARGE',
243
+ });
230
244
  if (!response.ok) {
231
- await throwResponseError(response, errorPrefix);
245
+ throw responseError(errorPrefix, response.status, body.toString('utf8'));
232
246
  }
233
247
  return {
234
- audioBytes: Buffer.from(await response.arrayBuffer()),
248
+ audioBytes: body,
235
249
  mimeType: response.headers.get('content-type') || defaultMimeType,
236
250
  };
237
251
  }
238
252
 
239
- async function fetchAudioStreamOrThrow(url, init, errorPrefix, defaultMimeType = 'audio/mpeg', onChunk) {
240
- const response = await fetch(url, init);
241
- if (!response.ok) {
242
- await throwResponseError(response, errorPrefix);
243
- }
244
- const mimeType = response.headers.get('content-type') || defaultMimeType;
245
- const reader = response.body.getReader();
246
- const chunks = [];
247
- while (true) {
248
- const { done, value } = await reader.read();
249
- if (done) break;
250
- if (value?.length) {
251
- chunks.push(Buffer.from(value));
252
- }
253
- }
254
- await onChunk({ audioBytes: Buffer.concat(chunks), mimeType });
253
+ async function fetchAudioStreamOrThrow(
254
+ url,
255
+ init,
256
+ errorPrefix,
257
+ defaultMimeType,
258
+ onChunk,
259
+ options = {},
260
+ ) {
261
+ const audio = await fetchAudioOrThrow(
262
+ url,
263
+ init,
264
+ errorPrefix,
265
+ defaultMimeType,
266
+ options,
267
+ );
268
+ await onChunk(audio);
255
269
  }
256
270
 
257
271
  function guessExtFromMimeType(mimeType) {
@@ -312,11 +326,26 @@ function wrapPcmAsWav(audioBytes, format) {
312
326
  return Buffer.concat([header, data]);
313
327
  }
314
328
 
315
- async function streamPcmAsWavChunks(readable, format, onChunk) {
329
+ async function emitPcmBufferAsWavChunks(audioBytes, format, onChunk, signal = null) {
330
+ const source = Buffer.isBuffer(audioBytes) ? audioBytes : Buffer.from(audioBytes || []);
331
+ for (let offset = 0; offset < source.length; offset += MAX_STREAM_PCM_CHUNK_BYTES) {
332
+ throwIfAborted(signal, 'Voice synthesis aborted.');
333
+ const end = Math.min(source.length, offset + MAX_STREAM_PCM_CHUNK_BYTES);
334
+ const evenEnd = end - ((end - offset) % 2);
335
+ if (evenEnd <= offset) continue;
336
+ await onChunk({
337
+ audioBytes: wrapPcmAsWav(source.subarray(offset, evenEnd), format),
338
+ mimeType: WEARABLE_SAFE_AUDIO_FORMAT.streamMimeType,
339
+ });
340
+ }
341
+ }
342
+
343
+ async function streamPcmAsWavChunks(readable, format, onChunk, options = {}) {
316
344
  const source = readable && typeof readable.getReader === 'function'
317
345
  ? readable
318
346
  : null;
319
347
  let pending = Buffer.alloc(0);
348
+ let totalBytes = 0;
320
349
 
321
350
  async function flushPending(force = false) {
322
351
  while (pending.length >= MIN_STREAM_PCM_CHUNK_BYTES || (force && pending.length > 0)) {
@@ -327,6 +356,7 @@ async function streamPcmAsWavChunks(readable, format, onChunk) {
327
356
  if (evenLength <= 0) return;
328
357
  const pcmChunk = pending.subarray(0, evenLength);
329
358
  pending = pending.subarray(evenLength);
359
+ throwIfAborted(options.signal, 'Voice synthesis aborted.');
330
360
  await onChunk({
331
361
  audioBytes: wrapPcmAsWav(pcmChunk, format),
332
362
  mimeType: WEARABLE_SAFE_AUDIO_FORMAT.streamMimeType,
@@ -340,6 +370,13 @@ async function streamPcmAsWavChunks(readable, format, onChunk) {
340
370
  const { done, value } = await reader.read();
341
371
  if (done) break;
342
372
  if (value?.length) {
373
+ totalBytes += value.length;
374
+ if (totalBytes > MAX_AUDIO_RESPONSE_BYTES) {
375
+ await reader.cancel().catch(() => {});
376
+ const error = new Error('Voice audio response exceeded its safety limit.');
377
+ error.code = 'VOICE_PROVIDER_RESPONSE_TOO_LARGE';
378
+ throw error;
379
+ }
343
380
  pending = Buffer.concat([pending, Buffer.from(value)]);
344
381
  await flushPending(false);
345
382
  }
@@ -347,7 +384,14 @@ async function streamPcmAsWavChunks(readable, format, onChunk) {
347
384
  } else {
348
385
  for await (const chunk of readable) {
349
386
  if (chunk?.length) {
350
- pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
387
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
388
+ totalBytes += buffer.length;
389
+ if (totalBytes > MAX_AUDIO_RESPONSE_BYTES) {
390
+ const error = new Error('Voice audio response exceeded its safety limit.');
391
+ error.code = 'VOICE_PROVIDER_RESPONSE_TOO_LARGE';
392
+ throw error;
393
+ }
394
+ pending = Buffer.concat([pending, buffer]);
351
395
  await flushPending(false);
352
396
  }
353
397
  }
@@ -363,19 +407,28 @@ async function transcribeWithOpenAi(filePath, model, options = {}) {
363
407
  if (!client) {
364
408
  throw new Error('OpenAI STT is selected but OPENAI_API_KEY is not configured.');
365
409
  }
366
- const transcription = await client.audio.transcriptions.create({
367
- file: fs.createReadStream(filePath),
368
- model,
369
- });
370
- return String(transcription?.text || '').trim();
410
+ const file = fs.createReadStream(filePath);
411
+ const abortFile = () => file.destroy(createAbortError(options.signal));
412
+ options.signal?.addEventListener('abort', abortFile, { once: true });
413
+ try {
414
+ const transcription = await client.audio.transcriptions.create({
415
+ file,
416
+ model,
417
+ }, { signal: options.signal });
418
+ return String(transcription?.text || '').trim();
419
+ } finally {
420
+ options.signal?.removeEventListener('abort', abortFile);
421
+ file.destroy();
422
+ }
371
423
  }
372
424
 
373
- async function transcribeWithDeepgram(filePath, mimeType) {
374
- const audioBytes = await fs.promises.readFile(filePath);
425
+ async function transcribeWithDeepgram(filePath, mimeType, options = {}) {
426
+ const audioBytes = await fs.promises.readFile(filePath, { signal: options.signal });
375
427
  const payload = await transcribeChunkWithDeepgram({
376
428
  audioBytes,
377
429
  mimeType: mimeType || 'audio/mpeg',
378
430
  detectLanguage: 'multi',
431
+ signal: options.signal,
379
432
  });
380
433
 
381
434
  const transcript = payload?.results?.channels?.[0]?.alternatives?.[0]?.transcript;
@@ -387,13 +440,14 @@ async function transcribeWithGemini(filePath, model, mimeType, options = {}) {
387
440
  (typeof options.apiKey === 'string' ? options.apiKey.trim() : '') ||
388
441
  requireApiKey('Gemini STT', ['GOOGLE_AI_KEY', 'GEMINI_API_KEY']);
389
442
 
390
- const audioBytes = await fs.promises.readFile(filePath);
443
+ const audioBytes = await fs.promises.readFile(filePath, { signal: options.signal });
391
444
  const payload = await fetchJsonOrThrow(
392
- `${GEMINI_API_BASE_URL}/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`,
445
+ `${GEMINI_API_BASE_URL}/${encodeURIComponent(model)}:generateContent`,
393
446
  {
394
447
  method: 'POST',
395
448
  headers: {
396
449
  'Content-Type': 'application/json',
450
+ 'x-goog-api-key': apiKey,
397
451
  },
398
452
  body: JSON.stringify({
399
453
  contents: [
@@ -415,8 +469,10 @@ async function transcribeWithGemini(filePath, model, mimeType, options = {}) {
415
469
  temperature: 0,
416
470
  },
417
471
  }),
472
+ signal: options.signal,
418
473
  },
419
474
  'Gemini STT request failed',
475
+ { maxResponseBytes: MAX_JSON_RESPONSE_BYTES, timeoutMs: DEFAULT_STT_TIMEOUT_MS },
420
476
  );
421
477
  const parts = payload?.candidates?.[0]?.content?.parts;
422
478
  const transcript = Array.isArray(parts)
@@ -428,16 +484,21 @@ async function transcribeWithGemini(filePath, model, mimeType, options = {}) {
428
484
  async function transcribeVoiceInput(filePath, options = {}) {
429
485
  const provider = normalizeSttProvider(options.provider);
430
486
  const model = resolveSttModel(provider, options.model);
431
- let request = null;
432
-
433
- if (provider === 'openai') {
434
- request = transcribeWithOpenAi(filePath, model, options);
435
- } else if (provider === 'deepgram') {
436
- request = transcribeWithDeepgram(filePath, options.mimeType);
437
- } else {
438
- request = transcribeWithGemini(filePath, model, options.mimeType, options);
439
- }
440
- return withTimeout(request, options.timeoutMs, `${provider} STT`);
487
+ return runWithAbortTimeout(async (signal) => {
488
+ const requestOptions = { ...options, signal };
489
+ if (provider === 'openai') {
490
+ return transcribeWithOpenAi(filePath, model, requestOptions);
491
+ }
492
+ if (provider === 'deepgram') {
493
+ return transcribeWithDeepgram(filePath, options.mimeType, requestOptions);
494
+ }
495
+ return transcribeWithGemini(filePath, model, options.mimeType, requestOptions);
496
+ }, {
497
+ signal: options.signal,
498
+ timeoutMs: options.timeoutMs || DEFAULT_STT_TIMEOUT_MS,
499
+ timeoutCode: 'VOICE_STT_TIMEOUT',
500
+ label: `${provider} STT`,
501
+ });
441
502
  }
442
503
 
443
504
  async function synthesizeWithOpenAi(text, model, voice, options = {}) {
@@ -453,6 +514,9 @@ async function synthesizeWithOpenAi(text, model, voice, options = {}) {
453
514
  model,
454
515
  voice,
455
516
  responseFormat: useWearableSafeAudio ? WEARABLE_SAFE_AUDIO_FORMAT.responseFormat : 'mp3',
517
+ signal: options.signal,
518
+ timeoutMs: options.timeoutMs || DEFAULT_TTS_TIMEOUT_MS,
519
+ maxResponseBytes: options.maxResponseBytes || MAX_AUDIO_RESPONSE_BYTES,
456
520
  });
457
521
  return {
458
522
  audioBytes,
@@ -474,7 +538,7 @@ async function streamWithOpenAi(text, model, voice, options = {}, onChunk) {
474
538
  voice: String(voice || 'alloy').trim() || 'alloy',
475
539
  input: text,
476
540
  response_format: useWearableSafeAudio ? WEARABLE_SAFE_AUDIO_FORMAT.streamResponseFormat : 'mp3',
477
- });
541
+ }, { signal: options.signal });
478
542
  if (useWearableSafeAudio) {
479
543
  await streamPcmAsWavChunks(
480
544
  response.body,
@@ -484,12 +548,22 @@ async function streamWithOpenAi(text, model, voice, options = {}, onChunk) {
484
548
  channels: WEARABLE_SAFE_AUDIO_FORMAT.pcmChannels,
485
549
  },
486
550
  onChunk,
551
+ { signal: options.signal },
487
552
  );
488
553
  return;
489
554
  }
490
555
  const chunks = [];
556
+ let totalBytes = 0;
491
557
  for await (const chunk of response.body) {
492
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
558
+ throwIfAborted(options.signal, 'OpenAI TTS stream aborted.');
559
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
560
+ totalBytes += buffer.length;
561
+ if (totalBytes > MAX_AUDIO_RESPONSE_BYTES) {
562
+ const error = new Error('OpenAI speech response exceeded its safety limit.');
563
+ error.code = 'VOICE_PROVIDER_RESPONSE_TOO_LARGE';
564
+ throw error;
565
+ }
566
+ chunks.push(buffer);
493
567
  }
494
568
  const audioBytes = Buffer.concat(chunks);
495
569
  await onChunk({
@@ -518,9 +592,11 @@ async function synthesizeWithDeepgram(text, model, options = {}) {
518
592
  'Content-Type': 'application/json',
519
593
  },
520
594
  body: JSON.stringify({ text }),
595
+ signal: options.signal,
521
596
  },
522
597
  'Deepgram TTS request failed',
523
598
  useWearableSafeAudio ? WEARABLE_SAFE_AUDIO_FORMAT.mimeType : 'audio/mpeg',
599
+ { timeoutMs: options.timeoutMs || DEFAULT_TTS_TIMEOUT_MS },
524
600
  );
525
601
  }
526
602
 
@@ -534,7 +610,7 @@ async function streamWithDeepgram(text, model, options = {}, onChunk) {
534
610
  searchParams.set('encoding', WEARABLE_SAFE_AUDIO_FORMAT.deepgramEncoding);
535
611
  searchParams.set('container', WEARABLE_SAFE_AUDIO_FORMAT.deepgramStreamContainer);
536
612
  searchParams.set('sample_rate', String(WEARABLE_SAFE_AUDIO_FORMAT.pcmSampleRate));
537
- const response = await fetch(
613
+ const audio = await fetchAudioOrThrow(
538
614
  `https://api.deepgram.com/v1/speak?${searchParams.toString()}`,
539
615
  {
540
616
  method: 'POST',
@@ -543,19 +619,21 @@ async function streamWithDeepgram(text, model, options = {}, onChunk) {
543
619
  'Content-Type': 'application/json',
544
620
  },
545
621
  body: JSON.stringify({ text }),
622
+ signal: options.signal,
546
623
  },
624
+ 'Deepgram TTS stream failed',
625
+ 'audio/pcm',
626
+ { timeoutMs: options.timeoutMs || DEFAULT_TTS_TIMEOUT_MS },
547
627
  );
548
- if (!response.ok) {
549
- await throwResponseError(response, 'Deepgram TTS stream failed');
550
- }
551
- await streamPcmAsWavChunks(
552
- response.body,
628
+ await emitPcmBufferAsWavChunks(
629
+ audio.audioBytes,
553
630
  {
554
631
  bitsPerSample: WEARABLE_SAFE_AUDIO_FORMAT.pcmBitsPerSample,
555
632
  sampleRate: WEARABLE_SAFE_AUDIO_FORMAT.pcmSampleRate,
556
633
  channels: WEARABLE_SAFE_AUDIO_FORMAT.pcmChannels,
557
634
  },
558
635
  onChunk,
636
+ options.signal,
559
637
  );
560
638
  return;
561
639
  }
@@ -568,10 +646,12 @@ async function streamWithDeepgram(text, model, options = {}, onChunk) {
568
646
  'Content-Type': 'application/json',
569
647
  },
570
648
  body: JSON.stringify({ text }),
649
+ signal: options.signal,
571
650
  },
572
651
  'Deepgram TTS stream failed',
573
652
  useWearableSafeAudio ? WEARABLE_SAFE_AUDIO_FORMAT.mimeType : 'audio/mpeg',
574
653
  onChunk,
654
+ { timeoutMs: options.timeoutMs || DEFAULT_TTS_TIMEOUT_MS },
575
655
  );
576
656
  }
577
657
 
@@ -581,11 +661,12 @@ async function synthesizeWithGemini(text, model, voice, options = {}) {
581
661
  requireApiKey('Gemini TTS', ['GOOGLE_AI_KEY', 'GEMINI_API_KEY']);
582
662
 
583
663
  const payload = await fetchJsonOrThrow(
584
- `${GEMINI_API_BASE_URL}/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`,
664
+ `${GEMINI_API_BASE_URL}/${encodeURIComponent(model)}:generateContent`,
585
665
  {
586
666
  method: 'POST',
587
667
  headers: {
588
668
  'Content-Type': 'application/json',
669
+ 'x-goog-api-key': apiKey,
589
670
  },
590
671
  body: JSON.stringify({
591
672
  contents: [
@@ -605,8 +686,10 @@ async function synthesizeWithGemini(text, model, voice, options = {}) {
605
686
  temperature: 0.6,
606
687
  },
607
688
  }),
689
+ signal: options.signal,
608
690
  },
609
691
  'Gemini TTS request failed',
692
+ { maxResponseBytes: MAX_JSON_RESPONSE_BYTES, timeoutMs: DEFAULT_TTS_TIMEOUT_MS },
610
693
  );
611
694
  const parts = payload?.candidates?.[0]?.content?.parts;
612
695
  const audioPart = Array.isArray(parts)
@@ -674,11 +757,14 @@ async function streamWithGemini(text, model, voice, options = {}, onChunk) {
674
757
  (typeof options.apiKey === 'string' ? options.apiKey.trim() : '') ||
675
758
  requireApiKey('Gemini TTS', ['GOOGLE_AI_KEY', 'GEMINI_API_KEY']);
676
759
 
677
- const response = await fetch(
678
- `${GEMINI_API_BASE_URL}/${encodeURIComponent(model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`,
760
+ const { response, text: eventStream } = await fetchResponseText(
761
+ `${GEMINI_API_BASE_URL}/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`,
679
762
  {
680
763
  method: 'POST',
681
- headers: { 'Content-Type': 'application/json' },
764
+ headers: {
765
+ 'Content-Type': 'application/json',
766
+ 'x-goog-api-key': apiKey,
767
+ },
682
768
  body: JSON.stringify({
683
769
  contents: [{ parts: [{ text }] }],
684
770
  generationConfig: {
@@ -693,54 +779,40 @@ async function streamWithGemini(text, model, voice, options = {}, onChunk) {
693
779
  temperature: 0.6,
694
780
  },
695
781
  }),
782
+ signal: options.signal,
783
+ timeoutMs: options.timeoutMs || DEFAULT_TTS_TIMEOUT_MS,
784
+ maxResponseBytes: MAX_JSON_RESPONSE_BYTES,
785
+ serviceName: 'Gemini TTS stream',
786
+ timeoutCode: 'VOICE_PROVIDER_TIMEOUT',
787
+ tooLargeCode: 'VOICE_PROVIDER_RESPONSE_TOO_LARGE',
696
788
  },
697
789
  );
698
-
699
790
  if (!response.ok) {
700
- await throwResponseError(response, 'Gemini TTS stream request failed');
701
- }
702
-
703
- const reader = response.body.getReader();
704
- const decoder = new TextDecoder();
705
- let buffer = '';
706
-
707
- while (true) {
708
- const { done, value } = await reader.read();
709
- if (done) break;
710
- buffer += decoder.decode(value, { stream: true });
711
-
712
- // SSE lines: each event is "data: {...}\n\n"
713
- const lines = buffer.split('\n');
714
- buffer = lines.pop(); // keep incomplete line
715
-
716
- for (const line of lines) {
717
- const trimmed = line.trim();
718
- if (!trimmed.startsWith('data:')) continue;
719
- const jsonStr = trimmed.slice(5).trim();
720
- if (!jsonStr || jsonStr === '[DONE]') continue;
721
- let parsed;
722
- try {
723
- parsed = JSON.parse(jsonStr);
724
- } catch {
725
- continue;
726
- }
727
- const chunk = extractGeminiAudioChunk(parsed);
728
- if (chunk) await onChunk(chunk);
791
+ throw responseError('Gemini TTS stream request failed', response.status, eventStream);
792
+ }
793
+
794
+ let totalAudioBytes = 0;
795
+ for (const line of eventStream.split('\n')) {
796
+ throwIfAborted(options.signal, 'Gemini TTS stream aborted.');
797
+ const trimmed = line.trim();
798
+ if (!trimmed.startsWith('data:')) continue;
799
+ const jsonStr = trimmed.slice(5).trim();
800
+ if (!jsonStr || jsonStr === '[DONE]') continue;
801
+ let parsed;
802
+ try {
803
+ parsed = JSON.parse(jsonStr);
804
+ } catch {
805
+ continue;
729
806
  }
730
- }
731
-
732
- // Flush any remaining buffered data.
733
- if (buffer.trim().startsWith('data:')) {
734
- const jsonStr = buffer.trim().slice(5).trim();
735
- if (jsonStr && jsonStr !== '[DONE]') {
736
- try {
737
- const parsed = JSON.parse(jsonStr);
738
- const chunk = extractGeminiAudioChunk(parsed);
739
- if (chunk) await onChunk(chunk);
740
- } catch {
741
- // Ignore incomplete trailing chunk.
742
- }
807
+ const chunk = extractGeminiAudioChunk(parsed);
808
+ if (!chunk) continue;
809
+ totalAudioBytes += chunk.audioBytes.length;
810
+ if (totalAudioBytes > MAX_AUDIO_RESPONSE_BYTES) {
811
+ const error = new Error('Gemini speech response exceeded its safety limit.');
812
+ error.code = 'VOICE_PROVIDER_RESPONSE_TOO_LARGE';
813
+ throw error;
743
814
  }
815
+ await onChunk(chunk);
744
816
  }
745
817
  }
746
818
 
@@ -751,16 +823,21 @@ async function synthesizeVoiceReply(text, options = {}) {
751
823
  }
752
824
 
753
825
  const { provider, model, voice } = normalizeVoiceSynthesisOptions(options);
754
- let request = null;
755
-
756
- if (provider === 'openai') {
757
- request = synthesizeWithOpenAi(content, model, voice, options);
758
- } else if (provider === 'deepgram') {
759
- request = synthesizeWithDeepgram(content, model, options);
760
- } else {
761
- request = synthesizeWithGemini(content, model, voice, options);
762
- }
763
- return withTimeout(request, options.timeoutMs, `${provider} TTS`);
826
+ return runWithAbortTimeout(async (signal) => {
827
+ const requestOptions = { ...options, signal };
828
+ if (provider === 'openai') {
829
+ return synthesizeWithOpenAi(content, model, voice, requestOptions);
830
+ }
831
+ if (provider === 'deepgram') {
832
+ return synthesizeWithDeepgram(content, model, requestOptions);
833
+ }
834
+ return synthesizeWithGemini(content, model, voice, requestOptions);
835
+ }, {
836
+ signal: options.signal,
837
+ timeoutMs: options.timeoutMs || DEFAULT_TTS_TIMEOUT_MS,
838
+ timeoutCode: 'VOICE_TTS_TIMEOUT',
839
+ label: `${provider} TTS`,
840
+ });
764
841
  }
765
842
 
766
843
  // Minimum characters before flushing a sentence chunk to TTS to avoid tiny requests.
@@ -834,16 +911,21 @@ async function synthesizeVoiceReplyStream(text, options = {}, onChunk) {
834
911
  const chunks = splitIntoSentenceChunks(content);
835
912
 
836
913
  for (const chunk of chunks) {
837
- const run = (async () => {
914
+ await runWithAbortTimeout(async (signal) => {
915
+ const requestOptions = { ...options, signal };
838
916
  if (provider === 'openai') {
839
- await streamWithOpenAi(chunk, model, voice, options, onChunk);
917
+ await streamWithOpenAi(chunk, model, voice, requestOptions, onChunk);
840
918
  } else if (provider === 'deepgram') {
841
- await streamWithDeepgram(chunk, model, options, onChunk);
919
+ await streamWithDeepgram(chunk, model, requestOptions, onChunk);
842
920
  } else {
843
- await streamWithGemini(chunk, model, voice, options, onChunk);
921
+ await streamWithGemini(chunk, model, voice, requestOptions, onChunk);
844
922
  }
845
- })();
846
- await withTimeout(run, options.timeoutMs, `${provider} TTS stream`);
923
+ }, {
924
+ signal: options.signal,
925
+ timeoutMs: options.timeoutMs || DEFAULT_TTS_TIMEOUT_MS,
926
+ timeoutCode: 'VOICE_TTS_TIMEOUT',
927
+ label: `${provider} TTS stream`,
928
+ });
847
929
  }
848
930
  }
849
931