neoagent 3.2.1-beta.0 → 3.2.1-beta.10
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/docs/agent-run-lifecycle.md +10 -0
- package/extensions/chrome-browser/background.mjs +318 -88
- package/extensions/chrome-browser/http.mjs +136 -0
- package/extensions/chrome-browser/protocol.mjs +654 -90
- package/flutter_app/lib/main_chat.dart +118 -739
- package/flutter_app/lib/main_controller.dart +111 -20
- package/flutter_app/lib/main_integrations.dart +607 -8
- package/flutter_app/lib/main_models.dart +3 -0
- package/flutter_app/lib/main_operations.dart +334 -321
- package/flutter_app/lib/main_security.dart +266 -112
- package/flutter_app/lib/main_settings.dart +4 -3
- package/flutter_app/lib/main_shared.dart +14 -11
- package/flutter_app/lib/src/backend_client.dart +78 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
- package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
- package/flutter_app/windows/runner/flutter_window.cpp +143 -32
- package/landing/index.html +3 -1
- package/lib/manager.js +106 -89
- package/lib/schema_migrations.js +145 -13
- package/package.json +30 -15
- package/runtime/paths.js +49 -5
- package/server/db/database.js +4 -4
- package/server/guest-agent.cli.package.json +13 -0
- package/server/guest_agent.js +85 -40
- package/server/http/middleware.js +24 -0
- package/server/http/routes.js +11 -6
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +73083 -72209
- package/server/routes/admin.js +1 -1
- package/server/routes/agents.js +35 -2
- package/server/routes/android.js +30 -34
- package/server/routes/browser.js +23 -15
- package/server/routes/desktop.js +18 -1
- package/server/routes/integrations.js +107 -1
- package/server/routes/memory.js +1 -0
- package/server/routes/settings.js +16 -5
- package/server/routes/social_reach.js +12 -3
- package/server/routes/social_video.js +4 -0
- package/server/services/agents/manager.js +1 -1
- package/server/services/ai/capabilityHealth.js +62 -96
- package/server/services/ai/compaction.js +7 -2
- package/server/services/ai/history.js +45 -6
- package/server/services/ai/integrated_tools/http_request.js +8 -0
- package/server/services/ai/loop/agent_engine_core.js +496 -166
- package/server/services/ai/loop/blank_recovery.js +5 -4
- package/server/services/ai/loop/callbacks.js +1 -0
- package/server/services/ai/loop/completion_judge.js +121 -5
- package/server/services/ai/loop/conversation_loop.js +620 -340
- package/server/services/ai/loop/lifecycle.js +108 -0
- package/server/services/ai/loop/messaging_delivery.js +129 -57
- package/server/services/ai/loop/model_call_guard.js +91 -0
- package/server/services/ai/loop/model_io.js +48 -56
- package/server/services/ai/loop/progress_classification.js +2 -0
- package/server/services/ai/loop/tool_dispatch.js +28 -8
- package/server/services/ai/loopPolicy.js +48 -21
- package/server/services/ai/messagingFallback.js +17 -17
- package/server/services/ai/model_discovery.js +227 -0
- package/server/services/ai/model_failure_cache.js +108 -0
- package/server/services/ai/model_identity.js +71 -0
- package/server/services/ai/models.js +68 -163
- package/server/services/ai/providerRetry.js +17 -59
- package/server/services/ai/provider_selector.js +166 -0
- package/server/services/ai/providers/anthropic.js +4 -4
- package/server/services/ai/providers/claudeCode.js +21 -33
- package/server/services/ai/providers/githubCopilot.js +41 -20
- package/server/services/ai/providers/google.js +135 -97
- package/server/services/ai/providers/grok.js +6 -5
- package/server/services/ai/providers/grokOauth.js +19 -27
- package/server/services/ai/providers/nvidia.js +12 -7
- package/server/services/ai/providers/ollama.js +114 -86
- package/server/services/ai/providers/ollama_stream.js +142 -0
- package/server/services/ai/providers/openai.js +41 -7
- package/server/services/ai/providers/openaiCodex.js +13 -4
- package/server/services/ai/providers/openrouter.js +31 -9
- package/server/services/ai/providers/provider_error.js +36 -0
- package/server/services/ai/settings.js +26 -2
- package/server/services/ai/systemPrompt.js +19 -12
- package/server/services/ai/taskAnalysis.js +58 -10
- package/server/services/ai/terminal_reply.js +18 -0
- package/server/services/ai/toolEvidence.js +350 -29
- package/server/services/ai/tools.js +190 -111
- package/server/services/android/controller.js +770 -237
- package/server/services/android/process.js +140 -0
- package/server/services/android/sdk_download.js +143 -0
- package/server/services/android/uia.js +6 -5
- package/server/services/artifacts/store.js +24 -0
- package/server/services/browser/controller.js +843 -385
- package/server/services/browser/extension/gateway.js +40 -16
- package/server/services/browser/extension/protocol.js +15 -1
- package/server/services/browser/extension/provider.js +71 -47
- package/server/services/browser/extension/registry.js +155 -34
- package/server/services/cli/executor.js +62 -9
- package/server/services/credentials/bitwarden_cli.js +322 -0
- package/server/services/credentials/broker.js +594 -0
- package/server/services/desktop/gateway.js +41 -4
- package/server/services/desktop/protocol.js +3 -0
- package/server/services/desktop/provider.js +39 -42
- package/server/services/desktop/registry.js +137 -52
- package/server/services/integrations/bitwarden/constants.js +14 -0
- package/server/services/integrations/bitwarden/provider.js +197 -0
- package/server/services/integrations/bitwarden/snapshot.js +65 -0
- package/server/services/integrations/figma/provider.js +78 -12
- package/server/services/integrations/github/common.js +11 -6
- package/server/services/integrations/github/provider.js +52 -53
- package/server/services/integrations/google/provider.js +55 -19
- package/server/services/integrations/home_assistant/network.js +17 -20
- package/server/services/integrations/home_assistant/provider.js +7 -5
- package/server/services/integrations/home_assistant/tools.js +17 -5
- package/server/services/integrations/http.js +51 -0
- package/server/services/integrations/manager.js +159 -53
- package/server/services/integrations/microsoft/provider.js +80 -13
- package/server/services/integrations/neoarchive/provider.js +55 -29
- package/server/services/integrations/neorecall/client.js +17 -10
- package/server/services/integrations/neorecall/provider.js +20 -11
- package/server/services/integrations/notion/provider.js +16 -13
- package/server/services/integrations/oauth_provider.js +115 -51
- package/server/services/integrations/registry.js +2 -0
- package/server/services/integrations/slack/provider.js +98 -9
- package/server/services/integrations/spotify/provider.js +67 -71
- package/server/services/integrations/trello/provider.js +21 -7
- package/server/services/integrations/weather/provider.js +18 -12
- package/server/services/integrations/whatsapp/provider.js +76 -16
- package/server/services/manager.js +110 -1
- package/server/services/memory/embedding_index.js +20 -8
- package/server/services/memory/embeddings.js +151 -90
- package/server/services/memory/ingestion.js +50 -9
- package/server/services/memory/ingestion_documents.js +13 -3
- package/server/services/memory/manager.js +52 -19
- package/server/services/messaging/automation.js +85 -10
- package/server/services/messaging/formatting_guides.js +7 -4
- package/server/services/messaging/http_platforms.js +33 -13
- package/server/services/messaging/inbound_queue.js +78 -24
- package/server/services/messaging/inbound_store.js +224 -0
- package/server/services/messaging/manager.js +326 -51
- package/server/services/messaging/typing_keepalive.js +5 -2
- package/server/services/messaging/whatsapp.js +22 -14
- package/server/services/network/http.js +210 -0
- package/server/services/network/safe_request.js +307 -0
- package/server/services/runtime/backends/local-vm.js +227 -67
- package/server/services/runtime/docker-vm-manager.js +9 -0
- package/server/services/runtime/guest_bootstrap.js +30 -4
- package/server/services/runtime/guest_image.js +43 -12
- package/server/services/runtime/manager.js +77 -23
- package/server/services/runtime/validation.js +7 -6
- package/server/services/security/tool_categories.js +6 -0
- package/server/services/social_reach/channels/github.js +10 -4
- package/server/services/social_reach/channels/reddit.js +4 -4
- package/server/services/social_reach/channels/rss.js +2 -2
- package/server/services/social_reach/channels/social_video.js +12 -7
- package/server/services/social_reach/channels/v2ex.js +21 -8
- package/server/services/social_reach/channels/x.js +2 -2
- package/server/services/social_reach/channels/xueqiu.js +5 -5
- package/server/services/social_reach/service.js +9 -6
- package/server/services/social_reach/utils.js +65 -14
- package/server/services/social_video/service.js +160 -50
- package/server/services/tasks/integration_runtime.js +18 -8
- package/server/services/tasks/runtime.js +39 -4
- package/server/services/voice/agentBridge.js +17 -4
- package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
- package/server/services/voice/liveSession.js +31 -0
- package/server/services/voice/message.js +1 -1
- package/server/services/voice/openaiSpeech.js +33 -8
- package/server/services/voice/providers.js +233 -151
- package/server/services/voice/runtime.js +2 -2
- package/server/services/voice/runtimeManager.js +118 -20
- package/server/services/voice/turnRunner.js +6 -0
- package/server/services/wearable/firmware_manifest.js +51 -13
- package/server/services/wearable/service.js +1 -0
- package/server/utils/abort.js +96 -0
- package/server/utils/cloud-security.js +110 -3
- package/server/utils/files.js +31 -0
- package/server/utils/image_payload.js +95 -0
- 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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
245
|
+
throw responseError(errorPrefix, response.status, body.toString('utf8'));
|
|
232
246
|
}
|
|
233
247
|
return {
|
|
234
|
-
audioBytes:
|
|
248
|
+
audioBytes: body,
|
|
235
249
|
mimeType: response.headers.get('content-type') || defaultMimeType,
|
|
236
250
|
};
|
|
237
251
|
}
|
|
238
252
|
|
|
239
|
-
async function fetchAudioStreamOrThrow(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
await onChunk(
|
|
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
|
|
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
|
-
|
|
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
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
549
|
-
|
|
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
|
|
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
|
|
678
|
-
`${GEMINI_API_BASE_URL}/${encodeURIComponent(model)}:streamGenerateContent?alt=sse
|
|
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: {
|
|
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
|
-
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
const
|
|
709
|
-
if (
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
914
|
+
await runWithAbortTimeout(async (signal) => {
|
|
915
|
+
const requestOptions = { ...options, signal };
|
|
838
916
|
if (provider === 'openai') {
|
|
839
|
-
await streamWithOpenAi(chunk, model, voice,
|
|
917
|
+
await streamWithOpenAi(chunk, model, voice, requestOptions, onChunk);
|
|
840
918
|
} else if (provider === 'deepgram') {
|
|
841
|
-
await streamWithDeepgram(chunk, model,
|
|
919
|
+
await streamWithDeepgram(chunk, model, requestOptions, onChunk);
|
|
842
920
|
} else {
|
|
843
|
-
await streamWithGemini(chunk, model, voice,
|
|
921
|
+
await streamWithGemini(chunk, model, voice, requestOptions, onChunk);
|
|
844
922
|
}
|
|
845
|
-
}
|
|
846
|
-
|
|
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
|
|
|
@@ -38,10 +38,10 @@ function buildVoiceMessagingPrompt(msg = {}) {
|
|
|
38
38
|
formattingGuide,
|
|
39
39
|
'',
|
|
40
40
|
'Send send_interim_update immediately with a brief spoken acknowledgment — do not leave silence while working.',
|
|
41
|
-
'Keep interim updates short (one sentence). Spoken language only: no bullet points, no markdown, no lists.',
|
|
41
|
+
'Keep interim updates short (one sentence), warm and natural. Spoken language only: no bullet points, no markdown, no lists.',
|
|
42
42
|
'If the task takes time, give one short update then work, do not narrate every step.',
|
|
43
43
|
`Finish with send_message platform="${msg.platform}" to="${msg.chatId}".`,
|
|
44
|
-
'Final reply must be natural spoken language. Contractions, direct address, and short sentences.',
|
|
44
|
+
'Final reply must be natural spoken language, like a favorite contact talking, not a helpdesk script. Contractions, direct address, and short sentences.',
|
|
45
45
|
].join('\n');
|
|
46
46
|
}
|
|
47
47
|
|