@openclaw/voice-call 2026.9.7 → 2026.10.1-beta.1

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 (41) hide show
  1. package/dist/.setup/{cli-D6uwMXyS.mjs → cli-Cn1leykM.mjs} +57 -37
  2. package/dist/.setup/{config-Ds7xoZbI.mjs → config-CX9J-053.mjs} +18 -119
  3. package/dist/.setup/{guarded-json-api-Bqc6QS8A.mjs → guarded-json-api-x55yqHvW.mjs} +39 -94
  4. package/dist/.setup/{mock-ClWW6Ufu.mjs → mock-eLv_Dc4o.mjs} +3 -6
  5. package/dist/.setup/{plivo-69ObrQPk.mjs → plivo-CpPIENu4.mjs} +48 -101
  6. package/dist/.setup/{realtime-handler-BgDPcPZt.mjs → realtime-handler-Do7Sz7Gh.mjs} +123 -235
  7. package/dist/.setup/{response-generator-BzKrJMEb.mjs → response-generator-DAhbXEta.mjs} +6 -25
  8. package/dist/.setup/{runtime-entry-BNOQRXii.mjs → runtime-entry-CbSKxSKk.mjs} +554 -425
  9. package/dist/.setup/state-retention-api-ONgqE-_V.mjs +201 -0
  10. package/dist/.setup/store-path-Cfwff6j7.mjs +33 -0
  11. package/dist/.setup/{telnyx-DjFGCfy1.mjs → telnyx-DPilutCH.mjs} +10 -29
  12. package/dist/.setup/{twilio-Bp3gfmUB.mjs → twilio-BWFNGyVx.mjs} +78 -201
  13. package/dist/.setup/{twilio-region-4fkgz3UG.mjs → twilio-region-wBOx-8sz.mjs} +1 -4
  14. package/dist/.setup/voice-mapping-B_iAUkER.mjs +31 -0
  15. package/dist/.setup/{webhook-replay-CCQSoBNF.mjs → webhook-replay-B7RJNWNG.mjs} +2 -5
  16. package/dist/doctor-contract-api.js +7 -330
  17. package/dist/index.js +18 -16
  18. package/dist/runtime-entry.js +1 -1
  19. package/dist/state-retention-api.js +2 -0
  20. package/openclaw.plugin.json +1 -1
  21. package/package.json +4 -6
  22. package/dist/.setup/utils-B5pnM_Ij.mjs +0 -437
  23. package/node_modules/ws/LICENSE +0 -20
  24. package/node_modules/ws/README.md +0 -548
  25. package/node_modules/ws/browser.js +0 -8
  26. package/node_modules/ws/index.js +0 -22
  27. package/node_modules/ws/lib/buffer-util.js +0 -131
  28. package/node_modules/ws/lib/constants.js +0 -19
  29. package/node_modules/ws/lib/event-target.js +0 -292
  30. package/node_modules/ws/lib/extension.js +0 -203
  31. package/node_modules/ws/lib/limiter.js +0 -55
  32. package/node_modules/ws/lib/permessage-deflate.js +0 -530
  33. package/node_modules/ws/lib/receiver.js +0 -743
  34. package/node_modules/ws/lib/sender.js +0 -607
  35. package/node_modules/ws/lib/stream.js +0 -161
  36. package/node_modules/ws/lib/subprotocol.js +0 -62
  37. package/node_modules/ws/lib/validation.js +0 -152
  38. package/node_modules/ws/lib/websocket-server.js +0 -562
  39. package/node_modules/ws/lib/websocket.js +0 -1407
  40. package/node_modules/ws/package.json +0 -74
  41. package/node_modules/ws/wrapper.mjs +0 -21
@@ -1,7 +1,7 @@
1
1
  import { isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, sendHttpRequestRejection } from "../api.js";
2
- import { a as resolveVoiceCallEffectiveConfig, c as resolveVoiceCallSessionKey, f as resolveVoiceCallRealtimeTools, i as resolveVoiceCallConfig, l as resolveVoiceCallStreamExposurePaths, m as normalizePhoneNumber, n as normalizeVoiceCallConfig, o as resolveVoiceCallNumberRouteKeyForCall, p as isAllowlistedCaller, r as resolveTwilioAuthToken, u as validateProviderConfig } from "./config-Ds7xoZbI.mjs";
3
- import { S as TerminalStates, _ as appendCallReplayKey, b as reserveRejectedProviderCall, d as findCallInStore, f as getCallHistoryFromStore, h as persistCallRecord, n as resolveDefaultVoiceCallStoreDir, p as loadActiveCallsFromStore, t as resolveUserPath, v as releaseRejectedProviderCall, x as setVoiceCallStateRuntime, y as rememberManagerReplayKey } from "./utils-B5pnM_Ij.mjs";
4
- import { createRequire } from "node:module";
2
+ import { a as resolveVoiceCallEffectiveConfig, c as resolveVoiceCallSessionKey, f as resolveVoiceCallRealtimeTools, i as resolveVoiceCallConfig, l as resolveVoiceCallStreamExposurePaths, m as normalizePhoneNumber, n as normalizeVoiceCallConfig, o as resolveVoiceCallNumberRouteKeyForCall, p as isAllowlistedCaller, r as resolveTwilioAuthToken, u as validateProviderConfig } from "./config-CX9J-053.mjs";
3
+ import { n as mapVoiceToPolly, t as escapeXml } from "./voice-mapping-B_iAUkER.mjs";
4
+ import { r as resolveUserPath, t as resolveDefaultVoiceCallStoreDir } from "./store-path-Cfwff6j7.mjs";
5
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
6
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
7
7
  import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
@@ -10,22 +10,80 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
10
10
  import { listAgentIds, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime";
11
11
  import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
12
12
  import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, assertRealtimeVoiceAgentConsultModelSelectionUnlocked, buildRealtimeVoiceAgentConsultPolicyInstructions, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createTalkSessionController, recordTalkObservabilityEvent, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceFastContextConsult } from "openclaw/plugin-sdk/realtime-voice";
13
- import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
13
+ import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath, rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
14
+ import { z } from "zod";
14
15
  import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
15
16
  import fs from "node:fs";
16
17
  import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
17
- import crypto from "node:crypto";
18
+ import crypto, { randomUUID } from "node:crypto";
18
19
  import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
19
- import path from "node:path";
20
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
20
21
  import * as realtimeBootstrapSdk from "openclaw/plugin-sdk/realtime-bootstrap-context";
21
22
  import { spawn } from "node:child_process";
22
23
  import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
23
24
  import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
24
25
  import http from "node:http";
25
26
  import { URL as URL$1 } from "node:url";
27
+ import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
26
28
  import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
27
- import { rejectWebSocketUpgrade } from "openclaw/plugin-sdk/websocket-runtime";
29
+ import { WebSocket, WebSocketServer, rejectWebSocketUpgrade } from "openclaw/plugin-sdk/websocket-runtime";
30
+ import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
28
31
  import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime";
32
+ //#region extensions/voice-call/src/types.ts
33
+ const ProviderNameSchema = z.enum([
34
+ "telnyx",
35
+ "twilio",
36
+ "plivo",
37
+ "mock"
38
+ ]);
39
+ const EndReasonSchema = z.enum([
40
+ "completed",
41
+ "hangup-user",
42
+ "hangup-bot",
43
+ "timeout",
44
+ "error",
45
+ "failed",
46
+ "no-answer",
47
+ "busy",
48
+ "voicemail"
49
+ ]);
50
+ const CallStateSchema = z.enum([
51
+ "initiated",
52
+ "ringing",
53
+ "answered",
54
+ "active",
55
+ "speaking",
56
+ "listening",
57
+ ...EndReasonSchema.options
58
+ ]);
59
+ const TerminalStates = new Set(EndReasonSchema.options);
60
+ const CallDirectionSchema = z.enum(["outbound", "inbound"]);
61
+ const TranscriptEntrySchema = z.object({
62
+ timestamp: z.number(),
63
+ speaker: z.enum(["bot", "user"]),
64
+ text: z.string(),
65
+ isFinal: z.boolean().default(true)
66
+ });
67
+ const CallRecordSchema = z.object({
68
+ callId: z.string(),
69
+ providerCallId: z.string().optional(),
70
+ provider: ProviderNameSchema,
71
+ direction: CallDirectionSchema,
72
+ state: CallStateSchema,
73
+ from: z.string(),
74
+ to: z.string(),
75
+ sessionKey: z.string().optional(),
76
+ /** Agent selected when the call was created; optional only for retained history. */
77
+ agentId: z.string().optional(),
78
+ startedAt: z.number(),
79
+ answeredAt: z.number().optional(),
80
+ endedAt: z.number().optional(),
81
+ endReason: EndReasonSchema.optional(),
82
+ transcript: z.array(TranscriptEntrySchema).default([]),
83
+ processedEventIds: z.array(z.string()).default([]),
84
+ metadata: z.record(z.string(), z.unknown()).optional()
85
+ });
86
+ //#endregion
29
87
  //#region extensions/voice-call/src/manager/state.ts
30
88
  const ConversationStates = /* @__PURE__ */ new Set(["speaking", "listening"]);
31
89
  const StateOrder = [
@@ -38,25 +96,15 @@ const StateOrder = [
38
96
  ];
39
97
  function transitionState(call, newState) {
40
98
  if (call.state === newState || TerminalStates.has(call.state)) return;
41
- if (TerminalStates.has(newState)) {
42
- call.state = newState;
43
- return;
44
- }
45
- if (ConversationStates.has(call.state) && ConversationStates.has(newState)) {
46
- call.state = newState;
47
- return;
48
- }
49
- const currentIndex = StateOrder.indexOf(call.state);
50
- if (StateOrder.indexOf(newState) > currentIndex) call.state = newState;
99
+ if (TerminalStates.has(newState) || ConversationStates.has(call.state) && ConversationStates.has(newState) || StateOrder.indexOf(newState) > StateOrder.indexOf(call.state)) call.state = newState;
51
100
  }
52
101
  function addTranscriptEntry(call, speaker, text) {
53
- const entry = {
102
+ call.transcript.push({
54
103
  timestamp: Date.now(),
55
104
  speaker,
56
105
  text,
57
106
  isFinal: true
58
- };
59
- call.transcript.push(entry);
107
+ });
60
108
  }
61
109
  /** Stage persisted changes without exposing uncommitted state through active-call getters. */
62
110
  function copyCallRecord(call) {
@@ -68,6 +116,303 @@ function copyCallRecord(call) {
68
116
  };
69
117
  }
70
118
  //#endregion
119
+ //#region extensions/voice-call/src/runtime-state.ts
120
+ const { setRuntime: setVoiceCallStateRuntime, tryGetRuntime: getOptionalVoiceCallStateRuntime } = createPluginRuntimeStore({
121
+ pluginId: "voice-call-state",
122
+ errorMessage: "Voice Call state runtime not initialized"
123
+ });
124
+ //#endregion
125
+ //#region extensions/voice-call/src/manager/replay-keys.ts
126
+ /** Match the existing voice-call webhook replay-cache cardinality. */
127
+ const MAX_MANAGER_REPLAY_KEYS = 1e4;
128
+ function pruneOldestEntries(keys, maxEntries) {
129
+ while (keys.size > maxEntries) {
130
+ const oldest = keys.keys().next().value;
131
+ if (oldest === void 0) break;
132
+ keys.delete(oldest);
133
+ }
134
+ }
135
+ /** Remember one manager replay key without refreshing duplicate insertion order. */
136
+ function rememberManagerReplayKey(keys, key, maxEntries = MAX_MANAGER_REPLAY_KEYS) {
137
+ keys.add(key);
138
+ pruneOldestEntries(keys, maxEntries);
139
+ }
140
+ /** Append one per-call replay key and retain only the newest bounded suffix. */
141
+ function appendCallReplayKey(keys, key, maxEntries = 500) {
142
+ keys.push(key);
143
+ trimCallReplayKeys(keys, maxEntries);
144
+ }
145
+ /** Normalize restored or externally constructed call replay history in place. */
146
+ function trimCallReplayKeys(keys, maxEntries = 500) {
147
+ const overflow = keys.length - maxEntries;
148
+ if (overflow > 0) keys.splice(0, overflow);
149
+ }
150
+ /**
151
+ * Reserve one rejected provider call. The token prevents a stale failed
152
+ * hangup from deleting a newer reservation after bounded eviction and reuse.
153
+ */
154
+ function reserveRejectedProviderCall(calls, providerCallId, maxEntries = MAX_MANAGER_REPLAY_KEYS) {
155
+ if (calls.has(providerCallId)) return;
156
+ const reservation = Symbol(providerCallId);
157
+ calls.set(providerCallId, reservation);
158
+ pruneOldestEntries(calls, maxEntries);
159
+ return reservation;
160
+ }
161
+ /** Release a rejected-call reservation only when the failing attempt still owns it. */
162
+ function releaseRejectedProviderCall(calls, providerCallId, reservation) {
163
+ if (calls.get(providerCallId) === reservation) calls.delete(providerCallId);
164
+ }
165
+ //#endregion
166
+ //#region extensions/voice-call/src/manager/store.ts
167
+ const CALL_RECORD_EVENTS_NAMESPACE = "call-record-events";
168
+ const CALL_RECORD_EVENT_CHUNKS_NAMESPACE = "call-record-event-chunks";
169
+ const MAX_CALL_RECORD_EVENTS = 1e3;
170
+ /** Extra metadata entries retained so pruning can safely trim oldest rows. */
171
+ const CALL_RECORD_EVENT_META_MAX_ENTRIES = 1100;
172
+ const MAX_CHUNKS_PER_CALL_RECORD_EVENT = 48;
173
+ const CALL_RECORD_CHUNK_MAX_ENTRIES = 48048;
174
+ /** Raw UTF-8 bytes stored per call record chunk before base64 encoding. */
175
+ const RAW_CALL_RECORD_CHUNK_BYTES = 48128;
176
+ const CALL_RECORD_READ_BATCH_KEYS = 128;
177
+ let callRecordEventSequence = 0;
178
+ function createCallRecordStateStores(storePath, stateRuntime) {
179
+ const state = stateRuntime ?? getOptionalVoiceCallStateRuntime()?.state;
180
+ if (!state) throw new Error("Voice Call state runtime not initialized");
181
+ const env = {
182
+ ...process.env,
183
+ OPENCLAW_STATE_DIR: storePath
184
+ };
185
+ return {
186
+ events: state.openKeyedStore({
187
+ namespace: CALL_RECORD_EVENTS_NAMESPACE,
188
+ maxEntries: CALL_RECORD_EVENT_META_MAX_ENTRIES,
189
+ env
190
+ }),
191
+ chunks: state.openKeyedStore({
192
+ namespace: CALL_RECORD_EVENT_CHUNKS_NAMESPACE,
193
+ maxEntries: CALL_RECORD_CHUNK_MAX_ENTRIES,
194
+ env
195
+ })
196
+ };
197
+ }
198
+ /** Open call stores and log failures instead of breaking restore paths. */
199
+ function tryCreateCallRecordStateStores(storePath, stateRuntime) {
200
+ try {
201
+ return createCallRecordStateStores(storePath, stateRuntime);
202
+ } catch (err) {
203
+ console.error("[voice-call] Failed to open SQLite call record store:", err);
204
+ return null;
205
+ }
206
+ }
207
+ function buildChunkKey(eventKey, index) {
208
+ return `${eventKey}:chunk:${String(index).padStart(4, "0")}`;
209
+ }
210
+ /** Allocate monotonic ordering metadata for newly persisted call records. */
211
+ function nextCallRecordOrder() {
212
+ const sequence = callRecordEventSequence;
213
+ callRecordEventSequence = (callRecordEventSequence + 1) % 1e6;
214
+ return {
215
+ persistedAt: Date.now(),
216
+ sequence
217
+ };
218
+ }
219
+ function parseEventKeySequence(key) {
220
+ const sequence = /^event:[^:]+:(\d+):/.exec(key)?.[1];
221
+ return sequence ? Number.parseInt(sequence, 10) : 0;
222
+ }
223
+ function countCallRecordChunks(call) {
224
+ return Math.max(1, Math.ceil(Buffer.byteLength(JSON.stringify(call), "utf8") / RAW_CALL_RECORD_CHUNK_BYTES));
225
+ }
226
+ /** Truncate oversized call records to fit the bounded plugin state chunk budget. */
227
+ function prepareVoiceCallRecordForStorage(call) {
228
+ let boundedCall = call;
229
+ if (call.processedEventIds.length > 500) {
230
+ boundedCall = {
231
+ ...call,
232
+ processedEventIds: [...call.processedEventIds]
233
+ };
234
+ trimCallReplayKeys(boundedCall.processedEventIds);
235
+ }
236
+ if (countCallRecordChunks(boundedCall) <= MAX_CHUNKS_PER_CALL_RECORD_EVENT) return boundedCall;
237
+ const transcriptEntries = boundedCall.transcript.length;
238
+ const metadata = {
239
+ ...boundedCall.metadata,
240
+ voiceCallPersistence: {
241
+ transcriptTruncated: true,
242
+ originalTranscriptEntries: transcriptEntries
243
+ }
244
+ };
245
+ const candidateInputs = [
246
+ {
247
+ transcript: call.transcript.slice(-20),
248
+ metadata
249
+ },
250
+ {
251
+ transcript: [],
252
+ metadata
253
+ },
254
+ {
255
+ transcript: [],
256
+ metadata: { voiceCallPersistence: {
257
+ transcriptTruncated: true,
258
+ originalTranscriptEntries: transcriptEntries,
259
+ metadataTruncated: true
260
+ } }
261
+ }
262
+ ];
263
+ for (const candidateInput of candidateInputs) {
264
+ const candidate = CallRecordSchema.parse({
265
+ ...boundedCall,
266
+ ...candidateInput
267
+ });
268
+ if (countCallRecordChunks(candidate) <= MAX_CHUNKS_PER_CALL_RECORD_EVENT) return candidate;
269
+ }
270
+ return boundedCall;
271
+ }
272
+ /** Encode one bounded record; chunks are produced only when requested by the writer. */
273
+ function encodeCallRecordEvent(call) {
274
+ const serialized = JSON.stringify(prepareVoiceCallRecordForStorage(call));
275
+ const buffer = Buffer.from(serialized, "utf8");
276
+ const chunkCount = Math.max(1, Math.ceil(buffer.byteLength / RAW_CALL_RECORD_CHUNK_BYTES));
277
+ if (chunkCount > MAX_CHUNKS_PER_CALL_RECORD_EVENT) throw new Error(`voice-call record exceeds SQLite chunk limit (${chunkCount}/${MAX_CHUNKS_PER_CALL_RECORD_EVENT})`);
278
+ return {
279
+ meta: {
280
+ chunkCount,
281
+ byteLength: buffer.byteLength
282
+ },
283
+ chunk(index) {
284
+ return {
285
+ index,
286
+ dataBase64: buffer.subarray(index * RAW_CALL_RECORD_CHUNK_BYTES, (index + 1) * RAW_CALL_RECORD_CHUNK_BYTES).toString("base64")
287
+ };
288
+ }
289
+ };
290
+ }
291
+ async function pruneCallRecordEvents(stores) {
292
+ if (stores.events.count && await stores.events.count() <= 1e3) return;
293
+ const rows = await stores.events.entries();
294
+ if (rows.length <= 1e3) return;
295
+ const sorted = rows.toSorted((a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key));
296
+ for (const row of sorted.slice(0, rows.length - MAX_CALL_RECORD_EVENTS)) {
297
+ const meta = await stores.events.lookup(row.key);
298
+ await stores.events.delete(row.key);
299
+ if (meta) for (let index = 0; index < meta.chunkCount; index += 1) await stores.chunks.delete(buildChunkKey(row.key, index));
300
+ }
301
+ }
302
+ function isValidCallRecordChunkCount(chunkCount) {
303
+ return Number.isSafeInteger(chunkCount) && chunkCount >= 1 && chunkCount <= MAX_CHUNKS_PER_CALL_RECORD_EVENT;
304
+ }
305
+ async function readCallRecordEvent(stores, eventKey, meta, records) {
306
+ if (!isValidCallRecordChunkCount(meta.chunkCount)) return null;
307
+ const chunks = [];
308
+ for (let index = 0; index < meta.chunkCount; index += 1) {
309
+ const result = records?.[index];
310
+ if (result && !result.ok) throw result.error;
311
+ const chunk = records ? result?.value : await stores.chunks.lookup(buildChunkKey(eventKey, index));
312
+ if (!chunk || chunk.index !== index) return null;
313
+ chunks.push(Buffer.from(chunk.dataBase64, "base64"));
314
+ }
315
+ const serialized = Buffer.concat(chunks, meta.byteLength).toString("utf8");
316
+ try {
317
+ return CallRecordSchema.parse(JSON.parse(serialized));
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+ /** Read all persisted call records in stable persisted order. */
323
+ async function readCallRecordEvents(stores) {
324
+ const entries = (await stores.events.entries()).toSorted((a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key));
325
+ const sqliteCalls = [];
326
+ let batchEnd = 0;
327
+ let chunkOffset = 0;
328
+ let chunkRecords;
329
+ for (const [entryIndex, entry] of entries.entries()) {
330
+ if (entryIndex >= batchEnd && stores.chunks.lookupMany) {
331
+ const keys = [];
332
+ for (let next = entryIndex;; next++) {
333
+ const row = entries[next];
334
+ if (!row) break;
335
+ const chunkCount = row.value?.chunkCount;
336
+ if (!isValidCallRecordChunkCount(chunkCount) || keys.length + chunkCount > CALL_RECORD_READ_BATCH_KEYS) break;
337
+ for (let index = 0; index < chunkCount; index++) keys.push(buildChunkKey(row.key, index));
338
+ batchEnd = next + 1;
339
+ }
340
+ chunkRecords = keys.length > 0 ? await stores.chunks.lookupMany(keys) : void 0;
341
+ chunkOffset = 0;
342
+ }
343
+ const records = chunkRecords?.slice(chunkOffset, chunkOffset + entry.value.chunkCount);
344
+ const call = await readCallRecordEvent(stores, entry.key, entry.value, records);
345
+ if (chunkRecords) chunkOffset += entry.value.chunkCount;
346
+ if (call) sqliteCalls.push({
347
+ call,
348
+ persistedAt: entry.value.persistedAt ?? entry.createdAt,
349
+ sequence: entry.value.sequence ?? parseEventKeySequence(entry.key),
350
+ orderKey: entry.key
351
+ });
352
+ }
353
+ return sqliteCalls.toSorted((a, b) => a.persistedAt - b.persistedAt || a.sequence - b.sequence || a.orderKey.localeCompare(b.orderKey)).map((entry) => entry.call);
354
+ }
355
+ async function persistCallRecord(storePath, call, stateRuntime) {
356
+ try {
357
+ const stores = createCallRecordStateStores(storePath, stateRuntime);
358
+ const order = nextCallRecordOrder();
359
+ const eventKey = `event:${order.persistedAt.toString(36)}:${String(order.sequence).padStart(6, "0")}:${randomUUID()}`;
360
+ const encoded = encodeCallRecordEvent(call);
361
+ for (let index = 0; index < encoded.meta.chunkCount; index += 1) await stores.chunks.register(buildChunkKey(eventKey, index), encoded.chunk(index));
362
+ await stores.events.register(eventKey, {
363
+ ...encoded.meta,
364
+ ...order
365
+ });
366
+ await pruneCallRecordEvents(stores);
367
+ } catch (err) {
368
+ console.error("[voice-call] Failed to persist call record:", err);
369
+ throw err;
370
+ }
371
+ }
372
+ /** Restore nonterminal active calls and event indexes from persisted records. */
373
+ async function loadActiveCallsFromStore(storePath, stateRuntime) {
374
+ const stores = tryCreateCallRecordStateStores(storePath, stateRuntime);
375
+ let calls = [];
376
+ try {
377
+ calls = stores ? await readCallRecordEvents(stores) : [];
378
+ } catch (err) {
379
+ console.error("[voice-call] Failed to read SQLite call records:", err);
380
+ }
381
+ const callMap = /* @__PURE__ */ new Map();
382
+ for (const call of calls) {
383
+ callMap.delete(call.callId);
384
+ callMap.set(call.callId, call);
385
+ }
386
+ const activeCalls = /* @__PURE__ */ new Map();
387
+ const processedEventIds = /* @__PURE__ */ new Set();
388
+ for (const [callId, call] of callMap) {
389
+ trimCallReplayKeys(call.processedEventIds);
390
+ for (const eventId of call.processedEventIds) rememberManagerReplayKey(processedEventIds, eventId);
391
+ if (TerminalStates.has(call.state)) continue;
392
+ activeCalls.set(callId, call);
393
+ }
394
+ return {
395
+ activeCalls,
396
+ processedEventIds
397
+ };
398
+ }
399
+ /** Resolve an internal ID or retained provider alias to its newest logical call snapshot. */
400
+ async function findCallInStore(storePath, callId, stateRuntime) {
401
+ const calls = await readCallRecordEvents(createCallRecordStateStores(storePath, stateRuntime));
402
+ const match = calls.findLast((call) => call.callId === callId) ?? calls.findLast((call) => call.providerCallId === callId);
403
+ return match ? calls.findLast((call) => call.callId === match.callId) : void 0;
404
+ }
405
+ async function getCallHistoryFromStore(storePath, limit = 50, stateRuntime) {
406
+ if (limit <= 0) return [];
407
+ const stores = tryCreateCallRecordStateStores(storePath, stateRuntime);
408
+ if (stores) try {
409
+ return (await readCallRecordEvents(stores)).slice(-limit);
410
+ } catch (err) {
411
+ console.error("[voice-call] Failed to read SQLite call history:", err);
412
+ }
413
+ return [];
414
+ }
415
+ //#endregion
71
416
  //#region extensions/voice-call/src/manager/timer-delays.ts
72
417
  /** Convert seconds to a safe timeout delay in milliseconds. */
73
418
  function resolveVoiceCallSecondsTimerDelayMs(seconds, minMs = 1) {
@@ -151,17 +496,11 @@ function waitForFinalTranscript(ctx, callId, turnToken) {
151
496
  //#endregion
152
497
  //#region extensions/voice-call/src/manager/lifecycle.ts
153
498
  const log$1 = createSubsystemLogger("voice-call/lifecycle");
154
- /** Remove a provider-call mapping only when it still points at this call. */
155
- function removeProviderCallMapping(providerCallIdMap, call) {
156
- if (!call.providerCallId) return;
157
- if (providerCallIdMap.get(call.providerCallId) === call.callId) providerCallIdMap.delete(call.providerCallId);
158
- }
159
499
  /** Finalize under the manager mutation queue, publishing cleanup only after persistence. */
160
500
  async function finalizeCall(params) {
161
501
  const { ctx, call, endReason } = params;
162
502
  if (ctx.activeCalls.get(call.callId) !== call) return;
163
- const previousState = call.state;
164
- if (!TerminalStates.has(previousState)) {
503
+ if (!TerminalStates.has(call.state)) {
165
504
  const next = copyCallRecord(params.preparedCall ?? call);
166
505
  next.endedAt = params.endedAt ?? Date.now();
167
506
  next.endReason = endReason;
@@ -178,7 +517,7 @@ async function finalizeCall(params) {
178
517
  }
179
518
  if (ctx.transcriptWaiters) rejectTranscriptWaiter({ transcriptWaiters: ctx.transcriptWaiters }, call.callId, params.transcriptRejectReason ?? `Call ended: ${endReason}`);
180
519
  ctx.activeCalls.delete(call.callId);
181
- removeProviderCallMapping(ctx.providerCallIdMap, call);
520
+ if (call.providerCallId && ctx.providerCallIdMap.get(call.providerCallId) === call.callId) ctx.providerCallIdMap.delete(call.providerCallId);
182
521
  }
183
522
  //#endregion
184
523
  //#region extensions/voice-call/src/manager/lookup.ts
@@ -200,52 +539,11 @@ function findCall(params) {
200
539
  }
201
540
  //#endregion
202
541
  //#region extensions/voice-call/src/tts-provider-voice.ts
203
- /** Read voice setting aliases from one provider-specific config block. */
204
- function resolveProviderVoiceSetting(providerConfig) {
205
- if (!providerConfig || typeof providerConfig !== "object") return;
206
- const candidate = providerConfig;
207
- return normalizeOptionalString(candidate.speakerVoice) ?? normalizeOptionalString(candidate.speakerVoiceId) ?? normalizeOptionalString(candidate.voice) ?? normalizeOptionalString(candidate.voiceId);
208
- }
209
542
  /** Resolve the active provider's preferred voice id/name from voice-call TTS config. */
210
543
  function resolvePreferredTtsVoice(config) {
211
544
  const providerId = config.tts?.provider;
212
- if (!providerId) return;
213
- return resolveProviderVoiceSetting(config.tts?.providers?.[providerId]);
214
- }
215
- //#endregion
216
- //#region extensions/voice-call/src/voice-mapping.ts
217
- /**
218
- * Escape XML special characters for TwiML and other XML responses.
219
- */
220
- function escapeXml(text) {
221
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
222
- }
223
- /**
224
- * Map of OpenAI voice names to similar Twilio Polly voices.
225
- */
226
- const OPENAI_TO_POLLY_MAP = /* @__PURE__ */ new Map([
227
- ["alloy", "Polly.Joanna"],
228
- ["echo", "Polly.Matthew"],
229
- ["fable", "Polly.Amy"],
230
- ["onyx", "Polly.Brian"],
231
- ["nova", "Polly.Salli"],
232
- ["shimmer", "Polly.Kimberly"]
233
- ]);
234
- /**
235
- * Default Polly voice when no mapping is found.
236
- */
237
- const DEFAULT_POLLY_VOICE = "Polly.Joanna";
238
- /**
239
- * Map OpenAI voice names to Twilio Polly equivalents.
240
- * Falls through if already a valid Polly/Google voice.
241
- *
242
- * @param voice - OpenAI voice name (alloy, echo, etc.) or Polly voice name
243
- * @returns Polly voice name suitable for Twilio TwiML
244
- */
245
- function mapVoiceToPolly(voice) {
246
- if (!voice) return DEFAULT_POLLY_VOICE;
247
- if (voice.startsWith("Polly.") || voice.startsWith("Google.")) return voice;
248
- return OPENAI_TO_POLLY_MAP.get(normalizeLowercaseStringOrEmpty(voice)) ?? DEFAULT_POLLY_VOICE;
545
+ const candidate = providerId ? config.tts?.providers?.[providerId] : void 0;
546
+ return normalizeOptionalString(candidate?.speakerVoice) ?? normalizeOptionalString(candidate?.speakerVoiceId) ?? normalizeOptionalString(candidate?.voice) ?? normalizeOptionalString(candidate?.voiceId);
249
547
  }
250
548
  //#endregion
251
549
  //#region extensions/voice-call/src/manager/mutations.ts
@@ -314,8 +612,7 @@ function isCurrentCall(ctx, call) {
314
612
  function validateDtmfDigits(digits) {
315
613
  return /^[0-9*#wWpP,]+$/.test(digits) ? null : "digits may only contain digits, *, #, comma, w, p";
316
614
  }
317
- async function initiateCall(ctx, to, sessionKey, options) {
318
- const opts = typeof options === "string" ? { message: options } : options ?? {};
615
+ async function initiateCall(ctx, to, sessionKey, opts = {}) {
319
616
  const initialMessage = opts.message;
320
617
  const mode = opts.mode ?? ctx.config.outbound.defaultMode;
321
618
  const dtmfSequence = opts.dtmfSequence;
@@ -350,7 +647,7 @@ async function initiateCall(ctx, to, sessionKey, options) {
350
647
  error: `Maximum concurrent calls (${ctx.config.maxConcurrentCalls}) reached`
351
648
  };
352
649
  const callId = crypto.randomUUID();
353
- const from = ctx.config.fromNumber || (ctx.provider?.name === "mock" ? "+15550000000" : void 0);
650
+ const from = ctx.config.fromNumber || (ctx.provider.name === "mock" ? "+15550000000" : void 0);
354
651
  if (!from) return {
355
652
  callId: "",
356
653
  success: false,
@@ -483,13 +780,12 @@ async function speak(ctx, callId, text, options) {
483
780
  });
484
781
  const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
485
782
  const voice = resolvePreferredTtsVoice(resolveVoiceCallEffectiveConfig(ctx.config, numberRouteKey).config);
486
- const playbackOptions = options?.listenAfterPlayback ? { listenAfterPlayback: true } : {};
487
783
  await provider.playTts({
488
784
  callId,
489
785
  providerCallId: call.providerCallId ?? providerCallId,
490
786
  text,
491
787
  voice,
492
- ...playbackOptions
788
+ ...options?.listenAfterPlayback ? { listenAfterPlayback: true } : {}
493
789
  });
494
790
  if (!await updateCall(ctx, call, (next) => addTranscriptEntry(next, "bot", text))) return {
495
791
  success: false,
@@ -506,10 +802,8 @@ async function speak(ctx, callId, text, options) {
506
802
  };
507
803
  }
508
804
  }
509
- function shouldStartListeningAfterInitialMessage(ctx) {
510
- if (ctx.provider?.name !== "twilio") return true;
511
- if (!ctx.config.streaming.enabled) return true;
512
- return ctx.provider.isConversationStreamConnectEnabled?.() !== true;
805
+ function hasConversationStreamConnect(provider, config) {
806
+ return provider?.name === "twilio" && config.streaming.enabled && provider.isConversationStreamConnectEnabled?.() === true;
513
807
  }
514
808
  async function sendDtmf(ctx, callId, digits) {
515
809
  const validationError = validateDtmfDigits(digits);
@@ -595,7 +889,7 @@ async function speakInitialMessage(ctx, providerCallId) {
595
889
  ctx.trackCallWork(work);
596
890
  }, delayMs);
597
891
  ctx.notifyHangupTimers.set(call.callId, timer);
598
- } else if (mode === "conversation" && ctx.provider && shouldStartListeningAfterInitialMessage(ctx)) {
892
+ } else if (mode === "conversation" && ctx.provider && !hasConversationStreamConnect(ctx.provider, ctx.config)) {
599
893
  if (!await updateCall(ctx, call, (next) => transitionState(next, "listening")) || !isCurrentCall(ctx, call)) return;
600
894
  if (ctx.isStopping()) throw new Error("Voice Call manager is stopping");
601
895
  await ctx.provider.startListening({
@@ -661,7 +955,7 @@ async function continueCall(ctx, callId, prompt) {
661
955
  success: false,
662
956
  error: "Call has ended"
663
957
  };
664
- console.log("[voice-call] continueCall latency call=" + call.callId + " totalMs=" + String(lastTurnLatencyMs) + " listenWaitMs=" + String(lastTurnListenWaitMs));
958
+ console.log(`[voice-call] continueCall latency call=${call.callId} totalMs=${lastTurnLatencyMs} listenWaitMs=${lastTurnListenWaitMs}`);
665
959
  return {
666
960
  success: true,
667
961
  transcript
@@ -742,8 +1036,7 @@ function shouldAcceptInbound(config, from) {
742
1036
  }
743
1037
  async function createWebhookCall(params) {
744
1038
  const callId = crypto.randomUUID();
745
- const effective = resolveVoiceCallEffectiveConfig(params.ctx.config, params.direction === "inbound" ? params.to : void 0);
746
- const effectiveConfig = effective.config;
1039
+ const { config: effectiveConfig, numberRouteKey } = resolveVoiceCallEffectiveConfig(params.ctx.config, params.direction === "inbound" ? params.to : void 0);
747
1040
  const callRecord = {
748
1041
  callId,
749
1042
  providerCallId: params.providerCallId,
@@ -764,7 +1057,7 @@ async function createWebhookCall(params) {
764
1057
  processedEventIds: [],
765
1058
  metadata: {
766
1059
  initialMessage: params.direction === "inbound" ? effectiveConfig.inboundGreeting || "Hello! How can I help you today?" : void 0,
767
- ...effective.numberRouteKey ? { numberRouteKey: effective.numberRouteKey } : {}
1060
+ ...numberRouteKey ? { numberRouteKey } : {}
768
1061
  }
769
1062
  };
770
1063
  await persistCallRecord(params.ctx.storePath, callRecord, params.ctx.stateRuntime);
@@ -820,9 +1113,8 @@ async function processEventInQueue(ctx, event) {
820
1113
  if (call && providerCallId && providerCallId !== call.providerCallId) {
821
1114
  if ((providerCallId === event.callId && retained ? retained : await findCallInStore(ctx.storePath, providerCallId, ctx.stateRuntime))?.callId === call.callId) providerCallId = call.providerCallId;
822
1115
  }
823
- const eventDirection = event.direction === "inbound" || event.direction === "outbound" ? event.direction : void 0;
824
- if (!call && providerCallId && eventDirection) {
825
- if (eventDirection === "inbound" && !shouldAcceptInbound(ctx.config, event.from)) {
1116
+ if (!call && providerCallId && event.direction) {
1117
+ if (event.direction === "inbound" && !shouldAcceptInbound(ctx.config, event.from)) {
826
1118
  const pid = providerCallId;
827
1119
  if (!ctx.provider) {
828
1120
  log.warn(`Inbound call rejected by policy but no provider to hang up (providerCallId: ${pid}, caller=${redactIdentifier(event.from)}); call will time out on provider side.`);
@@ -855,7 +1147,7 @@ async function processEventInQueue(ctx, event) {
855
1147
  call = await createWebhookCall({
856
1148
  ctx,
857
1149
  providerCallId,
858
- direction: eventDirection === "outbound" ? "outbound" : "inbound",
1150
+ direction: event.direction,
859
1151
  from: event.from || "unknown",
860
1152
  to: event.to || ctx.config.fromNumber || "unknown"
861
1153
  });
@@ -866,7 +1158,7 @@ async function processEventInQueue(ctx, event) {
866
1158
  replayable: true
867
1159
  };
868
1160
  const activeCall = copyCallRecord(call);
869
- const previousCall = { providerCallId: call.providerCallId };
1161
+ const previousProviderCallId = call.providerCallId;
870
1162
  const shouldCommitReplayKey = !(event.type === "call.error" && event.retryable);
871
1163
  const effects = [];
872
1164
  let result = { kind: "processed" };
@@ -884,10 +1176,10 @@ async function processEventInQueue(ctx, event) {
884
1176
  }
885
1177
  };
886
1178
  const publishProviderCallId = (terminal = false) => {
887
- if (!providerCallId || providerCallId === previousCall.providerCallId) return;
1179
+ if (!providerCallId || providerCallId === previousProviderCallId) return;
888
1180
  if (!terminal) ctx.providerCallIdMap.set(providerCallId, activeCall.callId);
889
- if (previousCall.providerCallId) {
890
- if (ctx.providerCallIdMap.get(previousCall.providerCallId) === activeCall.callId) ctx.providerCallIdMap.delete(previousCall.providerCallId);
1181
+ if (previousProviderCallId) {
1182
+ if (ctx.providerCallIdMap.get(previousProviderCallId) === activeCall.callId) ctx.providerCallIdMap.delete(previousProviderCallId);
891
1183
  }
892
1184
  };
893
1185
  if (providerCallId && providerCallId !== activeCall.providerCallId) activeCall.providerCallId = providerCallId;
@@ -963,35 +1255,26 @@ async function processEventInQueue(ctx, event) {
963
1255
  break;
964
1256
  case "call.silence":
965
1257
  case "call.dtmf": break;
1258
+ case "call.error":
966
1259
  case "call.ended":
1260
+ if (event.type === "call.error" && event.retryable) {
1261
+ result = {
1262
+ kind: "processed",
1263
+ replayable: true
1264
+ };
1265
+ break;
1266
+ }
967
1267
  await finalizeCall({
968
1268
  ctx,
969
1269
  call,
970
1270
  preparedCall: activeCall,
971
- endReason: event.reason,
972
- endedAt: event.timestamp
1271
+ endReason: event.type === "call.ended" ? event.reason : "error",
1272
+ endedAt: event.timestamp,
1273
+ transcriptRejectReason: event.type === "call.error" ? `Call error: ${event.error}` : void 0
973
1274
  });
974
1275
  publishProviderCallId(true);
975
1276
  rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
976
1277
  return { kind: "processed" };
977
- case "call.error":
978
- if (!event.retryable) {
979
- await finalizeCall({
980
- ctx,
981
- call,
982
- preparedCall: activeCall,
983
- endReason: "error",
984
- endedAt: event.timestamp,
985
- transcriptRejectReason: `Call error: ${event.error}`
986
- });
987
- publishProviderCallId(true);
988
- rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
989
- return { kind: "processed" };
990
- }
991
- result = {
992
- kind: "processed",
993
- replayable: true
994
- };
995
1278
  }
996
1279
  await persistCallRecord(ctx.storePath, activeCall, ctx.stateRuntime);
997
1280
  Object.assign(call, activeCall);
@@ -1001,19 +1284,25 @@ async function processEventInQueue(ctx, event) {
1001
1284
  return result;
1002
1285
  }
1003
1286
  //#endregion
1287
+ //#region extensions/voice-call/src/resolve-call-agent-id.ts
1288
+ /** Setup and startup must resolve the same owner before provisioning telephony. */
1289
+ function resolveVoiceCallAgentId(config, coreConfig) {
1290
+ return config.agentId ? normalizeAgentId(config.agentId) : resolveDefaultAgentId(coreConfig, {
1291
+ surface: "Voice Call",
1292
+ hint: "Set plugins.entries.voice-call.config.agentId to a configured agent ID."
1293
+ });
1294
+ }
1295
+ function resolveCallAgentId(call) {
1296
+ if (!call.agentId?.trim()) throw new Error("Voice Call has no recorded agent owner. Start a new call and hang up any remaining call with your provider; saved history is unchanged.");
1297
+ return normalizeAgentId(call.agentId);
1298
+ }
1299
+ //#endregion
1004
1300
  //#region extensions/voice-call/src/manager.ts
1005
1301
  function markRestoredCallSkipped(call, endReason) {
1006
1302
  call.endedAt = Date.now();
1007
1303
  call.endReason = endReason;
1008
1304
  call.state = endReason;
1009
1305
  }
1010
- function incrementRestoreStatusCount(counts, status) {
1011
- const key = normalizeOptionalString(status) ?? "terminal";
1012
- counts.set(key, (counts.get(key) ?? 0) + 1);
1013
- }
1014
- function resolveRestoredMaxDurationAnchor(call) {
1015
- return call.answeredAt ?? (call.state === "speaking" || call.state === "listening" ? call.startedAt : void 0);
1016
- }
1017
1306
  function resolveDefaultStoreBase(config, storePath) {
1018
1307
  const rawOverride = storePath?.trim() || config.store?.trim();
1019
1308
  if (rawOverride) return resolveUserPath(rawOverride);
@@ -1033,10 +1322,13 @@ var CallManager = class {
1033
1322
  stop() {
1034
1323
  if (this.stopPromise) return this.stopPromise;
1035
1324
  this.closing = true;
1036
- for (const timers of [this.maxDurationTimers, this.notifyHangupTimers]) {
1037
- for (const timer of timers.values()) clearTimeout(timer);
1038
- timers.clear();
1039
- }
1325
+ const clearTimers = () => {
1326
+ for (const timers of [this.maxDurationTimers, this.notifyHangupTimers]) {
1327
+ for (const timer of timers.values()) clearTimeout(timer);
1328
+ timers.clear();
1329
+ }
1330
+ };
1331
+ clearTimers();
1040
1332
  for (const waiter of this.transcriptWaiters.values()) {
1041
1333
  clearTimeout(waiter.timeout);
1042
1334
  waiter.reject(/* @__PURE__ */ new Error("Voice Call runtime stopped"));
@@ -1048,10 +1340,7 @@ var CallManager = class {
1048
1340
  const results = await Promise.allSettled(this.pendingWork);
1049
1341
  for (const result of results) if (result.status === "rejected") failures.push(result.reason);
1050
1342
  }
1051
- for (const timers of [this.maxDurationTimers, this.notifyHangupTimers]) {
1052
- for (const timer of timers.values()) clearTimeout(timer);
1053
- timers.clear();
1054
- }
1343
+ clearTimers();
1055
1344
  if (failures.length > 0) throw new AggregateError(failures, "Voice Call work failed during shutdown");
1056
1345
  })();
1057
1346
  return this.stopPromise;
@@ -1092,10 +1381,6 @@ var CallManager = class {
1092
1381
  this.storePath = resolveDefaultStoreBase(config, storePath);
1093
1382
  this.stateRuntime = stateRuntime;
1094
1383
  }
1095
- /**
1096
- * Initialize the call manager with a provider.
1097
- * Verifies persisted calls with the provider and restarts timers.
1098
- */
1099
1384
  initialize(provider, webhookUrl) {
1100
1385
  if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("Voice Call manager is stopping"));
1101
1386
  const pending = this.initializeFromStore(provider, webhookUrl);
@@ -1119,7 +1404,7 @@ var CallManager = class {
1119
1404
  const timers = [];
1120
1405
  let skippedAlreadyElapsedTimers = 0;
1121
1406
  for (const [callId, call] of verified) {
1122
- const maxDurationAnchor = resolveRestoredMaxDurationAnchor(call);
1407
+ const maxDurationAnchor = call.answeredAt ?? (call.state === "speaking" || call.state === "listening" ? call.startedAt : void 0);
1123
1408
  if (maxDurationAnchor !== void 0 && !TerminalStates.has(call.state)) {
1124
1409
  const elapsed = Date.now() - maxDurationAnchor;
1125
1410
  const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
@@ -1175,6 +1460,12 @@ var CallManager = class {
1175
1460
  try {
1176
1461
  for (const [callId, call] of candidates) {
1177
1462
  if (this.closing) break;
1463
+ try {
1464
+ resolveCallAgentId(call);
1465
+ } catch (error) {
1466
+ console.warn(`[voice-call] Skipped restored call ${callId}: ${formatErrorMessage(error)}`);
1467
+ continue;
1468
+ }
1178
1469
  if (!call.providerCallId) {
1179
1470
  skippedNoProviderCallId += 1;
1180
1471
  continue;
@@ -1195,7 +1486,8 @@ var CallManager = class {
1195
1486
  }
1196
1487
  const task = provider.getCallStatus({ providerCallId: call.providerCallId }).then(async (result) => {
1197
1488
  if (result.isTerminal) {
1198
- incrementRestoreStatusCount(skippedTerminalStatuses, result.status);
1489
+ const status = normalizeOptionalString(result.status) ?? "terminal";
1490
+ skippedTerminalStatuses.set(status, (skippedTerminalStatuses.get(status) ?? 0) + 1);
1199
1491
  markRestoredCallSkipped(call, "completed");
1200
1492
  await persistCallRecord(this.storePath, call, this.stateRuntime);
1201
1493
  } else if (result.isUnknown) {
@@ -1226,9 +1518,6 @@ var CallManager = class {
1226
1518
  if (keptVerificationFailures > 0) console.log(`[voice-call] Kept ${keptVerificationFailures} restored call(s) after verification failure (relying on timer)`);
1227
1519
  return verified;
1228
1520
  }
1229
- getProvider() {
1230
- return this.provider;
1231
- }
1232
1521
  async initiateCall(to, sessionKey, options) {
1233
1522
  return this.runOperation(() => initiateCall(this.getContext(), to, sessionKey, options));
1234
1523
  }
@@ -1238,15 +1527,9 @@ var CallManager = class {
1238
1527
  async sendDtmf(callId, digits) {
1239
1528
  return this.runOperation(() => sendDtmf(this.getContext(), callId, digits));
1240
1529
  }
1241
- /**
1242
- * Speak the initial message for a call (called when media stream connects).
1243
- */
1244
1530
  async speakInitialMessage(providerCallId) {
1245
1531
  return this.runOperation(() => speakInitialMessage(this.getContext(), providerCallId));
1246
1532
  }
1247
- /**
1248
- * Continue call: speak prompt, then wait for user's final transcript.
1249
- */
1250
1533
  async continueCall(callId, prompt) {
1251
1534
  return this.runOperation(() => continueCall(this.getContext(), callId, prompt));
1252
1535
  }
@@ -1298,18 +1581,11 @@ var CallManager = class {
1298
1581
  invalidateAutoResponse(call) {
1299
1582
  this.autoResponseOwners.delete(call);
1300
1583
  }
1301
- shouldDeferConversationInitialMessageUntilStreamConnect() {
1302
- if (!this.provider || this.provider.name !== "twilio" || !this.config.streaming.enabled) return false;
1303
- const streamAwareProvider = this.provider;
1304
- if (typeof streamAwareProvider.isConversationStreamConnectEnabled !== "function") return false;
1305
- return streamAwareProvider.isConversationStreamConnectEnabled();
1306
- }
1307
1584
  maybeSpeakInitialMessageOnAnswered(call) {
1308
1585
  if (!(normalizeOptionalString(call.metadata?.initialMessage) ?? "")) return;
1309
1586
  const mode = call.metadata?.mode ?? "conversation";
1310
1587
  if (mode === "conversation") {
1311
- if (this.config.realtime.enabled) return;
1312
- if (this.shouldDeferConversationInitialMessageUntilStreamConnect()) return;
1588
+ if (this.config.realtime.enabled || hasConversationStreamConnect(this.provider, this.config)) return;
1313
1589
  } else if (mode !== "notify") return;
1314
1590
  if (!this.provider || !call.providerCallId) return;
1315
1591
  this.speakInitialMessage(call.providerCallId).catch((err) => {
@@ -1370,19 +1646,6 @@ async function buildRealtimeVoiceInstructions(params) {
1370
1646
  return sections.filter(Boolean).join("\n\n");
1371
1647
  }
1372
1648
  //#endregion
1373
- //#region extensions/voice-call/src/resolve-call-agent-id.ts
1374
- /** Setup and startup must resolve the same owner before provisioning telephony. */
1375
- function resolveVoiceCallAgentId(config, coreConfig) {
1376
- return config.agentId ? normalizeAgentId(config.agentId) : resolveDefaultAgentId(coreConfig, {
1377
- surface: "Voice Call",
1378
- hint: "Set plugins.entries.voice-call.config.agentId to a configured agent ID."
1379
- });
1380
- }
1381
- /** Keep one agent owner for the full call, including legacy stored records. */
1382
- function resolveCallAgentId(call, config) {
1383
- return normalizeAgentId(call.agentId ?? config.agentId);
1384
- }
1385
- //#endregion
1386
1649
  //#region extensions/voice-call/src/response-model.ts
1387
1650
  /** Resolve provider/model fields from explicit voice config or agent defaults. */
1388
1651
  function resolveVoiceResponseModel(params) {
@@ -1395,16 +1658,7 @@ function resolveVoiceResponseModel(params) {
1395
1658
  };
1396
1659
  }
1397
1660
  //#endregion
1398
- //#region extensions/voice-call/src/telephony-audio.ts
1399
- /**
1400
- * Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
1401
- */
1402
- function* chunkAudio(audio, chunkSize = 160) {
1403
- for (let i = 0; i < audio.length; i += chunkSize) yield audio.subarray(i, i + chunkSize);
1404
- }
1405
- //#endregion
1406
1661
  //#region extensions/voice-call/src/telephony-tts.ts
1407
- /** Default timeout for one telephony synthesis request. */
1408
1662
  const TELEPHONY_DEFAULT_TTS_TIMEOUT_MS = 8e3;
1409
1663
  var UnsupportedTelephonyTtsOutputFormatError = class extends Error {
1410
1664
  constructor(outputFormat, provider) {
@@ -1485,7 +1739,7 @@ function buildTailscaleExposureArgs(opts) {
1485
1739
  opts.localUrl ?? "off"
1486
1740
  ];
1487
1741
  }
1488
- async function runTailscaleCommand(args, timeoutMs = 2500) {
1742
+ async function runTailscaleCommand(args) {
1489
1743
  try {
1490
1744
  const result = await runCommandWithTimeout(["tailscale", ...args], {
1491
1745
  killProcessTree: true,
@@ -1495,7 +1749,7 @@ async function runTailscaleCommand(args, timeoutMs = 2500) {
1495
1749
  },
1496
1750
  outputCapture: "head",
1497
1751
  terminateOnOutputLimit: { stdout: true },
1498
- timeoutMs
1752
+ timeoutMs: 2500
1499
1753
  });
1500
1754
  if (result.termination !== "exit" || result.outputLimitExceeded) return {
1501
1755
  code: -1,
@@ -1621,20 +1875,7 @@ async function terminateNgrokProcess(proc, isClosed) {
1621
1875
  if (isClosed()) finish();
1622
1876
  });
1623
1877
  }
1624
- function listenForChildStreamErrors(proc, onError) {
1625
- proc.stdout.on("error", (error) => onError("stdout", error));
1626
- proc.stderr.on("error", (error) => onError("stderr", error));
1627
- }
1628
- /**
1629
- * Start an ngrok tunnel to expose the local webhook server.
1630
- *
1631
- * Uses the ngrok CLI which must be installed: https://ngrok.com/download
1632
- *
1633
- * @example
1634
- * const tunnel = await startNgrokTunnel({ port: 3334, path: '/voice/webhook' });
1635
- * console.log('Public URL:', tunnel.publicUrl);
1636
- * // Later: await tunnel.stop();
1637
- */
1878
+ /** Start an ngrok CLI tunnel and retain its child until the tunnel is stopped. */
1638
1879
  async function startNgrokTunnel(config) {
1639
1880
  const args = [
1640
1881
  "http",
@@ -1644,7 +1885,7 @@ async function startNgrokTunnel(config) {
1644
1885
  "--log-format",
1645
1886
  "json"
1646
1887
  ];
1647
- if (config.domain) args.push("--domain", config.domain);
1888
+ if (config.ngrokDomain) args.push("--domain", config.ngrokDomain);
1648
1889
  return new Promise((resolve, reject) => {
1649
1890
  const proc = spawn("ngrok", args, {
1650
1891
  stdio: [
@@ -1652,9 +1893,9 @@ async function startNgrokTunnel(config) {
1652
1893
  "pipe",
1653
1894
  "pipe"
1654
1895
  ],
1655
- ...config.authToken ? { env: {
1896
+ ...config.ngrokAuthToken ? { env: {
1656
1897
  ...process.env,
1657
- NGROK_AUTHTOKEN: config.authToken
1898
+ NGROK_AUTHTOKEN: config.ngrokAuthToken
1658
1899
  } } : {}
1659
1900
  });
1660
1901
  let startupSettled = false;
@@ -1712,7 +1953,7 @@ async function startNgrokTunnel(config) {
1712
1953
  if (combined.includes(NGROK_ERROR_MARKER)) rejectIfPending(`ngrok error: ${formatBoundedChildOutput(combined)}`, true);
1713
1954
  stderrTail = sliceUtf16Safe(combined, -8);
1714
1955
  });
1715
- listenForChildStreamErrors(proc, (stream, error) => {
1956
+ for (const stream of ["stdout", "stderr"]) proc[stream].on("error", (error) => {
1716
1957
  rejectIfPending(`ngrok ${stream} error: ${error.message}`, true);
1717
1958
  });
1718
1959
  proc.on("error", (err) => {
@@ -1728,53 +1969,42 @@ async function startNgrokTunnel(config) {
1728
1969
  });
1729
1970
  });
1730
1971
  }
1731
- async function startTailscaleTunnel(config) {
1732
- const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
1733
- const routes = [{
1734
- publicPath: path,
1735
- localPath: path
1736
- }, ...config.streamPaths ?? []].map(({ publicPath, localPath }) => {
1737
- const normalizedPublicPath = publicPath.startsWith("/") ? publicPath : `/${publicPath}`;
1738
- const normalizedLocalPath = localPath.startsWith("/") ? localPath : `/${localPath}`;
1739
- return {
1740
- path: normalizedPublicPath,
1741
- localUrl: `http://127.0.0.1:${config.port}${normalizedLocalPath}`
1742
- };
1743
- });
1744
- const publicUrl = await setupTailscaleExposureRoutes({
1745
- mode: config.mode,
1746
- port: config.tailscalePort,
1747
- routes
1748
- });
1749
- if (!publicUrl) throw new Error(`Tailscale ${config.mode} failed`);
1750
- return {
1751
- publicUrl,
1752
- provider: `tailscale-${config.mode}`,
1753
- stop: async () => {
1754
- for (const route of routes) await cleanupTailscaleExposureRoute({
1755
- mode: config.mode,
1756
- port: config.tailscalePort,
1757
- path: route.path
1758
- });
1759
- }
1760
- };
1761
- }
1762
1972
  async function startTunnel(config) {
1763
1973
  switch (config.provider) {
1764
- case "ngrok": return startNgrokTunnel({
1765
- port: config.port,
1766
- path: config.path,
1767
- authToken: config.ngrokAuthToken,
1768
- domain: config.ngrokDomain
1769
- });
1974
+ case "ngrok": return startNgrokTunnel(config);
1770
1975
  case "tailscale-serve":
1771
- case "tailscale-funnel": return startTailscaleTunnel({
1772
- mode: config.provider === "tailscale-serve" ? "serve" : "funnel",
1773
- port: config.port,
1774
- tailscalePort: config.tailscalePort ?? 443,
1775
- path: config.path,
1776
- streamPaths: config.streamPaths
1777
- });
1976
+ case "tailscale-funnel": {
1977
+ const mode = config.provider === "tailscale-serve" ? "serve" : "funnel";
1978
+ const tailscalePort = config.tailscalePort ?? 443;
1979
+ const routes = [{
1980
+ publicPath: config.path,
1981
+ localPath: config.path
1982
+ }, ...config.streamPaths ?? []].map(({ publicPath, localPath }) => {
1983
+ const normalizedPublicPath = publicPath.startsWith("/") ? publicPath : `/${publicPath}`;
1984
+ const normalizedLocalPath = localPath.startsWith("/") ? localPath : `/${localPath}`;
1985
+ return {
1986
+ path: normalizedPublicPath,
1987
+ localUrl: `http://127.0.0.1:${config.port}${normalizedLocalPath}`
1988
+ };
1989
+ });
1990
+ const publicUrl = await setupTailscaleExposureRoutes({
1991
+ mode,
1992
+ port: tailscalePort,
1993
+ routes
1994
+ });
1995
+ if (!publicUrl) throw new Error(`Tailscale ${mode} failed`);
1996
+ return {
1997
+ publicUrl,
1998
+ provider: `tailscale-${mode}`,
1999
+ stop: async () => {
2000
+ for (const route of routes) await cleanupTailscaleExposureRoute({
2001
+ mode,
2002
+ port: tailscalePort,
2003
+ path: route.path
2004
+ });
2005
+ }
2006
+ };
2007
+ }
1778
2008
  default: return null;
1779
2009
  }
1780
2010
  }
@@ -1843,10 +2073,6 @@ function canonicalizeVoiceCallMediaBase64(payloadBase64) {
1843
2073
  return canonicalizeBase64(payloadBase64.replaceAll("-", "+").replaceAll("_", "/"));
1844
2074
  }
1845
2075
  //#endregion
1846
- //#region extensions/voice-call/src/websocket.ts
1847
- const require = createRequire(import.meta.url);
1848
- const { WebSocket, WebSocketServer } = require(path.join(path.dirname(require.resolve("ws/package.json")), "index.js"));
1849
- //#endregion
1850
2076
  //#region extensions/voice-call/src/media-stream.ts
1851
2077
  const DEFAULT_PRE_START_TIMEOUT_MS = 5e3;
1852
2078
  const DEFAULT_MAX_PENDING_CONNECTIONS = 32;
@@ -1863,24 +2089,17 @@ function sanitizeLogText(value, maxChars) {
1863
2089
  if (sanitized.length <= maxChars) return sanitized;
1864
2090
  return `${truncateUtf16Safe(sanitized, maxChars)}...`;
1865
2091
  }
1866
- function normalizeWsMessageData(data) {
1867
- if (Buffer.isBuffer(data)) return data;
1868
- if (Array.isArray(data)) return Buffer.concat(data);
1869
- return Buffer.from(data);
1870
- }
1871
2092
  function parseTwilioMediaMessage(data) {
1872
- const raw = normalizeWsMessageData(data);
2093
+ const raw = rawDataToString(data);
1873
2094
  try {
1874
- return JSON.parse(raw.toString("utf8"));
2095
+ return JSON.parse(raw);
1875
2096
  } catch (cause) {
1876
2097
  throw new Error("Twilio media stream message was malformed JSON", { cause });
1877
2098
  }
1878
2099
  }
1879
- /**
1880
- * Manages WebSocket connections for Twilio media streams.
1881
- */
1882
2100
  var MediaStreamHandler = class {
1883
2101
  constructor(config) {
2102
+ this.config = config;
1884
2103
  this.wss = null;
1885
2104
  this.closePromise = null;
1886
2105
  this.closing = false;
@@ -1889,22 +2108,17 @@ var MediaStreamHandler = class {
1889
2108
  this.pendingByIp = /* @__PURE__ */ new Map();
1890
2109
  this.inflightUpgrades = 0;
1891
2110
  this.ttsQueues = /* @__PURE__ */ new Map();
1892
- this.ttsPlaying = /* @__PURE__ */ new Map();
1893
2111
  this.ttsActiveControllers = /* @__PURE__ */ new Map();
1894
2112
  this.pendingPlaybackMarks = /* @__PURE__ */ new Map();
1895
2113
  this.ignoredPlaybackMarks = /* @__PURE__ */ new Map();
1896
- this.config = config;
1897
2114
  this.preStartTimeoutMs = resolveTimerTimeoutMs(config.preStartTimeoutMs, DEFAULT_PRE_START_TIMEOUT_MS);
1898
2115
  this.maxPendingConnections = config.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS;
1899
2116
  this.maxPendingConnectionsPerIp = config.maxPendingConnectionsPerIp ?? DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP;
1900
2117
  this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
1901
2118
  }
1902
- /**
1903
- * Handle WebSocket upgrade for media stream connections.
1904
- */
1905
2119
  handleUpgrade(request, socket, head) {
1906
2120
  if (this.closing) {
1907
- this.rejectUpgrade(socket, 503, "Media stream handler is shutting down");
2121
+ this.rejectUpgrade(socket, "Media stream handler is shutting down");
1908
2122
  return;
1909
2123
  }
1910
2124
  if (!this.wss) {
@@ -1916,8 +2130,8 @@ var MediaStreamHandler = class {
1916
2130
  this.handleConnection(ws, req);
1917
2131
  });
1918
2132
  }
1919
- if (this.getCurrentConnectionCount() >= this.maxConnections) {
1920
- this.rejectUpgrade(socket, 503, "Too many media stream connections");
2133
+ if (this.wss.clients.size + this.inflightUpgrades >= this.maxConnections) {
2134
+ this.rejectUpgrade(socket, "Too many media stream connections");
1921
2135
  return;
1922
2136
  }
1923
2137
  this.inflightUpgrades += 1;
@@ -1936,15 +2150,11 @@ var MediaStreamHandler = class {
1936
2150
  socket.once("close", handleUpgradeAbort);
1937
2151
  try {
1938
2152
  this.wss.handleUpgrade(request, socket, head, (ws) => {
1939
- socket.removeListener("error", handleUpgradeAbort);
1940
- socket.removeListener("close", handleUpgradeAbort);
1941
- releaseUpgradeReservation();
2153
+ handleUpgradeAbort();
1942
2154
  this.wss?.emit("connection", ws, request);
1943
2155
  });
1944
2156
  } catch (error) {
1945
- socket.removeListener("error", handleUpgradeAbort);
1946
- socket.removeListener("close", handleUpgradeAbort);
1947
- releaseUpgradeReservation();
2157
+ handleUpgradeAbort();
1948
2158
  throw error;
1949
2159
  }
1950
2160
  }
@@ -1965,9 +2175,6 @@ var MediaStreamHandler = class {
1965
2175
  });
1966
2176
  return this.closePromise;
1967
2177
  }
1968
- /**
1969
- * Handle new WebSocket connection from Twilio.
1970
- */
1971
2178
  async handleConnection(ws, _request) {
1972
2179
  let session = null;
1973
2180
  const streamToken = this.getStreamToken(_request);
@@ -2032,9 +2239,6 @@ var MediaStreamHandler = class {
2032
2239
  console.error("[MediaStream] WebSocket error:", error);
2033
2240
  });
2034
2241
  }
2035
- /**
2036
- * Handle stream start event.
2037
- */
2038
2242
  handleStart(ws, message, streamToken) {
2039
2243
  const streamSid = message.streamSid || "";
2040
2244
  const callSid = message.start?.callSid || "";
@@ -2172,9 +2376,6 @@ var MediaStreamHandler = class {
2172
2376
  });
2173
2377
  this.config.onTranscriptionReady?.(session.callId, session.streamSid);
2174
2378
  }
2175
- /**
2176
- * Handle stream stop event.
2177
- */
2178
2379
  handleStop(session) {
2179
2380
  console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
2180
2381
  this.clearTtsState(session.streamSid);
@@ -2203,9 +2404,6 @@ var MediaStreamHandler = class {
2203
2404
  if (resolvedIp) return resolvedIp;
2204
2405
  return request.socket.remoteAddress || "unknown";
2205
2406
  }
2206
- getCurrentConnectionCount() {
2207
- return this.wss ? this.wss.clients.size + this.inflightUpgrades : this.inflightUpgrades;
2208
- }
2209
2407
  registerPendingConnection(ws, ip) {
2210
2408
  if (this.pendingConnections.size >= this.maxPendingConnections) {
2211
2409
  console.warn("[MediaStream] Rejecting connection: pending connection limit reached");
@@ -2241,25 +2439,19 @@ var MediaStreamHandler = class {
2241
2439
  }
2242
2440
  this.pendingByIp.set(pending.ip, current - 1);
2243
2441
  }
2244
- rejectUpgrade(socket, statusCode, message) {
2245
- const statusText = statusCode === 429 ? "Too Many Requests" : "Service Unavailable";
2442
+ rejectUpgrade(socket, message) {
2246
2443
  const body = `${message}\n`;
2247
- socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r
2444
+ socket.write(`HTTP/1.1 503 Service Unavailable\r
2445
+ Connection: close\r
2248
2446
  Content-Type: text/plain; charset=utf-8\r
2249
2447
  Content-Length: ${Buffer.byteLength(body)}\r\n\r
2250
2448
  ` + body);
2251
2449
  socket.destroy();
2252
2450
  }
2253
- /**
2254
- * Get an active session with an open WebSocket, or undefined if unavailable.
2255
- */
2256
2451
  getOpenSession(streamSid) {
2257
2452
  const session = this.sessions.get(streamSid);
2258
2453
  return session?.ws.readyState === WebSocket.OPEN ? session : void 0;
2259
2454
  }
2260
- /**
2261
- * Send a message to a stream's WebSocket if available.
2262
- */
2263
2455
  sendToStream(streamSid, message) {
2264
2456
  const session = this.getOpenSession(streamSid);
2265
2457
  if (!session) return false;
@@ -2299,9 +2491,6 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2299
2491
  media: { payload: muLawAudio.toString("base64") }
2300
2492
  });
2301
2493
  }
2302
- /**
2303
- * Send a mark event to track audio playback position.
2304
- */
2305
2494
  sendMark(streamSid, name) {
2306
2495
  return this.sendToStream(streamSid, {
2307
2496
  event: "mark",
@@ -2312,7 +2501,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2312
2501
  /** Send a completion mark and wait until Twilio reports that buffered playback reached it. */
2313
2502
  async sendMarkAndWait(streamSid, name, audioDurationMs, signal) {
2314
2503
  signal.throwIfAborted();
2315
- const marks = this.getPendingPlaybackMarks(streamSid);
2504
+ const marks = this.pendingPlaybackMarks.get(streamSid) ?? /* @__PURE__ */ new Map();
2505
+ this.pendingPlaybackMarks.set(streamSid, marks);
2316
2506
  if (marks.has(name)) throw new Error(`Telephony playback mark is already pending: ${name}`);
2317
2507
  this.ignoredPlaybackMarks.get(streamSid)?.delete(name);
2318
2508
  let pending;
@@ -2342,9 +2532,6 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2342
2532
  if (!this.sendMark(streamSid, name)) pending.settle(/* @__PURE__ */ new Error("Telephony stream playback failed: completion mark not delivered"));
2343
2533
  return acknowledgement;
2344
2534
  }
2345
- /**
2346
- * Clear audio buffer (interrupt playback).
2347
- */
2348
2535
  clearAudio(streamSid) {
2349
2536
  this.invalidatePlaybackMarks(streamSid);
2350
2537
  return this.sendToStream(streamSid, {
@@ -2357,30 +2544,21 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2357
2544
  * Only one TTS operation plays at a time per stream to prevent overlap.
2358
2545
  */
2359
2546
  async queueTts(streamSid, playFn) {
2360
- const queue = this.getTtsQueue(streamSid);
2547
+ const queue = this.ttsQueues.get(streamSid) ?? [];
2548
+ this.ttsQueues.set(streamSid, queue);
2361
2549
  if (queue.length >= MAX_PENDING_TTS_OPERATIONS_PER_STREAM) throw new Error(`Telephony TTS queue is full for stream; maxPending=${MAX_PENDING_TTS_OPERATIONS_PER_STREAM}`);
2362
- let resolveEntry;
2363
- let rejectEntry;
2364
- const promise = new Promise((resolve, reject) => {
2365
- resolveEntry = resolve;
2366
- rejectEntry = reject;
2367
- });
2550
+ const { promise, resolve, reject } = createDeferred();
2368
2551
  queue.push({
2369
2552
  playFn,
2370
2553
  controller: new AbortController(),
2371
- resolve: resolveEntry,
2372
- reject: rejectEntry
2554
+ resolve,
2555
+ reject
2373
2556
  });
2374
- if (!this.ttsPlaying.get(streamSid)) this.processQueue(streamSid);
2557
+ if (!this.ttsActiveControllers.has(streamSid)) this.processQueue(streamSid);
2375
2558
  return promise;
2376
2559
  }
2377
- /**
2378
- * Clear TTS queue and interrupt current playback (barge-in).
2379
- */
2380
2560
  clearTtsQueue(streamSid, _reason = "unspecified") {
2381
- const queue = this.ttsQueues.get(streamSid);
2382
- if (queue) this.resolveQueuedTtsEntries(queue);
2383
- this.ttsActiveControllers.get(streamSid)?.abort();
2561
+ this.abortTtsPlayback(streamSid);
2384
2562
  const session = this.sessions.get(streamSid);
2385
2563
  if (session?.talk.activeTurnId) {
2386
2564
  const cancelled = session.talk.cancelTurn({ payload: {
@@ -2392,20 +2570,6 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2392
2570
  }
2393
2571
  this.clearAudio(streamSid);
2394
2572
  }
2395
- getTtsQueue(streamSid) {
2396
- const existing = this.ttsQueues.get(streamSid);
2397
- if (existing) return existing;
2398
- const queue = [];
2399
- this.ttsQueues.set(streamSid, queue);
2400
- return queue;
2401
- }
2402
- getPendingPlaybackMarks(streamSid) {
2403
- const existing = this.pendingPlaybackMarks.get(streamSid);
2404
- if (existing) return existing;
2405
- const marks = /* @__PURE__ */ new Map();
2406
- this.pendingPlaybackMarks.set(streamSid, marks);
2407
- return marks;
2408
- }
2409
2573
  acknowledgePlaybackMark(streamSid, name) {
2410
2574
  const ignored = this.ignoredPlaybackMarks.get(streamSid);
2411
2575
  if (ignored?.delete(name)) {
@@ -2429,16 +2593,10 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2429
2593
  }
2430
2594
  this.ignoredPlaybackMarks.set(streamSid, ignored);
2431
2595
  }
2432
- /**
2433
- * Process the TTS queue for a stream.
2434
- * Uses iterative approach to avoid stack accumulation from recursion.
2435
- */
2436
2596
  async processQueue(streamSid) {
2437
- this.ttsPlaying.set(streamSid, true);
2438
2597
  while (true) {
2439
2598
  const queue = this.ttsQueues.get(streamSid);
2440
2599
  if (!queue || queue.length === 0) {
2441
- this.ttsPlaying.delete(streamSid);
2442
2600
  this.ttsActiveControllers.delete(streamSid);
2443
2601
  this.ttsQueues.delete(streamSid);
2444
2602
  return;
@@ -2518,21 +2676,18 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2518
2676
  return turn.turnId;
2519
2677
  }
2520
2678
  clearTtsState(streamSid) {
2521
- const queue = this.ttsQueues.get(streamSid);
2522
- if (queue) this.resolveQueuedTtsEntries(queue);
2523
- this.ttsActiveControllers.get(streamSid)?.abort();
2679
+ this.abortTtsPlayback(streamSid);
2524
2680
  this.ttsActiveControllers.delete(streamSid);
2525
- this.ttsPlaying.delete(streamSid);
2526
2681
  this.ttsQueues.delete(streamSid);
2527
2682
  this.invalidatePlaybackMarks(streamSid);
2528
2683
  this.ignoredPlaybackMarks.delete(streamSid);
2529
2684
  }
2530
- resolveQueuedTtsEntries(queue) {
2531
- const pending = queue.splice(0);
2532
- for (const entry of pending) {
2685
+ abortTtsPlayback(streamSid) {
2686
+ for (const entry of this.ttsQueues.get(streamSid)?.splice(0) ?? []) {
2533
2687
  entry.controller.abort();
2534
2688
  entry.resolve();
2535
2689
  }
2690
+ this.ttsActiveControllers.get(streamSid)?.abort();
2536
2691
  }
2537
2692
  };
2538
2693
  //#endregion
@@ -2577,33 +2732,38 @@ const CHECK_INTERVAL_MS = 3e4;
2577
2732
  * can be absent even while the call is actively transcribing. These states
2578
2733
  * prove the call is live and should not be reaped. */
2579
2734
  const LiveConversationStates = /* @__PURE__ */ new Set(["speaking", "listening"]);
2580
- /** Start a stale-call reaper and return its cleanup callback. */
2735
+ /** Stop joins provider hangups before the call manager can close. */
2581
2736
  function startStaleCallReaper(params) {
2582
2737
  const maxAgeSeconds = params.staleCallReaperSeconds;
2583
2738
  if (!maxAgeSeconds || maxAgeSeconds <= 0) return null;
2584
2739
  const maxAgeMs = maxAgeSeconds * 1e3;
2585
- const callsBeingReaped = /* @__PURE__ */ new Set();
2586
- const interval = setInterval(() => {
2740
+ const reap = async () => {
2587
2741
  const now = Date.now();
2742
+ const hangups = [];
2588
2743
  for (const call of params.manager.getActiveCalls()) {
2589
2744
  if (call.answeredAt || TerminalStates.has(call.state) || LiveConversationStates.has(call.state)) continue;
2590
2745
  const age = now - call.startedAt;
2591
- if (age > maxAgeMs && !callsBeingReaped.has(call.callId)) {
2592
- callsBeingReaped.add(call.callId);
2746
+ if (age > maxAgeMs) {
2593
2747
  console.log(`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1e3)}s, state: ${call.state})`);
2594
- params.manager.endCall(call.callId).then((result) => {
2748
+ const operation = params.manager.endCall(call.callId).then((result) => {
2595
2749
  if (!result.success) console.warn(`[voice-call] Reaper failed to end call ${call.callId}: ${result.error ?? "unknown error"}`);
2596
2750
  }).catch((err) => {
2597
2751
  console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
2598
- }).finally(() => {
2599
- callsBeingReaped.delete(call.callId);
2600
2752
  });
2753
+ hangups.push(operation);
2601
2754
  }
2602
2755
  }
2603
- }, CHECK_INTERVAL_MS);
2604
- return () => {
2605
- clearInterval(interval);
2756
+ await Promise.allSettled(hangups);
2606
2757
  };
2758
+ if (params.scheduler.signal.aborted) return null;
2759
+ const scheduler = params.scheduler.scope();
2760
+ scheduler.schedule({
2761
+ id: "stale-call-reaper",
2762
+ delayMs: CHECK_INTERVAL_MS,
2763
+ everyMs: CHECK_INTERVAL_MS,
2764
+ run: reap
2765
+ });
2766
+ return () => scheduler.stop();
2607
2767
  }
2608
2768
  //#endregion
2609
2769
  //#region extensions/voice-call/src/webhook/stream-disconnect-grace.ts
@@ -2650,7 +2810,7 @@ const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
2650
2810
  const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
2651
2811
  const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
2652
2812
  const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.mjs"));
2653
- const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-BzKrJMEb.mjs"));
2813
+ const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-DAhbXEta.mjs"));
2654
2814
  function appendRecentTalkEventMetadata(metadata, event) {
2655
2815
  const previous = metadata ?? {};
2656
2816
  const recent = Array.isArray(previous.recentTalkEvents) ? previous.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
@@ -2670,13 +2830,11 @@ function appendRecentTalkEventMetadata(metadata, event) {
2670
2830
  function buildRequestUrl(requestUrl) {
2671
2831
  return new URL$1(requestUrl ?? "/", "http://localhost");
2672
2832
  }
2673
- function resolveForwardedClientIp(request, trustedProxyIPs) {
2674
- const normalizedTrustedProxyIps = new Set(trustedProxyIPs.map((ip) => normalizeProxyIp(ip)).filter((ip) => Boolean(ip)));
2833
+ function resolveForwardedClientIp(request, normalizedTrustedProxyIps) {
2675
2834
  const forwardedFor = getHeader(request.headers, "x-forwarded-for");
2676
2835
  if (forwardedFor) {
2677
2836
  const forwardedIps = normalizeStringEntries(forwardedFor.split(","));
2678
2837
  if (forwardedIps.length > 0) {
2679
- if (normalizedTrustedProxyIps.size === 0) return forwardedIps[0];
2680
2838
  for (let index = forwardedIps.length - 1; index >= 0; index -= 1) {
2681
2839
  const hop = forwardedIps[index];
2682
2840
  if (!normalizedTrustedProxyIps.has(normalizeProxyIp(hop) ?? "")) return hop;
@@ -2717,12 +2875,14 @@ function cloneWebhookResponsePayload(payload) {
2717
2875
  body: payload.body
2718
2876
  };
2719
2877
  }
2720
- /**
2721
- * HTTP server for receiving voice call webhooks from providers.
2722
- * Supports WebSocket upgrades for media streams when streaming is enabled.
2723
- */
2724
2878
  var VoiceCallWebhookServer = class {
2725
- constructor(config, manager, provider, coreConfig, fullConfig, agentRuntime, logger) {
2879
+ constructor(scheduler, config, manager, provider, coreConfig, fullConfig, agentRuntime, logger) {
2880
+ this.scheduler = scheduler;
2881
+ this.manager = manager;
2882
+ this.provider = provider;
2883
+ this.coreConfig = coreConfig;
2884
+ this.fullConfig = fullConfig;
2885
+ this.agentRuntime = agentRuntime;
2726
2886
  this.server = null;
2727
2887
  this.listeningUrl = null;
2728
2888
  this.startPromise = null;
@@ -2735,18 +2895,12 @@ var VoiceCallWebhookServer = class {
2735
2895
  this.replayResponses = /* @__PURE__ */ new Map();
2736
2896
  this.replayResponseCacheCalls = 0;
2737
2897
  this.config = normalizeVoiceCallConfig(config);
2738
- this.manager = manager;
2739
- this.provider = provider;
2740
- this.coreConfig = coreConfig ?? null;
2741
- this.fullConfig = fullConfig ?? null;
2742
- this.agentRuntime = agentRuntime ?? null;
2743
- this.logger = logger ?? {
2898
+ const rawLogger = logger ?? {
2744
2899
  info: console.log,
2745
2900
  warn: console.warn,
2746
2901
  error: console.error,
2747
2902
  debug: console.debug
2748
2903
  };
2749
- const rawLogger = this.logger;
2750
2904
  const rawDebug = rawLogger.debug;
2751
2905
  this.logger = {
2752
2906
  info: (msg) => rawLogger.info(`[voice-call] ${msg}`),
@@ -2791,14 +2945,13 @@ var VoiceCallWebhookServer = class {
2791
2945
  const normalizedRemoteIp = normalizeProxyIp(remoteIp);
2792
2946
  const fromTrustedProxy = normalizedTrustedProxyIps.size > 0 && normalizedRemoteIp !== void 0 && normalizedTrustedProxyIps.has(normalizedRemoteIp);
2793
2947
  if (this.config.webhookSecurity.trustForwardingHeaders && fromTrustedProxy) {
2794
- const forwardedIp = resolveForwardedClientIp(request, trustedProxyIPs);
2948
+ const forwardedIp = resolveForwardedClientIp(request, normalizedTrustedProxyIps);
2795
2949
  if (forwardedIp) return forwardedIp;
2796
2950
  }
2797
2951
  return remoteIp;
2798
2952
  }
2799
2953
  shouldSuppressBargeInForInitialMessage(call) {
2800
- if (!call || call.direction !== "outbound") return false;
2801
- if (call.state !== "speaking") return false;
2954
+ if (!call || call.direction !== "outbound" || call.state !== "speaking") return false;
2802
2955
  if ((call.metadata?.mode ?? "conversation") !== "conversation") return false;
2803
2956
  return (normalizeOptionalString(call.metadata?.initialMessage) ?? "").length > 0;
2804
2957
  }
@@ -2820,9 +2973,6 @@ var VoiceCallWebhookServer = class {
2820
2973
  current.provider.clearTtsQueue(providerCallId);
2821
2974
  return generation;
2822
2975
  }
2823
- /**
2824
- * Initialize media streaming with the selected realtime transcription provider.
2825
- */
2826
2976
  async initializeMediaStreaming() {
2827
2977
  const streaming = this.config.streaming;
2828
2978
  const pluginConfig = this.fullConfig ?? this.coreConfig ?? void 0;
@@ -2940,10 +3090,6 @@ var VoiceCallWebhookServer = class {
2940
3090
  this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
2941
3091
  this.logger.info("Media streaming initialized");
2942
3092
  }
2943
- /**
2944
- * Start the webhook server.
2945
- * Idempotent: returns immediately if the server is already listening.
2946
- */
2947
3093
  async start() {
2948
3094
  if (this.stopPromise) await this.stopPromise;
2949
3095
  const { port, bind, path: webhookPath } = this.config.serve;
@@ -2988,6 +3134,7 @@ var VoiceCallWebhookServer = class {
2988
3134
  }
2989
3135
  resolve(url);
2990
3136
  this.stopStaleCallReaper = startStaleCallReaper({
3137
+ scheduler: this.scheduler,
2991
3138
  manager: this.manager,
2992
3139
  staleCallReaperSeconds: this.config.staleCallReaperSeconds
2993
3140
  });
@@ -3013,13 +3160,12 @@ var VoiceCallWebhookServer = class {
3013
3160
  });
3014
3161
  this.startPromise = null;
3015
3162
  this.streamDisconnectGrace.close();
3016
- if (this.stopStaleCallReaper) {
3017
- this.stopStaleCallReaper();
3018
- this.stopStaleCallReaper = null;
3019
- }
3163
+ const reaperStopped = this.stopStaleCallReaper?.();
3164
+ this.stopStaleCallReaper = null;
3020
3165
  this.webhookInFlightLimiter.clear();
3021
3166
  this.stopPromise = (async () => {
3022
3167
  const results = await Promise.allSettled([
3168
+ reaperStopped,
3023
3169
  serverClosePromise,
3024
3170
  this.mediaStreamHandler?.close(serverClosePromise) ?? Promise.resolve(),
3025
3171
  this.realtimeHandler?.close(serverClosePromise) ?? Promise.resolve()
@@ -3049,9 +3195,6 @@ var VoiceCallWebhookServer = class {
3049
3195
  return null;
3050
3196
  }
3051
3197
  }
3052
- isWebhookPathMatch(requestPath, configuredPath) {
3053
- return normalizeWebhookPath(requestPath) === normalizeWebhookPath(configuredPath);
3054
- }
3055
3198
  async handleRequest(req, res, webhookPath) {
3056
3199
  const payload = await this.runWebhookPipeline(req, webhookPath, res);
3057
3200
  if (payload) this.writeWebhookResponse(res, payload);
@@ -3067,7 +3210,7 @@ var VoiceCallWebhookServer = class {
3067
3210
  <Play loop="0">https://s3.amazonaws.com/com.twilio.music.classical/BusyStrings.mp3</Play>
3068
3211
  </Response>`
3069
3212
  };
3070
- if (!this.isWebhookPathMatch(url.pathname, webhookPath)) return {
3213
+ if (normalizeWebhookPath(url.pathname) !== normalizeWebhookPath(webhookPath)) return {
3071
3214
  statusCode: 404,
3072
3215
  body: "Not Found"
3073
3216
  };
@@ -3075,9 +3218,9 @@ var VoiceCallWebhookServer = class {
3075
3218
  statusCode: 405,
3076
3219
  body: "Method Not Allowed"
3077
3220
  };
3078
- const headerGate = this.verifyPreAuthWebhookHeaders(req.headers);
3079
- if (!headerGate.ok) {
3080
- this.logger.warn(`Webhook rejected before body read: ${headerGate.reason}`);
3221
+ const headerRejection = this.verifyPreAuthWebhookHeaders(req.headers);
3222
+ if (headerRejection) {
3223
+ this.logger.warn(`Webhook rejected before body read: ${headerRejection}`);
3081
3224
  return {
3082
3225
  statusCode: 401,
3083
3226
  body: "Unauthorized"
@@ -3178,11 +3321,7 @@ var VoiceCallWebhookServer = class {
3178
3321
  if (now !== void 0) {
3179
3322
  for (const [key, entry] of this.replayResponses) if (entry.expiresAt <= now) this.replayResponses.delete(key);
3180
3323
  }
3181
- while (this.replayResponses.size > WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES) {
3182
- const oldest = this.replayResponses.keys().next().value;
3183
- if (!oldest) break;
3184
- this.replayResponses.delete(oldest);
3185
- }
3324
+ pruneMapToMaxSize(this.replayResponses, WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES);
3186
3325
  }
3187
3326
  async getCachedReplayResponse(key) {
3188
3327
  const now = asDateTimestampMs(Date.now());
@@ -3218,33 +3357,24 @@ var VoiceCallWebhookServer = class {
3218
3357
  return cloneWebhookResponsePayload(await ownerResponse);
3219
3358
  }
3220
3359
  verifyPreAuthWebhookHeaders(headers) {
3221
- if (this.config.skipSignatureVerification) return { ok: true };
3360
+ if (this.config.skipSignatureVerification) return;
3222
3361
  switch (this.provider.name) {
3223
3362
  case "telnyx": {
3224
3363
  const signature = getHeader(headers, "telnyx-signature-ed25519");
3225
3364
  const timestamp = getHeader(headers, "telnyx-timestamp");
3226
- if (signature && timestamp) return { ok: true };
3227
- return {
3228
- ok: false,
3229
- reason: "missing Telnyx signature or timestamp header"
3230
- };
3365
+ if (signature && timestamp) return;
3366
+ return "missing Telnyx signature or timestamp header";
3231
3367
  }
3232
3368
  case "twilio":
3233
- if (getHeader(headers, "x-twilio-signature")) return { ok: true };
3234
- return {
3235
- ok: false,
3236
- reason: "missing X-Twilio-Signature header"
3237
- };
3369
+ if (getHeader(headers, "x-twilio-signature")) return;
3370
+ return "missing X-Twilio-Signature header";
3238
3371
  case "plivo": {
3239
3372
  const hasV3 = Boolean(getHeader(headers, "x-plivo-signature-v3")) && Boolean(getHeader(headers, "x-plivo-signature-v3-nonce"));
3240
3373
  const hasV2 = Boolean(getHeader(headers, "x-plivo-signature-v2")) && Boolean(getHeader(headers, "x-plivo-signature-v2-nonce"));
3241
- if (hasV3 || hasV2) return { ok: true };
3242
- return {
3243
- ok: false,
3244
- reason: "missing Plivo signature headers"
3245
- };
3374
+ if (hasV3 || hasV2) return;
3375
+ return "missing Plivo signature headers";
3246
3376
  }
3247
- default: return { ok: true };
3377
+ default: return;
3248
3378
  }
3249
3379
  }
3250
3380
  isRealtimeWebSocketUpgrade(req) {
@@ -3304,10 +3434,6 @@ var VoiceCallWebhookServer = class {
3304
3434
  if (payload.headers) for (const [key, value] of Object.entries(payload.headers)) res.setHeader(key, value);
3305
3435
  res.end(payload.body);
3306
3436
  }
3307
- /**
3308
- * Handle auto-response for inbound calls using the agent system.
3309
- * Supports tool calling for richer voice interactions.
3310
- */
3311
3437
  async handleInboundResponse(callId, userMessage) {
3312
3438
  this.logger.info(`Auto-responding to inbound call ${callId} chars=${userMessage.length}`);
3313
3439
  const call = this.manager.getCall(callId);
@@ -3348,7 +3474,7 @@ var VoiceCallWebhookServer = class {
3348
3474
  sessionKey: call.sessionKey,
3349
3475
  from: call.from,
3350
3476
  senderIsOwner: call.direction === "inbound" ? false : void 0,
3351
- agentId: resolveCallAgentId(call, effectiveConfig),
3477
+ agentId: resolveCallAgentId(call),
3352
3478
  transcript: call.transcript,
3353
3479
  userMessage,
3354
3480
  onEarlyText: speakResponse
@@ -3375,12 +3501,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
3375
3501
  "Do not print secret values or dump environment variables; only check whether required configuration is present.",
3376
3502
  "Be accurate, brief, and speakable."
3377
3503
  ].join(" ");
3378
- const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-DjFGCfy1.mjs"));
3379
- const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-Bp3gfmUB.mjs"));
3380
- const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-69ObrQPk.mjs"));
3381
- const loadMockProvider = createLazyRuntimeModule(() => import("./mock-ClWW6Ufu.mjs"));
3504
+ const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-DPilutCH.mjs"));
3505
+ const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-BWFNGyVx.mjs"));
3506
+ const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-CpPIENu4.mjs"));
3507
+ const loadMockProvider = createLazyRuntimeModule(() => import("./mock-eLv_Dc4o.mjs"));
3382
3508
  const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.mjs"));
3383
- const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-BgDPcPZt.mjs"));
3509
+ const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-Do7Sz7Gh.mjs"));
3384
3510
  function mapVoiceCallConsultTranscript(call, context) {
3385
3511
  const transcript = (call.transcript ?? []).map((entry) => ({
3386
3512
  role: entry.speaker === "bot" ? "assistant" : "user",
@@ -3504,13 +3630,10 @@ async function createRealtimeInstructionsResolver(params) {
3504
3630
  })];
3505
3631
  }));
3506
3632
  const instructionsByAgentId = new Map(entries);
3507
- return (call) => {
3508
- const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
3509
- const effectiveConfig = resolveVoiceCallEffectiveConfig(params.config, numberRouteKey).config;
3510
- return instructionsByAgentId.get(resolveCallAgentId(call, effectiveConfig)) ?? genericInstructions;
3511
- };
3633
+ return (call) => instructionsByAgentId.get(resolveCallAgentId(call)) ?? genericInstructions;
3512
3634
  }
3513
3635
  async function createVoiceCallRuntime(params) {
3636
+ params.scheduler.signal.throwIfAborted();
3514
3637
  const { config: rawConfig, coreConfig, fullConfig, agentRuntime, stateRuntime, ttsRuntime, logger } = params;
3515
3638
  const log = logger ?? {
3516
3639
  info: console.log,
@@ -3532,7 +3655,7 @@ async function createVoiceCallRuntime(params) {
3532
3655
  if (stateRuntime) setVoiceCallStateRuntime({ state: stateRuntime });
3533
3656
  const manager = new CallManager(config, void 0, cfg.session, stateRuntime);
3534
3657
  const realtimeVoiceRuntime = config.realtime.enabled ? await loadRealtimeVoiceRuntime() : null;
3535
- const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig, cfg, agentRuntime, log);
3658
+ const webhookServer = new VoiceCallWebhookServer(params.scheduler, config, manager, provider, coreConfig, cfg, agentRuntime, log);
3536
3659
  if (realtimeVoiceRuntime) {
3537
3660
  const { RealtimeCallHandler } = await loadRealtimeHandler();
3538
3661
  const resolveRealtimeInstructions = await createRealtimeInstructionsResolver({
@@ -3547,7 +3670,7 @@ async function createVoiceCallRuntime(params) {
3547
3670
  const resolveCallRegistration = (call) => {
3548
3671
  const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
3549
3672
  const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
3550
- const agentId = resolveCallAgentId(call, effectiveConfig);
3673
+ const agentId = resolveCallAgentId(call);
3551
3674
  const resolved = realtimeVoiceRuntime.resolveConfiguredRealtimeVoiceProvider({
3552
3675
  configuredProviderId: effectiveConfig.realtime.provider,
3553
3676
  providerConfigs: effectiveConfig.realtime.providers,
@@ -3571,7 +3694,7 @@ async function createVoiceCallRuntime(params) {
3571
3694
  if (!call) return { error: `Call "${callId}" not found` };
3572
3695
  const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
3573
3696
  const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
3574
- const agentId = resolveCallAgentId(call, effectiveConfig);
3697
+ const agentId = resolveCallAgentId(call);
3575
3698
  const sessionKey = resolveVoiceCallSessionKey({
3576
3699
  config: {
3577
3700
  ...effectiveConfig,
@@ -3601,6 +3724,12 @@ async function createVoiceCallRuntime(params) {
3601
3724
  labels: {
3602
3725
  audienceLabel: "caller",
3603
3726
  contextName: "OpenClaw memory or session context"
3727
+ },
3728
+ liveness: {
3729
+ signal: handlerContext.abortSignal,
3730
+ assertCurrent() {
3731
+ if (manager.getCall(callId) !== call || TerminalStates.has(call.state)) throw new Error(`Call "${callId}" is no longer active`);
3732
+ }
3604
3733
  }
3605
3734
  });
3606
3735
  handlerContext.abortSignal?.throwIfAborted();
@@ -3719,4 +3848,4 @@ async function createVoiceCallRuntime(params) {
3719
3848
  }
3720
3849
  }
3721
3850
  //#endregion
3722
- export { resolveCallAgentId as _, normalizeProviderStatus as a, mapVoiceToPolly as b, canonicalizeVoiceCallMediaBase64 as c, cleanupTailscaleExposureRoute as d, getTailscaleSelfInfo as f, resolveVoiceResponseModel as g, chunkAudio as h, mapProviderStatusToEndReason as i, getHeader as l, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as m, normalizeProxyIp as n, WebSocket as o, setupTailscaleExposureRoutes as p, isProviderStatusTerminal as r, WebSocketServer as s, createVoiceCallRuntime as t, resolveWebhookExposureStatus as u, resolveVoiceCallAgentId as v, escapeXml as y };
3851
+ export { MAX_CALL_RECORD_EVENTS as _, normalizeProviderStatus as a, loadActiveCallsFromStore as b, resolveWebhookExposureStatus as c, setupTailscaleExposureRoutes as d, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as f, generateDtmfRedirectTwiml as g, resolveVoiceCallAgentId as h, mapProviderStatusToEndReason as i, cleanupTailscaleExposureRoute as l, resolveCallAgentId as m, normalizeProxyIp as n, canonicalizeVoiceCallMediaBase64 as o, resolveVoiceResponseModel as p, isProviderStatusTerminal as r, getHeader as s, createVoiceCallRuntime as t, getTailscaleSelfInfo as u, findCallInStore as v, setVoiceCallStateRuntime as x, getCallHistoryFromStore as y };