@openclaw/voice-call 2026.9.1-beta.1 → 2026.9.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.
@@ -1,22 +1,17 @@
1
- import { TtsConfigSchema, isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "./runtime-api.js";
1
+ import { isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, sendHttpRequestRejection } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { t as TWILIO_REGIONS } from "./twilio-region-4fkgz3UG.js";
4
- import { _ as releaseRejectedProviderCall, b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, g as appendCallReplayKey, l as findCallMatchesInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore, v as rememberManagerReplayKey, x as TerminalStates, y as reserveRejectedProviderCall } from "./store-path-51_oC_xn.js";
3
+ import { a as resolveVoiceCallEffectiveConfig, c as resolveVoiceCallSessionKey, f as resolveVoiceCallRealtimeTools, i as resolveVoiceCallConfig, l as resolveVoiceCallStreamExposurePaths, n as normalizeVoiceCallConfig, o as resolveVoiceCallNumberRouteKeyForCall, r as resolveTwilioAuthToken, u as validateProviderConfig } from "./config-DQwvsxeV.js";
4
+ import { _ as releaseRejectedProviderCall, b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, g as appendCallReplayKey, l as findCallInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore, v as rememberManagerReplayKey, x as TerminalStates, y as reserveRejectedProviderCall } from "./store-path-C_z9zKnA.js";
5
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
6
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
7
- import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
7
+ import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
8
8
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
9
9
  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
- import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES, assertRealtimeVoiceAgentConsultModelSelectionUnlocked, buildRealtimeVoiceAgentConsultPolicyInstructions, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createTalkSessionController, recordTalkObservabilityEvent, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceFastContextConsult } from "openclaw/plugin-sdk/realtime-voice";
13
- import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
14
- import { buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
15
- import { canonicalizeMainSessionAlias } from "openclaw/plugin-sdk/session-store-runtime";
16
- import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core";
12
+ import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, assertRealtimeVoiceAgentConsultModelSelectionUnlocked, buildRealtimeVoiceAgentConsultPolicyInstructions, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createTalkSessionController, recordTalkObservabilityEvent, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceFastContextConsult } from "openclaw/plugin-sdk/realtime-voice";
17
13
  import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
18
- import { z } from "zod";
19
- import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
14
+ import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
20
15
  import fs from "node:fs";
21
16
  import crypto from "node:crypto";
22
17
  import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
@@ -31,690 +26,6 @@ import { URL as URL$1 } from "node:url";
31
26
  import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
32
27
  import { WebSocket, WebSocketServer } from "ws";
33
28
  import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime";
34
- //#region extensions/voice-call/src/realtime-call-control.ts
35
- /** Stable provider-facing tool name for ending the current phone call. */
36
- const REALTIME_VOICE_END_CALL_TOOL_NAME = "openclaw_end_call";
37
- /** Closure-bound end-call control exposed on every realtime phone call. */
38
- const REALTIME_VOICE_END_CALL_TOOL = {
39
- type: "function",
40
- name: REALTIME_VOICE_END_CALL_TOOL_NAME,
41
- description: "End the current phone call immediately. Speak any final words to the caller before invoking this tool because the call ends as soon as it is invoked and no further reply will be spoken.",
42
- parameters: {
43
- type: "object",
44
- properties: {}
45
- }
46
- };
47
- /** Merge built-in call controls with consult and configured realtime tools. */
48
- function resolveVoiceCallRealtimeTools(policy, customTools = []) {
49
- const tools = resolveRealtimeVoiceAgentConsultTools(policy, customTools).filter((tool) => tool.name !== REALTIME_VOICE_END_CALL_TOOL_NAME);
50
- return [REALTIME_VOICE_END_CALL_TOOL, ...tools];
51
- }
52
- //#endregion
53
- //#region extensions/voice-call/src/realtime-defaults.ts
54
- /** Baseline instructions that keep realtime calls brief and route deep work to agent consult. */
55
- const DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS = `You are OpenClaw's phone-call realtime voice interface. Keep spoken replies brief and natural. When a question needs deeper reasoning, current information, or tools, call ${REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME} before answering. When the caller asks to end the call, speak any final words first, then call ${REALTIME_VOICE_END_CALL_TOOL_NAME}; it ends the call immediately and nothing after it will be spoken.`;
56
- //#endregion
57
- //#region extensions/voice-call/src/tailscale-config.ts
58
- const VoiceCallTailscaleConfigSchema = z.object({
59
- mode: z.enum([
60
- "off",
61
- "serve",
62
- "funnel"
63
- ]).default("off"),
64
- port: z.number().int().positive().max(MAX_TCP_PORT).default(443),
65
- path: z.string().min(1).default("/voice/webhook")
66
- }).strict().default({
67
- mode: "off",
68
- port: 443,
69
- path: "/voice/webhook"
70
- });
71
- const TAILSCALE_FUNNEL_HTTPS_PORTS = /* @__PURE__ */ new Set([
72
- 443,
73
- 8443,
74
- 1e4
75
- ]);
76
- function isTailscalePortAllowed(config) {
77
- return !(config.tailscale.mode === "funnel" || config.tunnel.provider === "tailscale-funnel") || TAILSCALE_FUNNEL_HTTPS_PORTS.has(config.tailscale.port);
78
- }
79
- //#endregion
80
- //#region extensions/voice-call/src/config.ts
81
- /**
82
- * E.164 phone number format: +[country code][number]
83
- * Examples use 555 prefix (reserved for fictional numbers)
84
- */
85
- const E164Schema = z.string().regex(/^\+[1-9]\d{1,14}$/, "Expected E.164 format, e.g. +15550001234");
86
- /**
87
- * Controls how inbound calls are handled:
88
- * - "disabled": Block all inbound calls (outbound only)
89
- * - "allowlist": Only accept calls from numbers in allowFrom
90
- * - "pairing": Unknown callers can request pairing (future)
91
- * - "open": Accept all inbound calls (dangerous!)
92
- */
93
- const InboundPolicySchema = z.enum([
94
- "disabled",
95
- "allowlist",
96
- "pairing",
97
- "open"
98
- ]);
99
- const SecretInputSchema = buildSecretInputSchema();
100
- const TelnyxConfigSchema = z.object({
101
- /** Telnyx API v2 key */
102
- apiKey: z.string().min(1).optional(),
103
- /** Telnyx connection ID (from Call Control app) */
104
- connectionId: z.string().min(1).optional(),
105
- /** Public key for webhook signature verification */
106
- publicKey: z.string().min(1).optional()
107
- }).strict();
108
- const TwilioConfigSchema = z.object({
109
- /** Twilio Account SID */
110
- accountSid: z.string().min(1).optional(),
111
- /** Twilio Auth Token */
112
- authToken: SecretInputSchema.optional(),
113
- /** Twilio processing Region (for example, ie1) */
114
- region: z.enum(TWILIO_REGIONS).optional()
115
- }).strict();
116
- const PlivoConfigSchema = z.object({
117
- /** Plivo Auth ID (starts with MA/SA) */
118
- authId: z.string().min(1).optional(),
119
- /** Plivo Auth Token */
120
- authToken: z.string().min(1).optional()
121
- }).strict();
122
- const VoiceCallNumberRouteConfigSchema = z.object({
123
- /** Greeting message for inbound calls to this number. */
124
- inboundGreeting: z.string().optional(),
125
- /** TTS override for inbound calls to this number. Deep-merges with global voice-call TTS. */
126
- tts: TtsConfigSchema,
127
- /** Agent ID to use for voice response generation for this number. */
128
- agentId: z.string().min(1).optional(),
129
- /** Optional model override for voice responses for this number. */
130
- responseModel: z.string().optional(),
131
- /** System prompt for voice responses for this number. */
132
- responseSystemPrompt: z.string().optional(),
133
- /** Timeout for response generation in ms for this number. */
134
- responseTimeoutMs: z.number().int().positive().optional()
135
- }).strict();
136
- const VoiceCallServeConfigSchema = z.object({
137
- /** Port to listen on */
138
- port: z.number().int().positive().default(3334),
139
- /** Bind address */
140
- bind: z.string().default("127.0.0.1"),
141
- /** Webhook path */
142
- path: z.string().min(1).default("/voice/webhook")
143
- }).strict().default({
144
- port: 3334,
145
- bind: "127.0.0.1",
146
- path: "/voice/webhook"
147
- });
148
- const VoiceCallTunnelConfigSchema = z.object({
149
- /**
150
- * Tunnel provider:
151
- * - "none": No tunnel (use publicUrl if set, or manual setup)
152
- * - "ngrok": Use ngrok for public HTTPS tunnel
153
- * - "tailscale-serve": Tailscale serve (private to tailnet)
154
- * - "tailscale-funnel": Tailscale funnel (public HTTPS)
155
- */
156
- provider: z.enum([
157
- "none",
158
- "ngrok",
159
- "tailscale-serve",
160
- "tailscale-funnel"
161
- ]).default("none"),
162
- /** ngrok auth token (optional, enables longer sessions and more features) */
163
- ngrokAuthToken: z.string().min(1).optional(),
164
- /** ngrok custom domain (paid feature, e.g., "myapp.ngrok.io") */
165
- ngrokDomain: z.string().min(1).optional(),
166
- /**
167
- * Allow ngrok free tier compatibility mode.
168
- * When true, forwarded headers may be trusted for loopback requests
169
- * to reconstruct the public ngrok URL used for signing.
170
- *
171
- * IMPORTANT: This does NOT bypass signature verification.
172
- */
173
- allowNgrokFreeTierLoopbackBypass: z.boolean().default(false)
174
- }).strict().default({
175
- provider: "none",
176
- allowNgrokFreeTierLoopbackBypass: false
177
- });
178
- const VoiceCallWebhookSecurityConfigSchema = z.object({
179
- /**
180
- * Allowed hostnames for webhook URL reconstruction.
181
- * Only these hosts are accepted from forwarding headers.
182
- */
183
- allowedHosts: z.array(z.string().min(1)).default([]),
184
- /**
185
- * Trust X-Forwarded-* headers without a hostname allowlist.
186
- * WARNING: Only enable if you trust your proxy configuration.
187
- */
188
- trustForwardingHeaders: z.boolean().default(false),
189
- /**
190
- * Trusted proxy IP addresses. Forwarded headers are only trusted when
191
- * the remote IP matches one of these addresses.
192
- */
193
- trustedProxyIPs: z.array(z.string().min(1)).default([])
194
- }).strict().default({
195
- allowedHosts: [],
196
- trustForwardingHeaders: false,
197
- trustedProxyIPs: []
198
- });
199
- /**
200
- * Call mode determines how outbound calls behave:
201
- * - "notify": Deliver message and auto-hangup after delay (one-way notification)
202
- * - "conversation": Stay open for back-and-forth until explicit end or timeout
203
- */
204
- const CallModeSchema = z.enum(["notify", "conversation"]);
205
- const VoiceCallSessionScopeSchema = z.enum([
206
- "per-phone",
207
- "per-call",
208
- "main"
209
- ]);
210
- const OutboundConfigSchema = z.object({
211
- /** Default call mode for outbound calls */
212
- defaultMode: CallModeSchema.default("notify"),
213
- /** Seconds to wait after TTS before auto-hangup in notify mode */
214
- notifyHangupDelaySec: z.number().int().nonnegative().default(3)
215
- }).strict().default({
216
- defaultMode: "notify",
217
- notifyHangupDelaySec: 3
218
- });
219
- const RealtimeToolSchema = z.object({
220
- type: z.literal("function"),
221
- name: z.string().min(1),
222
- description: z.string(),
223
- parameters: z.object({
224
- type: z.literal("object"),
225
- properties: z.record(z.string(), z.unknown()),
226
- required: z.array(z.string()).optional()
227
- })
228
- }).strict();
229
- const VoiceCallRealtimeProvidersConfigSchema = z.record(z.string(), z.record(z.string(), z.unknown())).default({});
230
- const VoiceCallRealtimeToolPolicySchema = z.enum(REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES);
231
- const VoiceCallRealtimeConsultPolicySchema = z.enum([
232
- "auto",
233
- "substantive",
234
- "always"
235
- ]);
236
- const VoiceCallRealtimeFastContextSourceSchema = z.enum(["memory", "sessions"]);
237
- const VoiceCallRealtimeFastContextConfigSchema = z.object({
238
- /** Enable bounded memory/session lookup before the full consult agent. */
239
- enabled: z.boolean().default(false),
240
- /** Hard deadline for the fast context lookup. */
241
- timeoutMs: z.number().int().positive().default(800),
242
- /** Maximum memory/session hits to inject into the realtime tool result. */
243
- maxResults: z.number().int().positive().default(3),
244
- /** Indexed sources used by the fast context lookup. */
245
- sources: z.array(VoiceCallRealtimeFastContextSourceSchema).min(1).default(["memory", "sessions"]),
246
- /** Fall back to the full agent consult when fast context has no answer. */
247
- fallbackToConsult: z.boolean().default(false)
248
- }).strict().default({
249
- enabled: false,
250
- timeoutMs: 800,
251
- maxResults: 3,
252
- sources: ["memory", "sessions"],
253
- fallbackToConsult: false
254
- });
255
- const VoiceCallRealtimeAgentContextConfigSchema = z.object({
256
- /** Inject a compact agent persona/context capsule into realtime voice instructions. */
257
- enabled: z.boolean().default(false),
258
- /** Maximum number of characters from the generated capsule to append. */
259
- maxChars: z.number().int().positive().default(6e3),
260
- /** Include configured agent identity fields. */
261
- includeIdentity: z.boolean().default(true),
262
- /** Include selected workspace files such as SOUL.md and IDENTITY.md. */
263
- includeWorkspaceFiles: z.boolean().default(true),
264
- /** Workspace-relative files to include, bounded by maxChars. */
265
- files: z.array(z.string().min(1)).default([
266
- "SOUL.md",
267
- "IDENTITY.md",
268
- "USER.md"
269
- ])
270
- }).strict().default({
271
- enabled: false,
272
- maxChars: 6e3,
273
- includeIdentity: true,
274
- includeWorkspaceFiles: true,
275
- files: [
276
- "SOUL.md",
277
- "IDENTITY.md",
278
- "USER.md"
279
- ]
280
- });
281
- const VoiceCallRealtimeConsultThinkingLevelSchema = z.enum([
282
- "off",
283
- "minimal",
284
- "low",
285
- "medium",
286
- "high",
287
- "xhigh",
288
- "adaptive",
289
- "max",
290
- "ultra"
291
- ]);
292
- const VoiceCallStreamingProvidersConfigSchema = z.record(z.string(), z.record(z.string(), z.unknown())).default({});
293
- const VoiceCallRealtimeConfigSchema = z.object({
294
- /** Enable realtime voice-to-voice mode. */
295
- enabled: z.boolean().default(false),
296
- /** Provider id from registered realtime voice providers. */
297
- provider: z.string().min(1).optional(),
298
- /** Optional override for the local WebSocket route path. */
299
- streamPath: z.string().min(1).optional(),
300
- /** System instructions passed to the realtime provider. */
301
- instructions: z.string().default(DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS),
302
- /** Tool policy for the shared OpenClaw agent consult tool. */
303
- toolPolicy: VoiceCallRealtimeToolPolicySchema.default("safe-read-only"),
304
- /** Guidance for when the realtime model should call the OpenClaw agent consult tool. */
305
- consultPolicy: VoiceCallRealtimeConsultPolicySchema.default("auto"),
306
- /** Optional thinking level override for the regular agent behind realtime consults. */
307
- consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional(),
308
- /** Optional fast mode override for the regular agent behind realtime consults. */
309
- consultFastMode: z.boolean().optional(),
310
- /** Tool definitions exposed to the realtime provider. */
311
- tools: z.array(RealtimeToolSchema).default([]),
312
- /** Low-latency memory/session context for the consult tool. */
313
- fastContext: VoiceCallRealtimeFastContextConfigSchema,
314
- /** Bounded agent persona/context injection for the fast realtime voice path. */
315
- agentContext: VoiceCallRealtimeAgentContextConfigSchema,
316
- /** Provider-owned raw config blobs keyed by provider id. */
317
- providers: VoiceCallRealtimeProvidersConfigSchema
318
- }).strict().default({
319
- enabled: false,
320
- instructions: DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS,
321
- toolPolicy: "safe-read-only",
322
- consultPolicy: "auto",
323
- tools: [],
324
- fastContext: {
325
- enabled: false,
326
- timeoutMs: 800,
327
- maxResults: 3,
328
- sources: ["memory", "sessions"],
329
- fallbackToConsult: false
330
- },
331
- agentContext: {
332
- enabled: false,
333
- maxChars: 6e3,
334
- includeIdentity: true,
335
- includeWorkspaceFiles: true,
336
- files: [
337
- "SOUL.md",
338
- "IDENTITY.md",
339
- "USER.md"
340
- ]
341
- },
342
- providers: {}
343
- });
344
- const VoiceCallStreamingConfigSchema = z.object({
345
- /** Enable Twilio Media Streams for real-time transcription. */
346
- enabled: z.boolean().default(false),
347
- /** Provider id from registered realtime transcription providers. */
348
- provider: z.string().min(1).optional(),
349
- /** WebSocket path for media stream connections */
350
- streamPath: z.string().min(1).default("/voice/stream"),
351
- /** Provider-owned raw config blobs keyed by provider id. */
352
- providers: VoiceCallStreamingProvidersConfigSchema,
353
- /**
354
- * Close unauthenticated media stream sockets if no valid `start` frame arrives in time.
355
- * Protects against pre-auth idle connection hold attacks.
356
- */
357
- preStartTimeoutMs: z.number().int().positive().default(5e3),
358
- /** Maximum number of concurrently pending (pre-start) media stream sockets. */
359
- maxPendingConnections: z.number().int().positive().default(32),
360
- /** Maximum pending media stream sockets per source IP. */
361
- maxPendingConnectionsPerIp: z.number().int().positive().default(4),
362
- /** Hard cap for all open media stream sockets (pending + active). */
363
- maxConnections: z.number().int().positive().default(128)
364
- }).strict().default({
365
- enabled: false,
366
- streamPath: "/voice/stream",
367
- providers: {},
368
- preStartTimeoutMs: 5e3,
369
- maxPendingConnections: 32,
370
- maxPendingConnectionsPerIp: 4,
371
- maxConnections: 128
372
- });
373
- const VoiceCallConfigSchema = z.object({
374
- /** Enable voice call functionality */
375
- enabled: z.boolean().default(false),
376
- /** Active provider (telnyx, twilio, plivo, or mock) */
377
- provider: z.enum([
378
- "telnyx",
379
- "twilio",
380
- "plivo",
381
- "mock"
382
- ]).optional(),
383
- /** Telnyx-specific configuration */
384
- telnyx: TelnyxConfigSchema.optional(),
385
- /** Twilio-specific configuration */
386
- twilio: TwilioConfigSchema.optional(),
387
- /** Plivo-specific configuration */
388
- plivo: PlivoConfigSchema.optional(),
389
- /** Phone number to call from (E.164) */
390
- fromNumber: E164Schema.optional(),
391
- /** Default phone number to call (E.164) */
392
- toNumber: E164Schema.optional(),
393
- /** Inbound call policy */
394
- inboundPolicy: InboundPolicySchema.default("disabled"),
395
- /** Allowlist of phone numbers for inbound calls (E.164) */
396
- allowFrom: z.array(E164Schema).default([]),
397
- /** Greeting message for inbound calls */
398
- inboundGreeting: z.string().optional(),
399
- /** Per-dialed-number overrides for inbound calls. Keys are E.164 numbers. */
400
- numbers: z.record(E164Schema, VoiceCallNumberRouteConfigSchema).default({}),
401
- /** Outbound call configuration */
402
- outbound: OutboundConfigSchema,
403
- /** Maximum call duration in seconds */
404
- maxDurationSeconds: z.number().int().positive().default(300),
405
- /**
406
- * Maximum age of a call in seconds before it is automatically reaped.
407
- * Catches calls stuck before answer (for example, local mock calls that
408
- * never receive provider webhooks). Set to 0 to disable.
409
- */
410
- staleCallReaperSeconds: z.number().int().nonnegative().default(120),
411
- /** Silence timeout for end-of-speech detection (ms) */
412
- silenceTimeoutMs: z.number().int().positive().default(800),
413
- /** Timeout for user transcript (ms) */
414
- transcriptTimeoutMs: z.number().int().positive().default(18e4),
415
- /** Ring timeout for outbound calls (ms) */
416
- ringTimeoutMs: z.number().int().positive().default(3e4),
417
- /** Maximum concurrent calls */
418
- maxConcurrentCalls: z.number().int().positive().default(1),
419
- /** Webhook server configuration */
420
- serve: VoiceCallServeConfigSchema,
421
- /** @deprecated Prefer tunnel config. */
422
- tailscale: VoiceCallTailscaleConfigSchema,
423
- /** Tunnel configuration (unified ngrok/tailscale) */
424
- tunnel: VoiceCallTunnelConfigSchema,
425
- /** Webhook signature reconstruction and proxy trust configuration */
426
- webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
427
- /** Real-time audio streaming configuration */
428
- streaming: VoiceCallStreamingConfigSchema,
429
- /** Realtime voice-to-voice configuration */
430
- realtime: VoiceCallRealtimeConfigSchema,
431
- /** Session memory scope for voice conversations. */
432
- sessionScope: VoiceCallSessionScopeSchema.default("per-phone"),
433
- /** Public webhook URL override (if set, bypasses tunnel auto-detection) */
434
- publicUrl: z.string().url().optional(),
435
- /** Skip webhook signature verification (development only, NOT for production) */
436
- skipSignatureVerification: z.boolean().default(false),
437
- /** TTS override (deep-merges with core tts) */
438
- tts: TtsConfigSchema,
439
- /** Store path for call logs */
440
- store: z.string().optional(),
441
- /** Agent ID to use for voice response generation. Defaults to "main". */
442
- agentId: z.string().min(1).optional(),
443
- /** Optional model override for generating voice responses. */
444
- responseModel: z.string().optional(),
445
- /** System prompt for voice responses */
446
- responseSystemPrompt: z.string().optional(),
447
- /** Timeout for response generation in ms (default 30s) */
448
- responseTimeoutMs: z.number().int().positive().default(3e4)
449
- }).strict().refine(isTailscalePortAllowed, {
450
- path: ["tailscale", "port"],
451
- message: "Tailscale Funnel HTTPS port must be one of 443, 8443, 10000"
452
- });
453
- const TWILIO_AUTH_TOKEN_PATH = "plugins.entries.voice-call.config.twilio.authToken";
454
- const DEFAULT_VOICE_CALL_CONFIG = VoiceCallConfigSchema.parse({});
455
- function cloneDefaultVoiceCallConfig() {
456
- return structuredClone(DEFAULT_VOICE_CALL_CONFIG);
457
- }
458
- function defaultRealtimeStreamPathForServePath(servePath) {
459
- const normalized = normalizeWebhookPath(servePath);
460
- if (normalized.endsWith("/webhook")) return `${normalized.slice(0, -8)}/stream/realtime`;
461
- if (normalized === "/") return "/voice/stream/realtime";
462
- return `${normalized}/stream/realtime`;
463
- }
464
- function resolveVoiceCallPublicPathPrefix(publicWebhookPath, localWebhookPath) {
465
- const publicPath = normalizeWebhookPath(publicWebhookPath);
466
- const localPathIndex = publicPath.indexOf(normalizeWebhookPath(localWebhookPath));
467
- return localPathIndex > 0 ? publicPath.slice(0, localPathIndex) : "";
468
- }
469
- function resolveVoiceCallStreamExposurePaths(config, webhookPaths = {}) {
470
- const exposurePaths = [];
471
- const localWebhookPath = webhookPaths.localWebhookPath ?? config.serve.path;
472
- const publicPathPrefix = resolveVoiceCallPublicPathPrefix(webhookPaths.publicWebhookPath ?? config.tailscale.path, localWebhookPath);
473
- if (config.realtime.enabled) {
474
- const localPath = normalizeWebhookPath(config.realtime.streamPath ?? defaultRealtimeStreamPathForServePath(config.serve.path));
475
- exposurePaths.push({
476
- localPath,
477
- publicPath: `${publicPathPrefix}${localPath}`
478
- });
479
- }
480
- if (config.streaming.enabled) {
481
- const localPath = normalizeWebhookPath(config.streaming.streamPath);
482
- if (!exposurePaths.some((path) => path.localPath === localPath && path.publicPath === localPath)) exposurePaths.push({
483
- localPath,
484
- publicPath: localPath
485
- });
486
- }
487
- return exposurePaths;
488
- }
489
- function normalizeVoiceCallTtsConfig(defaults, overrides) {
490
- if (!defaults && !overrides) return;
491
- return TtsConfigSchema.parse(mergeDeep(defaults ?? {}, overrides ?? {}));
492
- }
493
- function normalizePhoneRouteKey(phone) {
494
- return phone?.replace(/\D/g, "") ?? "";
495
- }
496
- function resolveVoiceCallNumberRouteKey(config, phone) {
497
- const routes = config.numbers;
498
- if (!routes) return;
499
- if (phone && Object.hasOwn(routes, phone)) return phone;
500
- const normalizedPhone = normalizePhoneRouteKey(phone);
501
- if (!normalizedPhone) return;
502
- return Object.keys(routes).find((routeKey) => normalizePhoneRouteKey(routeKey) === normalizedPhone);
503
- }
504
- /** Resolve inbound-only number routing from a persisted call record. */
505
- function resolveVoiceCallNumberRouteKeyForCall(call) {
506
- if (call.direction !== "inbound") return;
507
- const storedRouteKey = call.metadata?.numberRouteKey;
508
- if (typeof storedRouteKey === "string") return storedRouteKey;
509
- return call.to;
510
- }
511
- function resolveVoiceCallEffectiveConfig(config, phoneOrRouteKey) {
512
- const numberRouteKey = resolveVoiceCallNumberRouteKey(config, phoneOrRouteKey);
513
- if (!numberRouteKey) return { config };
514
- const route = config.numbers[numberRouteKey];
515
- if (!route) return { config };
516
- return {
517
- numberRouteKey,
518
- config: {
519
- ...config,
520
- ...route,
521
- tts: normalizeVoiceCallTtsConfig(config.tts, route.tts),
522
- numbers: config.numbers
523
- }
524
- };
525
- }
526
- function sanitizeVoiceCallProviderConfigs(value) {
527
- if (!value) return {};
528
- return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== void 0));
529
- }
530
- function sanitizeVoiceCallNumberRoutes(value) {
531
- if (!value) return {};
532
- return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== void 0).map(([key, route]) => [key, VoiceCallNumberRouteConfigSchema.parse(route)]));
533
- }
534
- function resolveTwilioAuthToken(config) {
535
- return normalizeResolvedSecretInputString({
536
- value: config.twilio?.authToken,
537
- path: TWILIO_AUTH_TOKEN_PATH
538
- });
539
- }
540
- function normalizeVoiceCallConfig(config) {
541
- const defaults = cloneDefaultVoiceCallConfig();
542
- const serve = {
543
- ...defaults.serve,
544
- ...config.serve
545
- };
546
- const streamingProvider = config.streaming?.provider;
547
- const streamingProviders = sanitizeVoiceCallProviderConfigs(config.streaming?.providers ?? defaults.streaming.providers);
548
- const realtimeProvider = config.realtime?.provider ?? defaults.realtime.provider;
549
- const realtimeProviders = sanitizeVoiceCallProviderConfigs(config.realtime?.providers ?? defaults.realtime.providers);
550
- const realtimeFastContext = {
551
- ...defaults.realtime.fastContext,
552
- ...config.realtime?.fastContext,
553
- sources: config.realtime?.fastContext?.sources ?? defaults.realtime.fastContext.sources
554
- };
555
- const realtimeAgentContext = {
556
- ...defaults.realtime.agentContext,
557
- ...config.realtime?.agentContext,
558
- files: config.realtime?.agentContext?.files ?? defaults.realtime.agentContext.files
559
- };
560
- return {
561
- ...defaults,
562
- ...config,
563
- allowFrom: config.allowFrom ?? defaults.allowFrom,
564
- numbers: sanitizeVoiceCallNumberRoutes(config.numbers ?? defaults.numbers),
565
- outbound: {
566
- ...defaults.outbound,
567
- ...config.outbound
568
- },
569
- serve,
570
- tailscale: {
571
- ...defaults.tailscale,
572
- ...config.tailscale
573
- },
574
- tunnel: {
575
- ...defaults.tunnel,
576
- ...config.tunnel
577
- },
578
- webhookSecurity: {
579
- ...defaults.webhookSecurity,
580
- ...config.webhookSecurity,
581
- allowedHosts: config.webhookSecurity?.allowedHosts ?? defaults.webhookSecurity.allowedHosts,
582
- trustedProxyIPs: config.webhookSecurity?.trustedProxyIPs ?? defaults.webhookSecurity.trustedProxyIPs
583
- },
584
- streaming: {
585
- ...defaults.streaming,
586
- ...config.streaming,
587
- provider: streamingProvider,
588
- providers: streamingProviders
589
- },
590
- realtime: {
591
- ...defaults.realtime,
592
- ...config.realtime,
593
- provider: realtimeProvider,
594
- streamPath: config.realtime?.streamPath ?? defaultRealtimeStreamPathForServePath(serve.path ?? defaults.serve.path),
595
- tools: config.realtime?.tools ?? defaults.realtime.tools,
596
- consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional().parse(config.realtime?.consultThinkingLevel ?? defaults.realtime.consultThinkingLevel),
597
- consultFastMode: config.realtime?.consultFastMode ?? defaults.realtime.consultFastMode,
598
- fastContext: realtimeFastContext,
599
- agentContext: realtimeAgentContext,
600
- providers: realtimeProviders
601
- },
602
- tts: normalizeVoiceCallTtsConfig(defaults.tts, config.tts)
603
- };
604
- }
605
- function resolveVoiceCallSessionKey(params) {
606
- const explicit = params.explicitSessionKey?.trim();
607
- if (explicit) return resolveVoiceCallAgentSessionKey({
608
- config: params.config,
609
- sessionKey: explicit,
610
- coreSession: params.coreSession
611
- });
612
- if (params.config.sessionScope === "main") return resolveVoiceCallAgentSessionKey({
613
- config: params.config,
614
- sessionKey: "main",
615
- coreSession: params.coreSession
616
- });
617
- const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
618
- if (params.config.sessionScope === "per-call") return `${prefix}:call:${params.callId}`.toLowerCase();
619
- const normalizedPhone = params.phone?.replace(/\D/g, "");
620
- return (normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`).toLowerCase();
621
- }
622
- /** Resolve persisted or integration-provided keys into the configured agent namespace. */
623
- function resolveVoiceCallAgentSessionKey(params) {
624
- const sessionKey = params.sessionKey.trim();
625
- if (!sessionKey) throw new Error("Voice Call session key cannot be empty");
626
- const lower = sessionKey.toLowerCase();
627
- const agentId = normalizeAgentId(params.config.agentId);
628
- if (lower === "global" || lower === "unknown") return lower;
629
- const parsedInput = parseAgentSessionKey(sessionKey);
630
- let normalizedScopedKey;
631
- if (parsedInput && normalizeAgentId(parsedInput.agentId) === parsedInput.agentId && parsedInput.agentId === agentId) normalizedScopedKey = `agent:${parsedInput.agentId}:${parsedInput.rest}`;
632
- else {
633
- const wrappedInput = parseAgentSessionKey(`agent:${agentId}:${sessionKey}`);
634
- if (!wrappedInput) throw new Error("Voice Call session key could not be normalized");
635
- normalizedScopedKey = `agent:${agentId}:${wrappedInput.rest}`;
636
- }
637
- const canonicalMain = canonicalizeMainSessionAlias({
638
- cfg: { session: params.coreSession },
639
- agentId,
640
- sessionKey: normalizedScopedKey
641
- });
642
- return canonicalMain === normalizedScopedKey ? normalizedScopedKey : canonicalMain;
643
- }
644
- /**
645
- * Resolves the configuration by merging environment variables into missing fields.
646
- * Returns a new configuration object with environment variables applied.
647
- */
648
- function resolveVoiceCallConfig(config) {
649
- const resolved = normalizeVoiceCallConfig(config);
650
- if (resolved.provider === "telnyx") {
651
- resolved.telnyx = resolved.telnyx ?? {};
652
- resolved.telnyx.apiKey = resolved.telnyx.apiKey ?? resolveSpeechProviderApiKey(process.env.TELNYX_API_KEY);
653
- resolved.telnyx.connectionId = resolved.telnyx.connectionId ?? resolveSpeechProviderApiKey(process.env.TELNYX_CONNECTION_ID);
654
- resolved.telnyx.publicKey = resolved.telnyx.publicKey ?? resolveSpeechProviderApiKey(process.env.TELNYX_PUBLIC_KEY);
655
- }
656
- if (resolved.provider === "twilio") {
657
- resolved.fromNumber = resolved.fromNumber ?? resolveSpeechProviderApiKey(process.env.TWILIO_FROM_NUMBER);
658
- resolved.twilio = resolved.twilio ?? {};
659
- resolved.twilio.accountSid = resolved.twilio.accountSid ?? resolveSpeechProviderApiKey(process.env.TWILIO_ACCOUNT_SID);
660
- resolved.twilio.authToken = resolved.twilio.authToken ?? resolveSpeechProviderApiKey(process.env.TWILIO_AUTH_TOKEN);
661
- }
662
- if (resolved.provider === "plivo") {
663
- resolved.plivo = resolved.plivo ?? {};
664
- resolved.plivo.authId = resolved.plivo.authId ?? resolveSpeechProviderApiKey(process.env.PLIVO_AUTH_ID);
665
- resolved.plivo.authToken = resolved.plivo.authToken ?? resolveSpeechProviderApiKey(process.env.PLIVO_AUTH_TOKEN);
666
- }
667
- resolved.tunnel = resolved.tunnel ?? {
668
- provider: "none",
669
- allowNgrokFreeTierLoopbackBypass: false
670
- };
671
- resolved.tunnel.allowNgrokFreeTierLoopbackBypass = resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
672
- resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? resolveSpeechProviderApiKey(process.env.NGROK_AUTHTOKEN);
673
- resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? resolveSpeechProviderApiKey(process.env.NGROK_DOMAIN);
674
- resolved.webhookSecurity = resolved.webhookSecurity ?? {
675
- allowedHosts: [],
676
- trustForwardingHeaders: false,
677
- trustedProxyIPs: []
678
- };
679
- resolved.webhookSecurity.allowedHosts = resolved.webhookSecurity.allowedHosts ?? [];
680
- resolved.webhookSecurity.trustForwardingHeaders = resolved.webhookSecurity.trustForwardingHeaders ?? false;
681
- resolved.webhookSecurity.trustedProxyIPs = resolved.webhookSecurity.trustedProxyIPs ?? [];
682
- return normalizeVoiceCallConfig(resolved);
683
- }
684
- /**
685
- * Validate that the configuration has all required fields for the selected provider.
686
- */
687
- function validateProviderConfig(config) {
688
- const errors = [];
689
- if (!config.enabled) return {
690
- valid: true,
691
- errors: []
692
- };
693
- if (!config.provider) errors.push("plugins.entries.voice-call.config.provider is required");
694
- if (!config.fromNumber && config.provider !== "mock") errors.push(config.provider === "twilio" ? "plugins.entries.voice-call.config.fromNumber is required (or set TWILIO_FROM_NUMBER env)" : "plugins.entries.voice-call.config.fromNumber is required");
695
- if (config.provider === "telnyx") {
696
- if (!config.telnyx?.apiKey) errors.push("plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)");
697
- if (!config.telnyx?.connectionId) errors.push("plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)");
698
- if (!config.skipSignatureVerification && !config.telnyx?.publicKey) errors.push("plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)");
699
- }
700
- if (config.provider === "twilio") {
701
- if (!config.twilio?.accountSid) errors.push("plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)");
702
- if (!hasConfiguredSecretInput(config.twilio?.authToken)) errors.push("plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)");
703
- }
704
- if (config.provider === "plivo") {
705
- if (!config.plivo?.authId) errors.push("plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)");
706
- if (!config.plivo?.authToken) errors.push("plugins.entries.voice-call.config.plivo.authToken is required (or set PLIVO_AUTH_TOKEN env)");
707
- }
708
- if (config.realtime.enabled && config.inboundPolicy === "disabled") errors.push("plugins.entries.voice-call.config.inboundPolicy must not be \"disabled\" when realtime.enabled is true");
709
- if (config.realtime.enabled && config.streaming.enabled) errors.push("plugins.entries.voice-call.config.realtime.enabled and plugins.entries.voice-call.config.streaming.enabled cannot both be true");
710
- if (config.streaming.enabled && config.provider && config.provider !== "twilio") errors.push("plugins.entries.voice-call.config.provider must be \"twilio\" when streaming.enabled is true");
711
- if (config.realtime.enabled && config.provider && config.provider !== "twilio" && config.provider !== "telnyx" && config.provider !== "mock") errors.push("plugins.entries.voice-call.config.provider must be \"twilio\", \"telnyx\", or \"mock\" when realtime.enabled is true");
712
- return {
713
- valid: errors.length === 0,
714
- errors
715
- };
716
- }
717
- //#endregion
718
29
  //#region extensions/voice-call/src/allowlist.ts
719
30
  /** Normalize a phone number to digits only. */
720
31
  function normalizePhoneNumber(input) {
@@ -1077,8 +388,8 @@ async function initiateCall(ctx, to, sessionKey, options) {
1077
388
  ...requesterSessionKey ? { requesterSessionKey } : {}
1078
389
  }
1079
390
  };
1080
- ctx.activeCalls.set(callId, callRecord);
1081
391
  persistCallRecord(ctx.storePath, callRecord);
392
+ ctx.activeCalls.set(callId, callRecord);
1082
393
  try {
1083
394
  let inlineTwiml;
1084
395
  let preConnectTwiml;
@@ -1109,10 +420,12 @@ async function initiateCall(ctx, to, sessionKey, options) {
1109
420
  streamAuthToken: streamSession.token
1110
421
  } : {}
1111
422
  });
1112
- callRecord.providerCallId = result.providerCallId;
1113
- ctx.providerCallIdMap.set(result.providerCallId, callId);
1114
- persistCallRecord(ctx.storePath, callRecord);
1115
- console.log(`[voice-call] Outbound call initiated: callId=${callId} providerCallId=${result.providerCallId} mode=${mode} preConnectDtmf=${preConnectTwiml ? "yes" : "no"} initialMessage=${initialMessage ? "yes" : "no"}`);
423
+ if (ctx.activeCalls.get(callId) === callRecord && !callRecord.providerCallId) {
424
+ callRecord.providerCallId = result.providerCallId;
425
+ ctx.providerCallIdMap.set(result.providerCallId, callId);
426
+ persistCallRecord(ctx.storePath, callRecord);
427
+ }
428
+ console.log(`[voice-call] Outbound call initiated: callId=${callId} providerCallId=${callRecord.providerCallId ?? result.providerCallId} mode=${mode} preConnectDtmf=${preConnectTwiml ? "yes" : "no"} initialMessage=${initialMessage ? "yes" : "no"}`);
1116
429
  return {
1117
430
  callId,
1118
431
  success: true
@@ -1442,7 +755,22 @@ function processEvent(ctx, event) {
1442
755
  providerCallIdMap: ctx.providerCallIdMap,
1443
756
  callIdOrProviderCallId: event.callId
1444
757
  });
1445
- const providerCallId = event.providerCallId;
758
+ let providerCallId = event.providerCallId;
759
+ let retained;
760
+ if (!call) {
761
+ retained = findCallInStore(ctx.storePath, event.callId);
762
+ if (!retained && providerCallId && providerCallId !== event.callId) retained = findCallInStore(ctx.storePath, providerCallId);
763
+ if (retained && retained.metadata?.rejectionReason !== "inbound-policy") {
764
+ call = ctx.activeCalls.get(retained.callId);
765
+ if (!call) return TerminalStates.has(retained.state) ? { kind: "ignored" } : {
766
+ kind: "ignored",
767
+ replayable: true
768
+ };
769
+ }
770
+ }
771
+ if (call && providerCallId && providerCallId !== call.providerCallId) {
772
+ if ((providerCallId === event.callId && retained ? retained : findCallInStore(ctx.storePath, providerCallId))?.callId === call.callId) providerCallId = call.providerCallId;
773
+ }
1446
774
  const eventDirection = event.direction === "inbound" || event.direction === "outbound" ? event.direction : void 0;
1447
775
  if (!call && providerCallId && eventDirection) {
1448
776
  if (eventDirection === "inbound" && !shouldAcceptInbound(ctx.config, event.from)) {
@@ -1514,14 +842,14 @@ function processEvent(ctx, event) {
1514
842
  }
1515
843
  };
1516
844
  const publishProviderCallId = (terminal = false) => {
1517
- if (!event.providerCallId || event.providerCallId === previousCall.providerCallId) return;
1518
- if (!terminal) ctx.providerCallIdMap.set(event.providerCallId, activeCall.callId);
845
+ if (!providerCallId || providerCallId === previousCall.providerCallId) return;
846
+ if (!terminal) ctx.providerCallIdMap.set(providerCallId, activeCall.callId);
1519
847
  if (previousCall.providerCallId) {
1520
848
  if (ctx.providerCallIdMap.get(previousCall.providerCallId) === activeCall.callId) ctx.providerCallIdMap.delete(previousCall.providerCallId);
1521
849
  }
1522
850
  };
1523
851
  try {
1524
- if (event.providerCallId && event.providerCallId !== activeCall.providerCallId) activeCall.providerCallId = event.providerCallId;
852
+ if (providerCallId && providerCallId !== activeCall.providerCallId) activeCall.providerCallId = providerCallId;
1525
853
  if (shouldCommitReplayKey) appendCallReplayKey(activeCall.processedEventIds, dedupeKey);
1526
854
  switch (event.type) {
1527
855
  case "call.initiated": {
@@ -1587,6 +915,7 @@ function processEvent(ctx, event) {
1587
915
  resolveTranscriptWaiter(ctx, activeCall.callId, event.transcript, event.turnToken);
1588
916
  });
1589
917
  }
918
+ if (event.transcript.trim()) effects.push(() => ctx.onCallerSpeech?.(activeCall));
1590
919
  prepareLiveDurationTimer();
1591
920
  transitionState(activeCall, "listening");
1592
921
  break;
@@ -1619,7 +948,6 @@ function processEvent(ctx, event) {
1619
948
  kind: "processed",
1620
949
  replayable: true
1621
950
  };
1622
- break;
1623
951
  }
1624
952
  persistCallRecord(ctx.storePath, activeCall);
1625
953
  publishProviderCallId();
@@ -1686,6 +1014,7 @@ var CallManager = class {
1686
1014
  this.transcriptWaiters = /* @__PURE__ */ new Map();
1687
1015
  this.maxDurationTimers = /* @__PURE__ */ new Map();
1688
1016
  this.initialMessageInFlight = /* @__PURE__ */ new Set();
1017
+ this.autoResponseOwners = /* @__PURE__ */ new WeakMap();
1689
1018
  this.config = config;
1690
1019
  this.coreSession = coreSession;
1691
1020
  this.storePath = resolveDefaultStoreBase(config, storePath);
@@ -1857,6 +1186,7 @@ var CallManager = class {
1857
1186
  transcriptWaiters: this.transcriptWaiters,
1858
1187
  maxDurationTimers: this.maxDurationTimers,
1859
1188
  initialMessageInFlight: this.initialMessageInFlight,
1189
+ onCallerSpeech: (call) => this.invalidateAutoResponse(call),
1860
1190
  onCallAnswered: (call) => {
1861
1191
  this.maybeSpeakInitialMessageOnAnswered(call);
1862
1192
  },
@@ -1869,6 +1199,19 @@ var CallManager = class {
1869
1199
  processEvent(event) {
1870
1200
  return processEvent(this.getContext(), event);
1871
1201
  }
1202
+ createAutoResponseGuard(call) {
1203
+ const owner = Symbol("automatic response");
1204
+ this.autoResponseOwners.set(call, owner);
1205
+ return {
1206
+ isCurrent: () => this.activeCalls.get(call.callId) === call && !TerminalStates.has(call.state) && this.autoResponseOwners.get(call) === owner,
1207
+ release: () => {
1208
+ if (this.autoResponseOwners.get(call) === owner) this.autoResponseOwners.delete(call);
1209
+ }
1210
+ };
1211
+ }
1212
+ invalidateAutoResponse(call) {
1213
+ this.autoResponseOwners.delete(call);
1214
+ }
1872
1215
  shouldDeferConversationInitialMessageUntilStreamConnect() {
1873
1216
  if (!this.provider || this.provider.name !== "twilio" || !this.config.streaming.enabled) return false;
1874
1217
  const streamAwareProvider = this.provider;
@@ -1913,8 +1256,7 @@ var CallManager = class {
1913
1256
  async getCallFromMemoryOrStore(callId) {
1914
1257
  const active = this.getCall(callId) ?? this.getCallByProviderCallId(callId);
1915
1258
  if (active) return active;
1916
- const persisted = await findCallMatchesInStore(this.storePath, callId);
1917
- return persisted.byCallId ?? persisted.byProviderCallId;
1259
+ return findCallInStore(this.storePath, callId);
1918
1260
  }
1919
1261
  /**
1920
1262
  * Get call history (from persisted logs).
@@ -1997,6 +1339,13 @@ async function resolveRealtimeFastContextConsult(params) {
1997
1339
  }
1998
1340
  //#endregion
1999
1341
  //#region extensions/voice-call/src/resolve-call-agent-id.ts
1342
+ /** Setup and startup must resolve the same owner before provisioning telephony. */
1343
+ function resolveVoiceCallAgentId(config, coreConfig) {
1344
+ return config.agentId ? normalizeAgentId(config.agentId) : resolveDefaultAgentId(coreConfig, {
1345
+ surface: "Voice Call",
1346
+ hint: "Set plugins.entries.voice-call.config.agentId to a configured agent ID."
1347
+ });
1348
+ }
2000
1349
  /** Keep one agent owner for the full call, including legacy stored records. */
2001
1350
  function resolveCallAgentId(call, config) {
2002
1351
  return normalizeAgentId(call.agentId ?? config.agentId);
@@ -2106,7 +1455,7 @@ function formatBoundedChildOutput(output) {
2106
1455
  }
2107
1456
  //#endregion
2108
1457
  //#region extensions/voice-call/src/webhook/tailscale.ts
2109
- const TAILSCALE_COMMAND_STDOUT_MAX_BYTES = 4 * 1024 * 1024;
1458
+ const TAILSCALE_COMMAND_STDOUT_MAX_BYTES = 4194304;
2110
1459
  const DEFAULT_TAILSCALE_HTTPS_PORT = 443;
2111
1460
  function buildTailscaleExposureArgs(opts) {
2112
1461
  if (!opts.localUrl && opts.port === DEFAULT_TAILSCALE_HTTPS_PORT) return [
@@ -2506,8 +1855,8 @@ const DEFAULT_PRE_START_TIMEOUT_MS = 5e3;
2506
1855
  const DEFAULT_MAX_PENDING_CONNECTIONS = 32;
2507
1856
  const DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP = 4;
2508
1857
  const DEFAULT_MAX_CONNECTIONS = 128;
2509
- const MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
2510
- const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
1858
+ const MAX_INBOUND_MESSAGE_BYTES = 65536;
1859
+ const MAX_WS_BUFFERED_BYTES = 1048576;
2511
1860
  const MAX_PENDING_TTS_OPERATIONS_PER_STREAM = 8;
2512
1861
  const MAX_IGNORED_PLAYBACK_MARKS_PER_STREAM = 64;
2513
1862
  const PLAYBACK_MARK_TIMEOUT_GRACE_MS = 2e3;
@@ -2670,10 +2019,7 @@ var MediaStreamHandler = class {
2670
2019
  session = null;
2671
2020
  }
2672
2021
  break;
2673
- case "mark":
2674
- if (session && message.mark?.name) this.acknowledgePlaybackMark(session.streamSid, message.mark.name);
2675
- break;
2676
- case "clear": break;
2022
+ case "mark": if (session && message.mark?.name) this.acknowledgePlaybackMark(session.streamSid, message.mark.name);
2677
2023
  }
2678
2024
  } catch (error) {
2679
2025
  console.error("[MediaStream] Error processing message:", error);
@@ -2735,7 +2081,7 @@ var MediaStreamHandler = class {
2735
2081
  role: "user"
2736
2082
  }
2737
2083
  });
2738
- this.config.onPartialTranscript?.(callSid, partial);
2084
+ this.config.onPartialTranscript?.(callSid, partial, streamSid);
2739
2085
  },
2740
2086
  onTranscript: (transcript) => {
2741
2087
  const session = this.sessions.get(streamSid);
@@ -2762,12 +2108,12 @@ var MediaStreamHandler = class {
2762
2108
  }
2763
2109
  });
2764
2110
  }
2765
- this.config.onTranscript?.(callSid, transcript);
2111
+ this.config.onTranscript?.(callSid, transcript, streamSid);
2766
2112
  },
2767
2113
  onSpeechStart: () => {
2768
2114
  const session = this.sessions.get(streamSid);
2769
2115
  if (session) this.ensureActiveTurn(session);
2770
- this.config.onSpeechStart?.(callSid);
2116
+ this.config.onSpeechStart?.(callSid, streamSid);
2771
2117
  },
2772
2118
  onError: (error) => {
2773
2119
  console.warn("[MediaStream] Transcription session error:", error.message);
@@ -3341,7 +2687,7 @@ const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
3341
2687
  const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
3342
2688
  const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
3343
2689
  const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
3344
- const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-BCm6S1G6.js"));
2690
+ const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-C0577tL-.js"));
3345
2691
  function appendRecentTalkEventMetadata(call, event) {
3346
2692
  const metadata = call.metadata ?? {};
3347
2693
  const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
@@ -3398,7 +2744,7 @@ function buildTwilioReplayTwiML() {
3398
2744
  body: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response></Response>"
3399
2745
  };
3400
2746
  }
3401
- const WEBHOOK_REPLAY_RESPONSE_TTL_MS = 600 * 1e3;
2747
+ const WEBHOOK_REPLAY_RESPONSE_TTL_MS = 6e5;
3402
2748
  const WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES = 1e4;
3403
2749
  const WEBHOOK_REPLAY_RESPONSE_PRUNE_INTERVAL = 64;
3404
2750
  function cloneWebhookResponsePayload(payload) {
@@ -3495,6 +2841,21 @@ var VoiceCallWebhookServer = class {
3495
2841
  if ((call.metadata?.mode ?? "conversation") !== "conversation") return false;
3496
2842
  return (normalizeOptionalString(call.metadata?.initialMessage) ?? "").length > 0;
3497
2843
  }
2844
+ getCurrentStream(providerCallId, streamSid) {
2845
+ if (this.provider.name !== "twilio") return;
2846
+ const provider = this.provider;
2847
+ const call = this.manager.getCallByProviderCallId(providerCallId);
2848
+ return call && provider.hasRegisteredStream(providerCallId, streamSid) ? {
2849
+ call,
2850
+ provider
2851
+ } : void 0;
2852
+ }
2853
+ interruptStreamReply(providerCallId, streamSid) {
2854
+ const current = this.getCurrentStream(providerCallId, streamSid);
2855
+ if (!current || this.shouldSuppressBargeInForInitialMessage(current.call)) return;
2856
+ this.manager.invalidateAutoResponse(current.call);
2857
+ current.provider.clearTtsQueue(providerCallId);
2858
+ }
3498
2859
  /**
3499
2860
  * Initialize media streaming with the selected realtime transcription provider.
3500
2861
  */
@@ -3508,7 +2869,7 @@ var VoiceCallWebhookServer = class {
3508
2869
  cfg: pluginConfig,
3509
2870
  cfgForResolve: pluginConfig ?? {},
3510
2871
  getConfiguredProvider: (providerId) => getRealtimeTranscriptionProvider(providerId, pluginConfig),
3511
- listProviders: () => listRealtimeTranscriptionProviders(pluginConfig),
2872
+ listProviders: () => listRealtimeTranscriptionProviders(pluginConfig, Object.keys(streaming.providers)),
3512
2873
  resolveProviderConfig: ({ provider, cfg, rawConfig }) => provider.resolveConfig?.({
3513
2874
  cfg,
3514
2875
  rawConfig
@@ -3551,18 +2912,19 @@ var VoiceCallWebhookServer = class {
3551
2912
  }
3552
2913
  return true;
3553
2914
  },
3554
- onTranscript: (providerCallId, transcript) => {
2915
+ onTranscript: (providerCallId, transcript, streamSid) => {
3555
2916
  this.logger.info(`Transcript received ${providerCallId} chars=${transcript.length}`);
3556
- const call = this.manager.getCallByProviderCallId(providerCallId);
3557
- if (!call) {
3558
- this.logger.warn(`No active call found for provider ID: ${providerCallId}`);
2917
+ const current = this.getCurrentStream(providerCallId, streamSid);
2918
+ if (!current) {
2919
+ this.logger.info(`Ignoring transcript from inactive stream ${streamSid} (${providerCallId})`);
3559
2920
  return;
3560
2921
  }
2922
+ const { call, provider: streamProvider } = current;
3561
2923
  if (this.shouldSuppressBargeInForInitialMessage(call)) {
3562
2924
  this.logger.info(`Ignoring barge transcript while initial message is still playing (${providerCallId})`);
3563
2925
  return;
3564
2926
  }
3565
- if (this.provider.name === "twilio") this.provider.clearTtsQueue(providerCallId);
2927
+ streamProvider.clearTtsQueue(providerCallId);
3566
2928
  const event = {
3567
2929
  id: `stream-transcript-${Date.now()}`,
3568
2930
  type: "call.speech",
@@ -3574,32 +2936,36 @@ var VoiceCallWebhookServer = class {
3574
2936
  };
3575
2937
  this.processEventWithAutoResponse(event);
3576
2938
  },
3577
- onSpeechStart: (providerCallId) => {
3578
- if (this.provider.name !== "twilio") return;
3579
- const call = this.manager.getCallByProviderCallId(providerCallId);
3580
- if (this.shouldSuppressBargeInForInitialMessage(call)) return;
3581
- this.provider.clearTtsQueue(providerCallId);
3582
- },
3583
- onPartialTranscript: (callId, partial) => {
2939
+ onSpeechStart: (providerCallId, streamSid) => this.interruptStreamReply(providerCallId, streamSid),
2940
+ onPartialTranscript: (callId, partial, streamSid) => {
3584
2941
  this.logger.info(`Partial transcript ${callId} chars=${partial.length}`);
2942
+ this.interruptStreamReply(callId, streamSid);
3585
2943
  },
3586
- onTalkEvent: (providerCallId, _streamSid, event) => {
3587
- const call = this.manager.getCallByProviderCallId(providerCallId);
3588
- if (call) appendRecentTalkEventMetadata(call, event);
2944
+ onTalkEvent: (providerCallId, streamSid, event) => {
2945
+ const current = this.getCurrentStream(providerCallId, streamSid);
2946
+ if (current) appendRecentTalkEventMetadata(current.call, event);
3589
2947
  },
3590
2948
  onConnect: (callId, streamSid) => {
3591
2949
  this.logger.info(`Media stream connected: ${callId} -> ${streamSid}`);
3592
2950
  this.streamDisconnectGrace.connect(callId, streamSid);
2951
+ const call = this.manager.getCallByProviderCallId(callId);
2952
+ if (call) this.manager.invalidateAutoResponse(call);
3593
2953
  if (this.provider.name === "twilio") this.provider.registerCallStream(callId, streamSid);
3594
2954
  },
3595
- onTranscriptionReady: (callId) => {
2955
+ onTranscriptionReady: (callId, streamSid) => {
2956
+ if (!this.getCurrentStream(callId, streamSid)) return;
3596
2957
  this.manager.speakInitialMessage(callId).catch((err) => {
3597
2958
  this.logger.warn(`Failed to speak initial message: ${String(err)}`);
3598
2959
  });
3599
2960
  },
3600
2961
  onDisconnect: (callId, streamSid) => {
3601
2962
  this.logger.info(`Media stream disconnected: ${callId} (${streamSid})`);
3602
- if (this.provider.name === "twilio") this.provider.unregisterCallStream(callId, streamSid);
2963
+ if (this.provider.name === "twilio") {
2964
+ const twilio = this.provider;
2965
+ twilio.unregisterCallStream(callId, streamSid);
2966
+ const call = this.manager.getCallByProviderCallId(callId);
2967
+ if (call && !twilio.hasRegisteredStream(callId)) this.manager.invalidateAutoResponse(call);
2968
+ }
3603
2969
  this.streamDisconnectGrace.disconnect(callId, streamSid);
3604
2970
  }
3605
2971
  };
@@ -3722,10 +3088,10 @@ var VoiceCallWebhookServer = class {
3722
3088
  * Handle incoming HTTP request.
3723
3089
  */
3724
3090
  async handleRequest(req, res, webhookPath) {
3725
- const payload = await this.runWebhookPipeline(req, webhookPath);
3726
- this.writeWebhookResponse(res, payload);
3091
+ const payload = await this.runWebhookPipeline(req, webhookPath, res);
3092
+ if (payload) this.writeWebhookResponse(res, payload);
3727
3093
  }
3728
- async runWebhookPipeline(req, webhookPath) {
3094
+ async runWebhookPipeline(req, webhookPath, res) {
3729
3095
  const url = buildRequestUrl(req.url);
3730
3096
  if (url.pathname === "/voice/hold-music") return {
3731
3097
  statusCode: 200,
@@ -3767,14 +3133,14 @@ var VoiceCallWebhookServer = class {
3767
3133
  try {
3768
3134
  body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES, WEBHOOK_BODY_TIMEOUT_MS);
3769
3135
  } catch (err) {
3770
- if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) return {
3771
- statusCode: 413,
3772
- body: "Payload Too Large"
3773
- };
3774
- if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) return {
3775
- statusCode: 408,
3776
- body: requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
3777
- };
3136
+ if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
3137
+ await sendHttpRequestRejection(req, res, 413, "Payload Too Large");
3138
+ return null;
3139
+ }
3140
+ if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
3141
+ await sendHttpRequestRejection(req, res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
3142
+ return null;
3143
+ }
3778
3144
  throw err;
3779
3145
  }
3780
3146
  const ctx = {
@@ -3972,10 +3338,11 @@ var VoiceCallWebhookServer = class {
3972
3338
  /**
3973
3339
  * Read request body as string with timeout protection.
3974
3340
  */
3975
- readBody(req, maxBytes, timeoutMs = WEBHOOK_BODY_TIMEOUT_MS) {
3341
+ readBody(req, maxBytes, timeoutMs) {
3976
3342
  return readRequestBodyWithLimit(req, {
3977
3343
  maxBytes,
3978
- timeoutMs
3344
+ timeoutMs,
3345
+ destroyOnLimit: false
3979
3346
  });
3980
3347
  }
3981
3348
  /**
@@ -3997,8 +3364,18 @@ var VoiceCallWebhookServer = class {
3997
3364
  this.logger.warn("Agent runtime missing; skipping auto-response");
3998
3365
  return;
3999
3366
  }
3367
+ const response = this.manager.createAutoResponseGuard(call);
3368
+ const speakResponse = async (text) => {
3369
+ if (!response.isCurrent()) {
3370
+ this.logger.info(`Discarding superseded automatic reply ${callId}`);
3371
+ return false;
3372
+ }
3373
+ this.logger.info(`AI response queued ${callId} chars=${text.length}`);
3374
+ return (await this.manager.speak(callId, text, { listenAfterPlayback: true })).success;
3375
+ };
4000
3376
  try {
4001
3377
  const { generateVoiceResponse } = await loadResponseGeneratorModule();
3378
+ if (!response.isCurrent()) return;
4002
3379
  const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
4003
3380
  const effectiveConfig = resolveVoiceCallEffectiveConfig(this.config, numberRouteKey).config;
4004
3381
  const result = await generateVoiceResponse({
@@ -4012,21 +3389,17 @@ var VoiceCallWebhookServer = class {
4012
3389
  agentId: resolveCallAgentId(call, effectiveConfig),
4013
3390
  transcript: call.transcript,
4014
3391
  userMessage,
4015
- onEarlyText: async (text) => {
4016
- this.logger.info(`Early AI response queued ${callId} chars=${text.length}`);
4017
- return (await this.manager.speak(callId, text, { listenAfterPlayback: true })).success;
4018
- }
3392
+ onEarlyText: speakResponse
4019
3393
  });
4020
3394
  if (result.error) {
4021
3395
  this.logger.error(`Response generation error: ${result.error}`);
4022
3396
  return;
4023
3397
  }
4024
- if (result.text && !result.deliveredEarly) {
4025
- this.logger.info(`AI response delivered ${callId} chars=${result.text.length}`);
4026
- await this.manager.speak(callId, result.text, { listenAfterPlayback: true });
4027
- }
3398
+ if (result.text && !result.deliveredEarly) await speakResponse(result.text);
4028
3399
  } catch (err) {
4029
3400
  this.logger.error(`Auto-response error: ${String(err)}`);
3401
+ } finally {
3402
+ response.release();
4030
3403
  }
4031
3404
  }
4032
3405
  };
@@ -4040,12 +3413,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
4040
3413
  "Do not print secret values or dump environment variables; only check whether required configuration is present.",
4041
3414
  "Be accurate, brief, and speakable."
4042
3415
  ].join(" ");
4043
- const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-DWpKHVeV.js"));
4044
- const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-DUIVX0Eb.js"));
4045
- const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-CNPyQgvw.js"));
4046
- const loadMockProvider = createLazyRuntimeModule(() => import("./mock-Dv7KxYGt.js"));
3416
+ const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-rcjslHOP.js"));
3417
+ const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-B100ALoj.js"));
3418
+ const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-BfKoYCMW.js"));
3419
+ const loadMockProvider = createLazyRuntimeModule(() => import("./mock-C57qj18W.js"));
4047
3420
  const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
4048
- const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-DYTBiNVr.js"));
3421
+ const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-BYWqpZ6N.js"));
4049
3422
  function resolveVoiceCallConsultSessionKey(call) {
4050
3423
  return resolveVoiceCallSessionKey({
4051
3424
  config: call.config,
@@ -4196,10 +3569,9 @@ async function createVoiceCallRuntime(params) {
4196
3569
  };
4197
3570
  const cfg = fullConfig ?? coreConfig;
4198
3571
  const unresolvedConfig = resolveVoiceCallConfig(rawConfig);
4199
- const configuredAgentId = unresolvedConfig.agentId ? normalizeAgentId(unresolvedConfig.agentId) : resolveDefaultAgentId(cfg);
4200
3572
  const config = {
4201
3573
  ...unresolvedConfig,
4202
- agentId: configuredAgentId
3574
+ agentId: resolveVoiceCallAgentId(unresolvedConfig, cfg)
4203
3575
  };
4204
3576
  if (!config.enabled) throw new Error("Voice call disabled. Enable the plugin entry in config.");
4205
3577
  if (config.skipSignatureVerification) log.warn("[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.");
@@ -4388,4 +3760,4 @@ async function createVoiceCallRuntime(params) {
4388
3760
  }
4389
3761
  }
4390
3762
  //#endregion
4391
- export { resolveVoiceCallStreamExposurePaths as C, resolveVoiceCallSessionKey as S, REALTIME_VOICE_END_CALL_TOOL_NAME as T, escapeXml as _, normalizeProviderStatus as a, resolveVoiceCallConfig as b, resolveWebhookExposureStatus as c, setupTailscaleExposureRoutes as d, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as f, resolveUserPath as g, resolveCallAgentId as h, mapProviderStatusToEndReason as i, cleanupTailscaleExposureRoute as l, resolveVoiceResponseModel as m, normalizeProxyIp as n, canonicalizeVoiceCallMediaBase64 as o, chunkAudio as p, isProviderStatusTerminal as r, getHeader as s, createVoiceCallRuntime as t, getTailscaleSelfInfo as u, mapVoiceToPolly as v, validateProviderConfig as w, resolveVoiceCallPublicPathPrefix as x, VoiceCallConfigSchema as y };
3763
+ export { resolveUserPath as _, normalizeProviderStatus as a, resolveWebhookExposureStatus as c, setupTailscaleExposureRoutes as d, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as f, resolveVoiceCallAgentId as g, resolveCallAgentId as h, mapProviderStatusToEndReason as i, cleanupTailscaleExposureRoute as l, resolveVoiceResponseModel as m, normalizeProxyIp as n, canonicalizeVoiceCallMediaBase64 as o, chunkAudio as p, isProviderStatusTerminal as r, getHeader as s, createVoiceCallRuntime as t, getTailscaleSelfInfo as u, escapeXml as v, mapVoiceToPolly as y };