@openclaw/voice-call 2026.7.1 → 2026.7.2-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 (31) hide show
  1. package/README.md +1 -2
  2. package/dist/doctor-contract-api.js +27 -10
  3. package/dist/{guarded-json-api-CLVDU_h1.js → guarded-json-api-DoLAoIRF.js} +32 -41
  4. package/dist/index.js +9 -15
  5. package/dist/{plivo-D5wI4-pi.js → plivo-BICQA_yK.js} +2 -2
  6. package/dist/{realtime-handler-Bz-MNWtD.js → realtime-handler-DJk-eYyf.js} +31 -20
  7. package/dist/{response-generator-D3YO-WtN.js → response-generator-hmAdGp5h.js} +20 -4
  8. package/dist/{runtime-entry-DwMgbq2p.js → runtime-entry-DhTLZ2uB.js} +811 -263
  9. package/dist/runtime-entry.js +1 -1
  10. package/dist/setup-api.js +86 -2
  11. package/dist/{store-DtewuuOb.js → store-C1pl3avP.js} +2 -2
  12. package/dist/{telnyx-DksS6q0n.js → telnyx-B9tF5jeJ.js} +1 -1
  13. package/dist/{twilio-BYcsdUkj.js → twilio-U_myvowz.js} +7 -10
  14. package/dist/twilio-region-4fkgz3UG.js +25 -0
  15. package/npm-shrinkwrap.json +3 -13
  16. package/openclaw.plugin.json +1 -0
  17. package/package.json +4 -6
  18. package/dist/config-CDSeuGp3.js +0 -674
  19. package/dist/config-compat-D8fcjOHp.js +0 -156
  20. package/node_modules/commander/LICENSE +0 -22
  21. package/node_modules/commander/Readme.md +0 -1172
  22. package/node_modules/commander/index.js +0 -21
  23. package/node_modules/commander/lib/argument.js +0 -147
  24. package/node_modules/commander/lib/command.js +0 -2790
  25. package/node_modules/commander/lib/error.js +0 -36
  26. package/node_modules/commander/lib/help.js +0 -731
  27. package/node_modules/commander/lib/option.js +0 -377
  28. package/node_modules/commander/lib/suggestSimilar.js +0 -99
  29. package/node_modules/commander/package-support.json +0 -19
  30. package/node_modules/commander/package.json +0 -64
  31. package/node_modules/commander/typings/index.d.ts +0 -1113
@@ -1,27 +1,657 @@
1
- import { isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "./runtime-api.js";
1
+ import { TtsConfigSchema, isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { a as resolveVoiceCallEffectiveConfig, c as validateProviderConfig, d as deepMergeDefined, i as resolveVoiceCallConfig, n as normalizeVoiceCallConfig, o as resolveVoiceCallNumberRouteKeyForCall, r as resolveTwilioAuthToken, s as resolveVoiceCallSessionKey } from "./config-CDSeuGp3.js";
4
- import { c as findCallMatchesInStore, f as persistCallRecord, g as TerminalStates, h as setVoiceCallStateRuntime, l as getCallHistoryFromStore, u as loadActiveCallsFromStore } from "./store-DtewuuOb.js";
3
+ import { t as TWILIO_REGIONS } from "./twilio-region-4fkgz3UG.js";
4
+ import { c as findCallMatchesInStore, f as persistCallRecord, g as TerminalStates, h as setVoiceCallStateRuntime, l as getCallHistoryFromStore, u as loadActiveCallsFromStore } from "./store-C1pl3avP.js";
5
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
6
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
7
7
  import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
8
- import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
8
+ import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
9
9
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
10
+ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
10
11
  import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
11
- import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultPolicyInstructions, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createTalkSessionController, recordTalkObservabilityEvent, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceFastContextConsult } from "openclaw/plugin-sdk/realtime-voice";
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";
12
16
  import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
17
+ import { z } from "zod";
13
18
  import fs from "node:fs";
14
19
  import os from "node:os";
15
20
  import path from "node:path";
16
21
  import crypto from "node:crypto";
22
+ import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
17
23
  import { root } from "openclaw/plugin-sdk/security-runtime";
18
- import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
24
+ import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
19
25
  import { parseTtsDirectives } from "openclaw/plugin-sdk/speech";
20
26
  import { spawn } from "node:child_process";
27
+ import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
21
28
  import http from "node:http";
22
29
  import { URL as URL$1 } from "node:url";
23
30
  import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
24
31
  import { WebSocket, WebSocketServer } from "ws";
32
+ //#region extensions/voice-call/src/realtime-defaults.ts
33
+ /** Baseline instructions that keep realtime calls brief and route deep work to agent consult. */
34
+ 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.`;
35
+ //#endregion
36
+ //#region extensions/voice-call/src/config.ts
37
+ /**
38
+ * E.164 phone number format: +[country code][number]
39
+ * Examples use 555 prefix (reserved for fictional numbers)
40
+ */
41
+ const E164Schema = z.string().regex(/^\+[1-9]\d{1,14}$/, "Expected E.164 format, e.g. +15550001234");
42
+ /**
43
+ * Controls how inbound calls are handled:
44
+ * - "disabled": Block all inbound calls (outbound only)
45
+ * - "allowlist": Only accept calls from numbers in allowFrom
46
+ * - "pairing": Unknown callers can request pairing (future)
47
+ * - "open": Accept all inbound calls (dangerous!)
48
+ */
49
+ const InboundPolicySchema = z.enum([
50
+ "disabled",
51
+ "allowlist",
52
+ "pairing",
53
+ "open"
54
+ ]);
55
+ const SecretInputSchema = buildSecretInputSchema();
56
+ const TelnyxConfigSchema = z.object({
57
+ /** Telnyx API v2 key */
58
+ apiKey: z.string().min(1).optional(),
59
+ /** Telnyx connection ID (from Call Control app) */
60
+ connectionId: z.string().min(1).optional(),
61
+ /** Public key for webhook signature verification */
62
+ publicKey: z.string().min(1).optional()
63
+ }).strict();
64
+ const TwilioConfigSchema = z.object({
65
+ /** Twilio Account SID */
66
+ accountSid: z.string().min(1).optional(),
67
+ /** Twilio Auth Token */
68
+ authToken: SecretInputSchema.optional(),
69
+ /** Twilio processing Region (for example, ie1) */
70
+ region: z.enum(TWILIO_REGIONS).optional()
71
+ }).strict();
72
+ const PlivoConfigSchema = z.object({
73
+ /** Plivo Auth ID (starts with MA/SA) */
74
+ authId: z.string().min(1).optional(),
75
+ /** Plivo Auth Token */
76
+ authToken: z.string().min(1).optional()
77
+ }).strict();
78
+ const VoiceCallNumberRouteConfigSchema = z.object({
79
+ /** Greeting message for inbound calls to this number. */
80
+ inboundGreeting: z.string().optional(),
81
+ /** TTS override for inbound calls to this number. Deep-merges with global voice-call TTS. */
82
+ tts: TtsConfigSchema,
83
+ /** Agent ID to use for voice response generation for this number. */
84
+ agentId: z.string().min(1).optional(),
85
+ /** Optional model override for voice responses for this number. */
86
+ responseModel: z.string().optional(),
87
+ /** System prompt for voice responses for this number. */
88
+ responseSystemPrompt: z.string().optional(),
89
+ /** Timeout for response generation in ms for this number. */
90
+ responseTimeoutMs: z.number().int().positive().optional()
91
+ }).strict();
92
+ const VoiceCallServeConfigSchema = z.object({
93
+ /** Port to listen on */
94
+ port: z.number().int().positive().default(3334),
95
+ /** Bind address */
96
+ bind: z.string().default("127.0.0.1"),
97
+ /** Webhook path */
98
+ path: z.string().min(1).default("/voice/webhook")
99
+ }).strict().default({
100
+ port: 3334,
101
+ bind: "127.0.0.1",
102
+ path: "/voice/webhook"
103
+ });
104
+ const VoiceCallTailscaleConfigSchema = z.object({
105
+ /**
106
+ * Tailscale exposure mode:
107
+ * - "off": No Tailscale exposure
108
+ * - "serve": Tailscale serve (private to tailnet)
109
+ * - "funnel": Tailscale funnel (public HTTPS)
110
+ */
111
+ mode: z.enum([
112
+ "off",
113
+ "serve",
114
+ "funnel"
115
+ ]).default("off"),
116
+ /** Path for Tailscale serve/funnel (should usually match serve.path) */
117
+ path: z.string().min(1).default("/voice/webhook")
118
+ }).strict().default({
119
+ mode: "off",
120
+ path: "/voice/webhook"
121
+ });
122
+ const VoiceCallTunnelConfigSchema = z.object({
123
+ /**
124
+ * Tunnel provider:
125
+ * - "none": No tunnel (use publicUrl if set, or manual setup)
126
+ * - "ngrok": Use ngrok for public HTTPS tunnel
127
+ * - "tailscale-serve": Tailscale serve (private to tailnet)
128
+ * - "tailscale-funnel": Tailscale funnel (public HTTPS)
129
+ */
130
+ provider: z.enum([
131
+ "none",
132
+ "ngrok",
133
+ "tailscale-serve",
134
+ "tailscale-funnel"
135
+ ]).default("none"),
136
+ /** ngrok auth token (optional, enables longer sessions and more features) */
137
+ ngrokAuthToken: z.string().min(1).optional(),
138
+ /** ngrok custom domain (paid feature, e.g., "myapp.ngrok.io") */
139
+ ngrokDomain: z.string().min(1).optional(),
140
+ /**
141
+ * Allow ngrok free tier compatibility mode.
142
+ * When true, forwarded headers may be trusted for loopback requests
143
+ * to reconstruct the public ngrok URL used for signing.
144
+ *
145
+ * IMPORTANT: This does NOT bypass signature verification.
146
+ */
147
+ allowNgrokFreeTierLoopbackBypass: z.boolean().default(false)
148
+ }).strict().default({
149
+ provider: "none",
150
+ allowNgrokFreeTierLoopbackBypass: false
151
+ });
152
+ const VoiceCallWebhookSecurityConfigSchema = z.object({
153
+ /**
154
+ * Allowed hostnames for webhook URL reconstruction.
155
+ * Only these hosts are accepted from forwarding headers.
156
+ */
157
+ allowedHosts: z.array(z.string().min(1)).default([]),
158
+ /**
159
+ * Trust X-Forwarded-* headers without a hostname allowlist.
160
+ * WARNING: Only enable if you trust your proxy configuration.
161
+ */
162
+ trustForwardingHeaders: z.boolean().default(false),
163
+ /**
164
+ * Trusted proxy IP addresses. Forwarded headers are only trusted when
165
+ * the remote IP matches one of these addresses.
166
+ */
167
+ trustedProxyIPs: z.array(z.string().min(1)).default([])
168
+ }).strict().default({
169
+ allowedHosts: [],
170
+ trustForwardingHeaders: false,
171
+ trustedProxyIPs: []
172
+ });
173
+ /**
174
+ * Call mode determines how outbound calls behave:
175
+ * - "notify": Deliver message and auto-hangup after delay (one-way notification)
176
+ * - "conversation": Stay open for back-and-forth until explicit end or timeout
177
+ */
178
+ const CallModeSchema = z.enum(["notify", "conversation"]);
179
+ const VoiceCallSessionScopeSchema = z.enum(["per-phone", "per-call"]);
180
+ const OutboundConfigSchema = z.object({
181
+ /** Default call mode for outbound calls */
182
+ defaultMode: CallModeSchema.default("notify"),
183
+ /** Seconds to wait after TTS before auto-hangup in notify mode */
184
+ notifyHangupDelaySec: z.number().int().nonnegative().default(3)
185
+ }).strict().default({
186
+ defaultMode: "notify",
187
+ notifyHangupDelaySec: 3
188
+ });
189
+ const RealtimeToolSchema = z.object({
190
+ type: z.literal("function"),
191
+ name: z.string().min(1),
192
+ description: z.string(),
193
+ parameters: z.object({
194
+ type: z.literal("object"),
195
+ properties: z.record(z.string(), z.unknown()),
196
+ required: z.array(z.string()).optional()
197
+ })
198
+ }).strict();
199
+ const VoiceCallRealtimeProvidersConfigSchema = z.record(z.string(), z.record(z.string(), z.unknown())).default({});
200
+ const VoiceCallRealtimeToolPolicySchema = z.enum(REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES);
201
+ const VoiceCallRealtimeConsultPolicySchema = z.enum([
202
+ "auto",
203
+ "substantive",
204
+ "always"
205
+ ]);
206
+ const VoiceCallRealtimeFastContextSourceSchema = z.enum(["memory", "sessions"]);
207
+ const VoiceCallRealtimeFastContextConfigSchema = z.object({
208
+ /** Enable bounded memory/session lookup before the full consult agent. */
209
+ enabled: z.boolean().default(false),
210
+ /** Hard deadline for the fast context lookup. */
211
+ timeoutMs: z.number().int().positive().default(800),
212
+ /** Maximum memory/session hits to inject into the realtime tool result. */
213
+ maxResults: z.number().int().positive().default(3),
214
+ /** Indexed sources used by the fast context lookup. */
215
+ sources: z.array(VoiceCallRealtimeFastContextSourceSchema).min(1).default(["memory", "sessions"]),
216
+ /** Fall back to the full agent consult when fast context has no answer. */
217
+ fallbackToConsult: z.boolean().default(false)
218
+ }).strict().default({
219
+ enabled: false,
220
+ timeoutMs: 800,
221
+ maxResults: 3,
222
+ sources: ["memory", "sessions"],
223
+ fallbackToConsult: false
224
+ });
225
+ const VoiceCallRealtimeAgentContextConfigSchema = z.object({
226
+ /** Inject a compact agent persona/context capsule into realtime voice instructions. */
227
+ enabled: z.boolean().default(false),
228
+ /** Maximum number of characters from the generated capsule to append. */
229
+ maxChars: z.number().int().positive().default(6e3),
230
+ /** Include configured agent identity fields. */
231
+ includeIdentity: z.boolean().default(true),
232
+ /** Include selected workspace files such as SOUL.md and IDENTITY.md. */
233
+ includeWorkspaceFiles: z.boolean().default(true),
234
+ /** Workspace-relative files to include, bounded by maxChars. */
235
+ files: z.array(z.string().min(1)).default([
236
+ "SOUL.md",
237
+ "IDENTITY.md",
238
+ "USER.md"
239
+ ])
240
+ }).strict().default({
241
+ enabled: false,
242
+ maxChars: 6e3,
243
+ includeIdentity: true,
244
+ includeWorkspaceFiles: true,
245
+ files: [
246
+ "SOUL.md",
247
+ "IDENTITY.md",
248
+ "USER.md"
249
+ ]
250
+ });
251
+ const VoiceCallRealtimeConsultThinkingLevelSchema = z.enum([
252
+ "off",
253
+ "minimal",
254
+ "low",
255
+ "medium",
256
+ "high",
257
+ "xhigh",
258
+ "adaptive",
259
+ "max",
260
+ "ultra"
261
+ ]);
262
+ const VoiceCallStreamingProvidersConfigSchema = z.record(z.string(), z.record(z.string(), z.unknown())).default({});
263
+ const VoiceCallRealtimeConfigSchema = z.object({
264
+ /** Enable realtime voice-to-voice mode. */
265
+ enabled: z.boolean().default(false),
266
+ /** Provider id from registered realtime voice providers. */
267
+ provider: z.string().min(1).optional(),
268
+ /** Optional override for the local WebSocket route path. */
269
+ streamPath: z.string().min(1).optional(),
270
+ /** System instructions passed to the realtime provider. */
271
+ instructions: z.string().default(DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS),
272
+ /** Tool policy for the shared OpenClaw agent consult tool. */
273
+ toolPolicy: VoiceCallRealtimeToolPolicySchema.default("safe-read-only"),
274
+ /** Guidance for when the realtime model should call the OpenClaw agent consult tool. */
275
+ consultPolicy: VoiceCallRealtimeConsultPolicySchema.default("auto"),
276
+ /** Optional thinking level override for the regular agent behind realtime consults. */
277
+ consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional(),
278
+ /** Optional fast mode override for the regular agent behind realtime consults. */
279
+ consultFastMode: z.boolean().optional(),
280
+ /** Tool definitions exposed to the realtime provider. */
281
+ tools: z.array(RealtimeToolSchema).default([]),
282
+ /** Low-latency memory/session context for the consult tool. */
283
+ fastContext: VoiceCallRealtimeFastContextConfigSchema,
284
+ /** Bounded agent persona/context injection for the fast realtime voice path. */
285
+ agentContext: VoiceCallRealtimeAgentContextConfigSchema,
286
+ /** Provider-owned raw config blobs keyed by provider id. */
287
+ providers: VoiceCallRealtimeProvidersConfigSchema
288
+ }).strict().default({
289
+ enabled: false,
290
+ instructions: DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS,
291
+ toolPolicy: "safe-read-only",
292
+ consultPolicy: "auto",
293
+ tools: [],
294
+ fastContext: {
295
+ enabled: false,
296
+ timeoutMs: 800,
297
+ maxResults: 3,
298
+ sources: ["memory", "sessions"],
299
+ fallbackToConsult: false
300
+ },
301
+ agentContext: {
302
+ enabled: false,
303
+ maxChars: 6e3,
304
+ includeIdentity: true,
305
+ includeWorkspaceFiles: true,
306
+ files: [
307
+ "SOUL.md",
308
+ "IDENTITY.md",
309
+ "USER.md"
310
+ ]
311
+ },
312
+ providers: {}
313
+ });
314
+ const VoiceCallStreamingConfigSchema = z.object({
315
+ /** Enable Twilio Media Streams for real-time transcription. */
316
+ enabled: z.boolean().default(false),
317
+ /** Provider id from registered realtime transcription providers. */
318
+ provider: z.string().min(1).optional(),
319
+ /** WebSocket path for media stream connections */
320
+ streamPath: z.string().min(1).default("/voice/stream"),
321
+ /** Provider-owned raw config blobs keyed by provider id. */
322
+ providers: VoiceCallStreamingProvidersConfigSchema,
323
+ /**
324
+ * Close unauthenticated media stream sockets if no valid `start` frame arrives in time.
325
+ * Protects against pre-auth idle connection hold attacks.
326
+ */
327
+ preStartTimeoutMs: z.number().int().positive().default(5e3),
328
+ /** Maximum number of concurrently pending (pre-start) media stream sockets. */
329
+ maxPendingConnections: z.number().int().positive().default(32),
330
+ /** Maximum pending media stream sockets per source IP. */
331
+ maxPendingConnectionsPerIp: z.number().int().positive().default(4),
332
+ /** Hard cap for all open media stream sockets (pending + active). */
333
+ maxConnections: z.number().int().positive().default(128)
334
+ }).strict().default({
335
+ enabled: false,
336
+ streamPath: "/voice/stream",
337
+ providers: {},
338
+ preStartTimeoutMs: 5e3,
339
+ maxPendingConnections: 32,
340
+ maxPendingConnectionsPerIp: 4,
341
+ maxConnections: 128
342
+ });
343
+ const VoiceCallConfigSchema = z.object({
344
+ /** Enable voice call functionality */
345
+ enabled: z.boolean().default(false),
346
+ /** Active provider (telnyx, twilio, plivo, or mock) */
347
+ provider: z.enum([
348
+ "telnyx",
349
+ "twilio",
350
+ "plivo",
351
+ "mock"
352
+ ]).optional(),
353
+ /** Telnyx-specific configuration */
354
+ telnyx: TelnyxConfigSchema.optional(),
355
+ /** Twilio-specific configuration */
356
+ twilio: TwilioConfigSchema.optional(),
357
+ /** Plivo-specific configuration */
358
+ plivo: PlivoConfigSchema.optional(),
359
+ /** Phone number to call from (E.164) */
360
+ fromNumber: E164Schema.optional(),
361
+ /** Default phone number to call (E.164) */
362
+ toNumber: E164Schema.optional(),
363
+ /** Inbound call policy */
364
+ inboundPolicy: InboundPolicySchema.default("disabled"),
365
+ /** Allowlist of phone numbers for inbound calls (E.164) */
366
+ allowFrom: z.array(E164Schema).default([]),
367
+ /** Greeting message for inbound calls */
368
+ inboundGreeting: z.string().optional(),
369
+ /** Per-dialed-number overrides for inbound calls. Keys are E.164 numbers. */
370
+ numbers: z.record(E164Schema, VoiceCallNumberRouteConfigSchema).default({}),
371
+ /** Outbound call configuration */
372
+ outbound: OutboundConfigSchema,
373
+ /** Maximum call duration in seconds */
374
+ maxDurationSeconds: z.number().int().positive().default(300),
375
+ /**
376
+ * Maximum age of a call in seconds before it is automatically reaped.
377
+ * Catches calls stuck before answer (for example, local mock calls that
378
+ * never receive provider webhooks). Set to 0 to disable.
379
+ */
380
+ staleCallReaperSeconds: z.number().int().nonnegative().default(120),
381
+ /** Silence timeout for end-of-speech detection (ms) */
382
+ silenceTimeoutMs: z.number().int().positive().default(800),
383
+ /** Timeout for user transcript (ms) */
384
+ transcriptTimeoutMs: z.number().int().positive().default(18e4),
385
+ /** Ring timeout for outbound calls (ms) */
386
+ ringTimeoutMs: z.number().int().positive().default(3e4),
387
+ /** Maximum concurrent calls */
388
+ maxConcurrentCalls: z.number().int().positive().default(1),
389
+ /** Webhook server configuration */
390
+ serve: VoiceCallServeConfigSchema,
391
+ /** @deprecated Prefer tunnel config. */
392
+ tailscale: VoiceCallTailscaleConfigSchema,
393
+ /** Tunnel configuration (unified ngrok/tailscale) */
394
+ tunnel: VoiceCallTunnelConfigSchema,
395
+ /** Webhook signature reconstruction and proxy trust configuration */
396
+ webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
397
+ /** Real-time audio streaming configuration */
398
+ streaming: VoiceCallStreamingConfigSchema,
399
+ /** Realtime voice-to-voice configuration */
400
+ realtime: VoiceCallRealtimeConfigSchema,
401
+ /** Session memory scope for voice conversations. */
402
+ sessionScope: VoiceCallSessionScopeSchema.default("per-phone"),
403
+ /** Public webhook URL override (if set, bypasses tunnel auto-detection) */
404
+ publicUrl: z.string().url().optional(),
405
+ /** Skip webhook signature verification (development only, NOT for production) */
406
+ skipSignatureVerification: z.boolean().default(false),
407
+ /** TTS override (deep-merges with core messages.tts) */
408
+ tts: TtsConfigSchema,
409
+ /** Store path for call logs */
410
+ store: z.string().optional(),
411
+ /** Agent ID to use for voice response generation. Defaults to "main". */
412
+ agentId: z.string().min(1).optional(),
413
+ /** Optional model override for generating voice responses. */
414
+ responseModel: z.string().optional(),
415
+ /** System prompt for voice responses */
416
+ responseSystemPrompt: z.string().optional(),
417
+ /** Timeout for response generation in ms (default 30s) */
418
+ responseTimeoutMs: z.number().int().positive().default(3e4)
419
+ }).strict();
420
+ const TWILIO_AUTH_TOKEN_PATH = "plugins.entries.voice-call.config.twilio.authToken";
421
+ const DEFAULT_VOICE_CALL_CONFIG = VoiceCallConfigSchema.parse({});
422
+ function cloneDefaultVoiceCallConfig() {
423
+ return structuredClone(DEFAULT_VOICE_CALL_CONFIG);
424
+ }
425
+ function defaultRealtimeStreamPathForServePath(servePath) {
426
+ const normalized = normalizeWebhookPath(servePath);
427
+ if (normalized.endsWith("/webhook")) return `${normalized.slice(0, -8)}/stream/realtime`;
428
+ if (normalized === "/") return "/voice/stream/realtime";
429
+ return `${normalized}/stream/realtime`;
430
+ }
431
+ function normalizeVoiceCallTtsConfig(defaults, overrides) {
432
+ if (!defaults && !overrides) return;
433
+ return TtsConfigSchema.parse(mergeDeep(defaults ?? {}, overrides ?? {}));
434
+ }
435
+ function normalizePhoneRouteKey(phone) {
436
+ return phone?.replace(/\D/g, "") ?? "";
437
+ }
438
+ function resolveVoiceCallNumberRouteKey(config, phone) {
439
+ const routes = config.numbers;
440
+ if (!routes) return;
441
+ if (phone && Object.hasOwn(routes, phone)) return phone;
442
+ const normalizedPhone = normalizePhoneRouteKey(phone);
443
+ if (!normalizedPhone) return;
444
+ return Object.keys(routes).find((routeKey) => normalizePhoneRouteKey(routeKey) === normalizedPhone);
445
+ }
446
+ /** Resolve inbound-only number routing from a persisted call record. */
447
+ function resolveVoiceCallNumberRouteKeyForCall(call) {
448
+ if (call.direction !== "inbound") return;
449
+ const storedRouteKey = call.metadata?.numberRouteKey;
450
+ if (typeof storedRouteKey === "string") return storedRouteKey;
451
+ return call.to;
452
+ }
453
+ function resolveVoiceCallEffectiveConfig(config, phoneOrRouteKey) {
454
+ const numberRouteKey = resolveVoiceCallNumberRouteKey(config, phoneOrRouteKey);
455
+ if (!numberRouteKey) return { config };
456
+ const route = config.numbers[numberRouteKey];
457
+ if (!route) return { config };
458
+ return {
459
+ numberRouteKey,
460
+ config: {
461
+ ...config,
462
+ ...route,
463
+ tts: normalizeVoiceCallTtsConfig(config.tts, route.tts),
464
+ numbers: config.numbers
465
+ }
466
+ };
467
+ }
468
+ function sanitizeVoiceCallProviderConfigs(value) {
469
+ if (!value) return {};
470
+ return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== void 0));
471
+ }
472
+ function sanitizeVoiceCallNumberRoutes(value) {
473
+ if (!value) return {};
474
+ return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== void 0).map(([key, route]) => [key, VoiceCallNumberRouteConfigSchema.parse(route)]));
475
+ }
476
+ function resolveTwilioAuthToken(config) {
477
+ return normalizeResolvedSecretInputString({
478
+ value: config.twilio?.authToken,
479
+ path: TWILIO_AUTH_TOKEN_PATH
480
+ });
481
+ }
482
+ function normalizeVoiceCallConfig(config) {
483
+ const defaults = cloneDefaultVoiceCallConfig();
484
+ const serve = {
485
+ ...defaults.serve,
486
+ ...config.serve
487
+ };
488
+ const streamingProvider = config.streaming?.provider;
489
+ const streamingProviders = sanitizeVoiceCallProviderConfigs(config.streaming?.providers ?? defaults.streaming.providers);
490
+ const realtimeProvider = config.realtime?.provider ?? defaults.realtime.provider;
491
+ const realtimeProviders = sanitizeVoiceCallProviderConfigs(config.realtime?.providers ?? defaults.realtime.providers);
492
+ const realtimeFastContext = {
493
+ ...defaults.realtime.fastContext,
494
+ ...config.realtime?.fastContext,
495
+ sources: config.realtime?.fastContext?.sources ?? defaults.realtime.fastContext.sources
496
+ };
497
+ const realtimeAgentContext = {
498
+ ...defaults.realtime.agentContext,
499
+ ...config.realtime?.agentContext,
500
+ files: config.realtime?.agentContext?.files ?? defaults.realtime.agentContext.files
501
+ };
502
+ return {
503
+ ...defaults,
504
+ ...config,
505
+ allowFrom: config.allowFrom ?? defaults.allowFrom,
506
+ numbers: sanitizeVoiceCallNumberRoutes(config.numbers ?? defaults.numbers),
507
+ outbound: {
508
+ ...defaults.outbound,
509
+ ...config.outbound
510
+ },
511
+ serve,
512
+ tailscale: {
513
+ ...defaults.tailscale,
514
+ ...config.tailscale
515
+ },
516
+ tunnel: {
517
+ ...defaults.tunnel,
518
+ ...config.tunnel
519
+ },
520
+ webhookSecurity: {
521
+ ...defaults.webhookSecurity,
522
+ ...config.webhookSecurity,
523
+ allowedHosts: config.webhookSecurity?.allowedHosts ?? defaults.webhookSecurity.allowedHosts,
524
+ trustedProxyIPs: config.webhookSecurity?.trustedProxyIPs ?? defaults.webhookSecurity.trustedProxyIPs
525
+ },
526
+ streaming: {
527
+ ...defaults.streaming,
528
+ ...config.streaming,
529
+ provider: streamingProvider,
530
+ providers: streamingProviders
531
+ },
532
+ realtime: {
533
+ ...defaults.realtime,
534
+ ...config.realtime,
535
+ provider: realtimeProvider,
536
+ streamPath: config.realtime?.streamPath ?? defaultRealtimeStreamPathForServePath(serve.path ?? defaults.serve.path),
537
+ tools: config.realtime?.tools ?? defaults.realtime.tools,
538
+ consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional().parse(config.realtime?.consultThinkingLevel ?? defaults.realtime.consultThinkingLevel),
539
+ consultFastMode: config.realtime?.consultFastMode ?? defaults.realtime.consultFastMode,
540
+ fastContext: realtimeFastContext,
541
+ agentContext: realtimeAgentContext,
542
+ providers: realtimeProviders
543
+ },
544
+ tts: normalizeVoiceCallTtsConfig(defaults.tts, config.tts)
545
+ };
546
+ }
547
+ function resolveVoiceCallSessionKey(params) {
548
+ const explicit = params.explicitSessionKey?.trim();
549
+ if (explicit) return resolveVoiceCallAgentSessionKey({
550
+ config: params.config,
551
+ sessionKey: explicit,
552
+ coreSession: params.coreSession
553
+ });
554
+ const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
555
+ if (params.config.sessionScope === "per-call") return `${prefix}:call:${params.callId}`.toLowerCase();
556
+ const normalizedPhone = params.phone?.replace(/\D/g, "");
557
+ return (normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`).toLowerCase();
558
+ }
559
+ /** Resolve persisted or integration-provided keys into the configured agent namespace. */
560
+ function resolveVoiceCallAgentSessionKey(params) {
561
+ const sessionKey = params.sessionKey.trim();
562
+ if (!sessionKey) throw new Error("Voice Call session key cannot be empty");
563
+ const lower = sessionKey.toLowerCase();
564
+ const agentId = normalizeAgentId(params.config.agentId);
565
+ if (lower === "global" || lower === "unknown") return lower;
566
+ const parsedInput = parseAgentSessionKey(sessionKey);
567
+ let normalizedScopedKey;
568
+ if (parsedInput && normalizeAgentId(parsedInput.agentId) === parsedInput.agentId && parsedInput.agentId === agentId) normalizedScopedKey = `agent:${parsedInput.agentId}:${parsedInput.rest}`;
569
+ else {
570
+ const wrappedInput = parseAgentSessionKey(`agent:${agentId}:${sessionKey}`);
571
+ if (!wrappedInput) throw new Error("Voice Call session key could not be normalized");
572
+ normalizedScopedKey = `agent:${agentId}:${wrappedInput.rest}`;
573
+ }
574
+ const canonicalMain = canonicalizeMainSessionAlias({
575
+ cfg: { session: params.coreSession },
576
+ agentId,
577
+ sessionKey: normalizedScopedKey
578
+ });
579
+ return canonicalMain === normalizedScopedKey ? normalizedScopedKey : canonicalMain;
580
+ }
581
+ /**
582
+ * Resolves the configuration by merging environment variables into missing fields.
583
+ * Returns a new configuration object with environment variables applied.
584
+ */
585
+ function resolveVoiceCallConfig(config) {
586
+ const resolved = normalizeVoiceCallConfig(config);
587
+ if (resolved.provider === "telnyx") {
588
+ resolved.telnyx = resolved.telnyx ?? {};
589
+ resolved.telnyx.apiKey = resolved.telnyx.apiKey ?? process.env.TELNYX_API_KEY;
590
+ resolved.telnyx.connectionId = resolved.telnyx.connectionId ?? process.env.TELNYX_CONNECTION_ID;
591
+ resolved.telnyx.publicKey = resolved.telnyx.publicKey ?? process.env.TELNYX_PUBLIC_KEY;
592
+ }
593
+ if (resolved.provider === "twilio") {
594
+ resolved.fromNumber = resolved.fromNumber ?? process.env.TWILIO_FROM_NUMBER;
595
+ resolved.twilio = resolved.twilio ?? {};
596
+ resolved.twilio.accountSid = resolved.twilio.accountSid ?? process.env.TWILIO_ACCOUNT_SID;
597
+ resolved.twilio.authToken = resolved.twilio.authToken ?? process.env.TWILIO_AUTH_TOKEN;
598
+ }
599
+ if (resolved.provider === "plivo") {
600
+ resolved.plivo = resolved.plivo ?? {};
601
+ resolved.plivo.authId = resolved.plivo.authId ?? process.env.PLIVO_AUTH_ID;
602
+ resolved.plivo.authToken = resolved.plivo.authToken ?? process.env.PLIVO_AUTH_TOKEN;
603
+ }
604
+ resolved.tunnel = resolved.tunnel ?? {
605
+ provider: "none",
606
+ allowNgrokFreeTierLoopbackBypass: false
607
+ };
608
+ resolved.tunnel.allowNgrokFreeTierLoopbackBypass = resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
609
+ resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
610
+ resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
611
+ resolved.webhookSecurity = resolved.webhookSecurity ?? {
612
+ allowedHosts: [],
613
+ trustForwardingHeaders: false,
614
+ trustedProxyIPs: []
615
+ };
616
+ resolved.webhookSecurity.allowedHosts = resolved.webhookSecurity.allowedHosts ?? [];
617
+ resolved.webhookSecurity.trustForwardingHeaders = resolved.webhookSecurity.trustForwardingHeaders ?? false;
618
+ resolved.webhookSecurity.trustedProxyIPs = resolved.webhookSecurity.trustedProxyIPs ?? [];
619
+ return normalizeVoiceCallConfig(resolved);
620
+ }
621
+ /**
622
+ * Validate that the configuration has all required fields for the selected provider.
623
+ */
624
+ function validateProviderConfig(config) {
625
+ const errors = [];
626
+ if (!config.enabled) return {
627
+ valid: true,
628
+ errors: []
629
+ };
630
+ if (!config.provider) errors.push("plugins.entries.voice-call.config.provider is required");
631
+ 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");
632
+ if (config.provider === "telnyx") {
633
+ if (!config.telnyx?.apiKey) errors.push("plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)");
634
+ if (!config.telnyx?.connectionId) errors.push("plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)");
635
+ if (!config.skipSignatureVerification && !config.telnyx?.publicKey) errors.push("plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)");
636
+ }
637
+ if (config.provider === "twilio") {
638
+ if (!config.twilio?.accountSid) errors.push("plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)");
639
+ if (!hasConfiguredSecretInput(config.twilio?.authToken)) errors.push("plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)");
640
+ }
641
+ if (config.provider === "plivo") {
642
+ if (!config.plivo?.authId) errors.push("plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)");
643
+ if (!config.plivo?.authToken) errors.push("plugins.entries.voice-call.config.plivo.authToken is required (or set PLIVO_AUTH_TOKEN env)");
644
+ }
645
+ if (config.realtime.enabled && config.inboundPolicy === "disabled") errors.push("plugins.entries.voice-call.config.inboundPolicy must not be \"disabled\" when realtime.enabled is true");
646
+ 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");
647
+ 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");
648
+ 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");
649
+ return {
650
+ valid: errors.length === 0,
651
+ errors
652
+ };
653
+ }
654
+ //#endregion
25
655
  //#region extensions/voice-call/src/allowlist.ts
26
656
  /** Normalize a phone number to digits only. */
27
657
  function normalizePhoneNumber(input) {
@@ -244,7 +874,7 @@ const DEFAULT_POLLY_VOICE = "Polly.Joanna";
244
874
  function mapVoiceToPolly(voice) {
245
875
  if (!voice) return DEFAULT_POLLY_VOICE;
246
876
  if (voice.startsWith("Polly.") || voice.startsWith("Google.")) return voice;
247
- return OPENAI_TO_POLLY_MAP[normalizeLowercaseStringOrEmpty(voice)] || "Polly.Joanna";
877
+ return OPENAI_TO_POLLY_MAP[normalizeLowercaseStringOrEmpty(voice)] || DEFAULT_POLLY_VOICE;
248
878
  }
249
879
  //#endregion
250
880
  //#region extensions/voice-call/src/manager/twiml.ts
@@ -646,24 +1276,26 @@ async function endCall(ctx, callId, options) {
646
1276
  }
647
1277
  //#endregion
648
1278
  //#region extensions/voice-call/src/manager/events.ts
1279
+ const log = createSubsystemLogger("voice-call/events");
649
1280
  function shouldAcceptInbound(config, from) {
650
1281
  const { inboundPolicy: policy, allowFrom } = config;
651
1282
  switch (policy) {
652
1283
  case "disabled":
653
- console.log("[voice-call] Inbound call rejected: policy is disabled");
1284
+ log.info("Inbound call rejected: policy is disabled");
654
1285
  return false;
655
1286
  case "open":
656
- console.log("[voice-call] Inbound call accepted: policy is open");
1287
+ log.info("Inbound call accepted: policy is open");
657
1288
  return true;
658
1289
  case "allowlist":
659
1290
  case "pairing": {
660
1291
  const normalized = normalizePhoneNumber(from);
661
1292
  if (!normalized) {
662
- console.log("[voice-call] Inbound call rejected: missing caller ID");
1293
+ log.info("Inbound call rejected: missing caller ID");
663
1294
  return false;
664
1295
  }
665
1296
  const allowed = isAllowlistedCaller(normalized, allowFrom);
666
- console.log(`[voice-call] Inbound call ${allowed ? "accepted" : "rejected"}: ${from} ${allowed ? "is in" : "not in"} allowlist`);
1297
+ const status = allowed ? "accepted" : "rejected";
1298
+ log.info(`Inbound call ${status}: caller=${redactIdentifier(from)} allowlisted=${allowed}`);
667
1299
  return allowed;
668
1300
  }
669
1301
  default: return false;
@@ -698,7 +1330,7 @@ function createWebhookCall(params) {
698
1330
  params.ctx.activeCalls.set(callId, callRecord);
699
1331
  params.ctx.providerCallIdMap.set(params.providerCallId, callId);
700
1332
  persistCallRecord(params.ctx.storePath, callRecord);
701
- console.log(`[voice-call] Created ${params.direction} call record: ${callId} from ${params.from}`);
1333
+ log.info(`Created ${params.direction} call record: ${callId} caller=${redactIdentifier(params.from)}`);
702
1334
  return callRecord;
703
1335
  }
704
1336
  function persistRejectedInboundCall(params) {
@@ -735,7 +1367,7 @@ function processEvent(ctx, event) {
735
1367
  if (eventDirection === "inbound" && !shouldAcceptInbound(ctx.config, event.from)) {
736
1368
  const pid = providerCallId;
737
1369
  if (!ctx.provider) {
738
- console.warn(`[voice-call] Inbound call rejected by policy but no provider to hang up (providerCallId: ${pid}, from: ${event.from}); call will time out on provider side.`);
1370
+ 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.`);
739
1371
  return { kind: "ignored" };
740
1372
  }
741
1373
  ctx.processedEventIds.add(dedupeKey);
@@ -748,7 +1380,7 @@ function processEvent(ctx, event) {
748
1380
  dedupeKey,
749
1381
  providerCallId: pid
750
1382
  });
751
- console.log(`[voice-call] Rejecting inbound call by policy: ${pid}`);
1383
+ log.info(`Rejecting inbound call by policy: ${pid}`);
752
1384
  ctx.provider.hangupCall({
753
1385
  callId,
754
1386
  providerCallId: pid,
@@ -756,7 +1388,7 @@ function processEvent(ctx, event) {
756
1388
  }).catch((err) => {
757
1389
  ctx.rejectedProviderCallIds.delete(pid);
758
1390
  const message = formatErrorMessage(err);
759
- console.warn(`[voice-call] Failed to reject inbound call ${pid}:`, message);
1391
+ log.warn(`Failed to reject inbound call ${pid}: ${message}`);
760
1392
  });
761
1393
  return { kind: "processed" };
762
1394
  }
@@ -803,7 +1435,7 @@ function processEvent(ctx, event) {
803
1435
  } : {}
804
1436
  }).catch((err) => {
805
1437
  const message = formatErrorMessage(err);
806
- console.warn(`[voice-call] Failed to answer inbound call ${call.providerCallId}:`, message);
1438
+ log.warn(`Failed to answer inbound call ${call.providerCallId}: ${message}`);
807
1439
  });
808
1440
  }
809
1441
  break;
@@ -843,7 +1475,7 @@ function processEvent(ctx, event) {
843
1475
  const hadWaiter = ctx.transcriptWaiters.has(call.callId);
844
1476
  const resolved = resolveTranscriptWaiter(ctx, call.callId, event.transcript, event.turnToken);
845
1477
  if (hadWaiter && !resolved) {
846
- console.warn(`[voice-call] Ignoring speech event with mismatched turn token for ${call.callId}`);
1478
+ log.warn(`Ignoring speech event with mismatched turn token for ${call.callId}`);
847
1479
  result = { kind: "ignored" };
848
1480
  break;
849
1481
  }
@@ -1337,7 +1969,7 @@ function mergeTtsConfig(base, override) {
1337
1969
  if (!base && !override) return;
1338
1970
  if (!override) return base;
1339
1971
  if (!base) return override;
1340
- return deepMergeDefined(base, override);
1972
+ return mergeDeep(base, override);
1341
1973
  }
1342
1974
  /** Resolve directive override policy for telephony synthesis. */
1343
1975
  function resolveTelephonyModelOverridePolicy(overrides) {
@@ -1424,7 +2056,7 @@ function appendBoundedChildOutput(current, chunk, maxChars = DEFAULT_MAX_OUTPUT_
1424
2056
  truncated: current.truncated
1425
2057
  };
1426
2058
  return {
1427
- text: appended.slice(-maxChars),
2059
+ text: sliceUtf16Safe(appended, -maxChars),
1428
2060
  truncated: true
1429
2061
  };
1430
2062
  }
@@ -1435,77 +2067,32 @@ function formatBoundedChildOutput(output) {
1435
2067
  //#endregion
1436
2068
  //#region extensions/voice-call/src/webhook/tailscale.ts
1437
2069
  const TAILSCALE_COMMAND_STDOUT_MAX_BYTES = 4 * 1024 * 1024;
1438
- function appendTailscaleCommandStdout(current, data, maxBytes = TAILSCALE_COMMAND_STDOUT_MAX_BYTES) {
1439
- if (current.exceeded) return current;
1440
- const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
1441
- const bytes = current.bytes + buffer.byteLength;
1442
- if (bytes > maxBytes) return {
1443
- bytes,
1444
- exceeded: true,
1445
- text: ""
1446
- };
1447
- return {
1448
- bytes,
1449
- exceeded: false,
1450
- text: `${current.text}${buffer.toString("utf8")}`
1451
- };
1452
- }
1453
- function runTailscaleCommand(args, timeoutMs = 2500) {
1454
- return new Promise((resolve) => {
1455
- const proc = spawn("tailscale", args, { stdio: [
1456
- "ignore",
1457
- "pipe",
1458
- "ignore"
1459
- ] });
1460
- let stdout = {
1461
- bytes: 0,
1462
- exceeded: false,
1463
- text: ""
2070
+ async function runTailscaleCommand(args, timeoutMs = 2500) {
2071
+ try {
2072
+ const result = await runCommandWithTimeout(["tailscale", ...args], {
2073
+ killProcessTree: true,
2074
+ maxOutputBytes: {
2075
+ stdout: TAILSCALE_COMMAND_STDOUT_MAX_BYTES,
2076
+ stderr: 1
2077
+ },
2078
+ outputCapture: "head",
2079
+ terminateOnOutputLimit: { stdout: true },
2080
+ timeoutMs
2081
+ });
2082
+ if (result.termination !== "exit" || result.outputLimitExceeded) return {
2083
+ code: -1,
2084
+ stdout: ""
1464
2085
  };
1465
- let settled = false;
1466
- const finish = (result) => {
1467
- if (settled) return;
1468
- settled = true;
1469
- clearTimeout(timer);
1470
- resolve(result);
2086
+ return {
2087
+ code: result.code ?? -1,
2088
+ stdout: result.stdout
1471
2089
  };
1472
- const timer = setTimeout(() => {
1473
- proc.kill("SIGKILL");
1474
- finish({
1475
- code: -1,
1476
- stdout: ""
1477
- });
1478
- }, timeoutMs);
1479
- proc.stdout.on("data", (data) => {
1480
- stdout = appendTailscaleCommandStdout(stdout, data);
1481
- if (stdout.exceeded) {
1482
- proc.kill("SIGKILL");
1483
- finish({
1484
- code: -1,
1485
- stdout: ""
1486
- });
1487
- }
1488
- });
1489
- proc.stdout.on("error", () => {
1490
- proc.kill("SIGKILL");
1491
- finish({
1492
- code: -1,
1493
- stdout: ""
1494
- });
1495
- });
1496
- proc.on("error", () => {
1497
- finish({
1498
- code: -1,
1499
- stdout: ""
1500
- });
1501
- });
1502
- proc.on("close", (code) => {
1503
- finish({
1504
- code: code ?? -1,
1505
- stdout: stdout.text
1506
- });
1507
- });
1508
- });
2090
+ } catch {
2091
+ return {
2092
+ code: -1,
2093
+ stdout: ""
2094
+ };
2095
+ }
1509
2096
  }
1510
2097
  async function getTailscaleSelfInfo() {
1511
2098
  const { code, stdout } = await runTailscaleCommand([
@@ -1576,6 +2163,8 @@ async function cleanupTailscaleExposure(config) {
1576
2163
  //#endregion
1577
2164
  //#region extensions/voice-call/src/tunnel.ts
1578
2165
  const NGROK_LOG_BUFFER_MAX_CHARS = 16384;
2166
+ const NGROK_ERROR_MARKER = "ERR_NGROK";
2167
+ const TUNNEL_COMMAND_OUTPUT_MAX_BYTES = 16384;
1579
2168
  function listenForChildStreamErrors(proc, onError) {
1580
2169
  proc.stdout.on("error", (error) => onError("stdout", error));
1581
2170
  proc.stderr.on("error", (error) => onError("stderr", error));
@@ -1615,6 +2204,7 @@ async function startNgrokTunnel(config) {
1615
2204
  let closed = false;
1616
2205
  let publicUrl = null;
1617
2206
  let outputBuffer = "";
2207
+ let stderrTail = "";
1618
2208
  const timeout = setTimeout(() => {
1619
2209
  if (!resolved) {
1620
2210
  resolved = true;
@@ -1671,12 +2261,13 @@ async function startNgrokTunnel(config) {
1671
2261
  proc.stdout.on("data", (data) => {
1672
2262
  const lines = (outputBuffer + data.toString()).split("\n");
1673
2263
  outputBuffer = lines.pop() || "";
1674
- if (outputBuffer.length > NGROK_LOG_BUFFER_MAX_CHARS) outputBuffer = outputBuffer.slice(-16384);
2264
+ if (outputBuffer.length > NGROK_LOG_BUFFER_MAX_CHARS) outputBuffer = sliceUtf16Safe(outputBuffer, -16384);
1675
2265
  for (const line of lines) if (line.trim()) processLine(line);
1676
2266
  });
1677
2267
  proc.stderr.on("data", (data) => {
1678
- const msg = data.toString();
1679
- if (msg.includes("ERR_NGROK")) rejectIfPending(`ngrok error: ${formatBoundedChildOutput(appendBoundedChildOutput(emptyBoundedChildOutput(), msg))}`, true);
2268
+ const combined = stderrTail + data.toString();
2269
+ if (combined.includes(NGROK_ERROR_MARKER)) rejectIfPending(`ngrok error: ${formatBoundedChildOutput(appendBoundedChildOutput(emptyBoundedChildOutput(), combined))}`, true);
2270
+ stderrTail = sliceUtf16Safe(combined, -8);
1680
2271
  });
1681
2272
  listenForChildStreamErrors(proc, (stream, error) => {
1682
2273
  rejectIfPending(`ngrok ${stream} error: ${error.message}`, true);
@@ -1698,41 +2289,22 @@ async function startNgrokTunnel(config) {
1698
2289
  * Run an ngrok command and wait for completion.
1699
2290
  */
1700
2291
  async function runNgrokCommand(args) {
1701
- return new Promise((resolve, reject) => {
1702
- const proc = spawn("ngrok", args, { stdio: [
1703
- "ignore",
1704
- "pipe",
1705
- "pipe"
1706
- ] });
1707
- let stdout = emptyBoundedChildOutput();
1708
- let stderr = emptyBoundedChildOutput();
1709
- let settled = false;
1710
- const rejectIfPending = (error, kill = false) => {
1711
- if (settled) return;
1712
- settled = true;
1713
- if (kill) proc.kill("SIGKILL");
1714
- reject(error);
1715
- };
1716
- proc.stdout.on("data", (data) => {
1717
- stdout = appendBoundedChildOutput(stdout, data.toString());
1718
- });
1719
- proc.stderr.on("data", (data) => {
1720
- stderr = appendBoundedChildOutput(stderr, data.toString());
1721
- });
1722
- listenForChildStreamErrors(proc, (stream, error) => {
1723
- rejectIfPending(/* @__PURE__ */ new Error(`ngrok command ${stream} error: ${error.message}`), true);
1724
- });
1725
- proc.on("close", (code) => {
1726
- if (settled) return;
1727
- settled = true;
1728
- if (code === 0) resolve(stdout.text);
1729
- else {
1730
- const output = stderr.text ? stderr : stdout;
1731
- reject(/* @__PURE__ */ new Error(`ngrok command failed: ${formatBoundedChildOutput(output)}`));
1732
- }
1733
- });
1734
- proc.on("error", (error) => rejectIfPending(error));
2292
+ const result = await runCommandWithTimeout(["ngrok", ...args], {
2293
+ killProcessTree: true,
2294
+ maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES,
2295
+ outputCapture: "tail",
2296
+ timeoutMs: 3e4
1735
2297
  });
2298
+ if (result.termination === "timeout") throw new Error("ngrok command timed out");
2299
+ if (result.code === 0) return result.stdout;
2300
+ const output = result.stderr ? {
2301
+ text: result.stderr,
2302
+ truncated: Boolean(result.stderrTruncatedBytes)
2303
+ } : {
2304
+ text: result.stdout,
2305
+ truncated: Boolean(result.stdoutTruncatedBytes)
2306
+ };
2307
+ throw new Error(`ngrok command failed: ${formatBoundedChildOutput(output)}`);
1736
2308
  }
1737
2309
  /**
1738
2310
  * Start a Tailscale serve/funnel tunnel.
@@ -1742,89 +2314,56 @@ async function startTailscaleTunnel(config) {
1742
2314
  if (!dnsName) throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
1743
2315
  const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
1744
2316
  const localUrl = `http://127.0.0.1:${config.port}${path}`;
1745
- return new Promise((resolve, reject) => {
1746
- const proc = spawn("tailscale", [
1747
- config.mode,
1748
- "--bg",
1749
- "--yes",
1750
- "--set-path",
1751
- path,
1752
- localUrl
1753
- ], { stdio: [
1754
- "ignore",
1755
- "pipe",
1756
- "pipe"
1757
- ] });
1758
- let resolved = false;
1759
- let stdout = emptyBoundedChildOutput();
1760
- let stderr = emptyBoundedChildOutput();
1761
- const rejectIfPending = (error, kill = false) => {
1762
- if (resolved) return;
1763
- resolved = true;
1764
- clearTimeout(timeout);
1765
- if (kill) proc.kill("SIGKILL");
1766
- reject(error);
1767
- };
1768
- const timeout = setTimeout(() => {
1769
- rejectIfPending(/* @__PURE__ */ new Error(`Tailscale ${config.mode} timed out`), true);
1770
- }, 1e4);
1771
- proc.stdout.on("data", (data) => {
1772
- stdout = appendBoundedChildOutput(stdout, data.toString());
1773
- });
1774
- proc.stderr.on("data", (data) => {
1775
- stderr = appendBoundedChildOutput(stderr, data.toString());
1776
- });
1777
- listenForChildStreamErrors(proc, (stream, error) => {
1778
- rejectIfPending(/* @__PURE__ */ new Error(`Tailscale ${config.mode} ${stream} error: ${error.message}`), true);
1779
- });
1780
- proc.on("close", (code) => {
1781
- clearTimeout(timeout);
1782
- if (resolved) return;
1783
- resolved = true;
1784
- if (code === 0) {
1785
- const publicUrl = `https://${dnsName}${path}`;
1786
- console.log(`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`);
1787
- resolve({
1788
- publicUrl,
1789
- provider: `tailscale-${config.mode}`,
1790
- stop: async () => {
1791
- await stopTailscaleTunnel(config.mode, path);
1792
- }
1793
- });
1794
- } else {
1795
- const output = stderr.text ? stderr : stdout;
1796
- const detail = output.text ? `: ${formatBoundedChildOutput(output)}` : "";
1797
- reject(/* @__PURE__ */ new Error(`Tailscale ${config.mode} failed with code ${code}${detail}`));
1798
- }
1799
- });
1800
- proc.on("error", (err) => {
1801
- rejectIfPending(err);
1802
- });
2317
+ const result = await runCommandWithTimeout([
2318
+ "tailscale",
2319
+ config.mode,
2320
+ "--bg",
2321
+ "--yes",
2322
+ "--set-path",
2323
+ path,
2324
+ localUrl
2325
+ ], {
2326
+ killProcessTree: true,
2327
+ maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES,
2328
+ outputCapture: "tail",
2329
+ timeoutMs: 1e4
1803
2330
  });
2331
+ if (result.termination === "timeout") throw new Error(`Tailscale ${config.mode} timed out`);
2332
+ if (result.code !== 0) {
2333
+ const output = result.stderr ? {
2334
+ text: result.stderr,
2335
+ truncated: Boolean(result.stderrTruncatedBytes)
2336
+ } : {
2337
+ text: result.stdout,
2338
+ truncated: Boolean(result.stdoutTruncatedBytes)
2339
+ };
2340
+ const detail = output.text ? `: ${formatBoundedChildOutput(output)}` : "";
2341
+ throw new Error(`Tailscale ${config.mode} failed with code ${result.code}${detail}`);
2342
+ }
2343
+ const publicUrl = `https://${dnsName}${path}`;
2344
+ console.log(`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`);
2345
+ return {
2346
+ publicUrl,
2347
+ provider: `tailscale-${config.mode}`,
2348
+ stop: async () => {
2349
+ await stopTailscaleTunnel(config.mode, path);
2350
+ }
2351
+ };
1804
2352
  }
1805
2353
  /**
1806
2354
  * Stop a Tailscale serve/funnel tunnel.
1807
2355
  */
1808
2356
  async function stopTailscaleTunnel(mode, path) {
1809
- return new Promise((resolve) => {
1810
- const proc = spawn("tailscale", [
1811
- mode,
1812
- "off",
1813
- path
1814
- ], { stdio: "ignore" });
1815
- const timeout = setTimeout(() => {
1816
- proc.kill("SIGKILL");
1817
- resolve();
1818
- }, 5e3);
1819
- proc.on("close", () => {
1820
- clearTimeout(timeout);
1821
- resolve();
1822
- });
1823
- proc.on("error", () => {
1824
- clearTimeout(timeout);
1825
- resolve();
1826
- });
1827
- });
2357
+ await runCommandWithTimeout([
2358
+ "tailscale",
2359
+ mode,
2360
+ "off",
2361
+ path
2362
+ ], {
2363
+ killProcessTree: true,
2364
+ maxOutputBytes: 1,
2365
+ timeoutMs: 5e3
2366
+ }).catch(() => {});
1828
2367
  }
1829
2368
  /**
1830
2369
  * Start a tunnel based on configuration.
@@ -2308,9 +2847,7 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2308
2847
  bufferedAfterBytes: session.ws.bufferedAmount
2309
2848
  };
2310
2849
  if (bufferedBeforeBytes > MAX_WS_BUFFERED_BYTES) {
2311
- try {
2312
- session.ws.close(1013, "Backpressure: send buffer exceeded");
2313
- } catch {}
2850
+ session.ws.close(1013, "Backpressure: send buffer exceeded");
2314
2851
  return {
2315
2852
  sent: false,
2316
2853
  readyState,
@@ -2322,9 +2859,7 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2322
2859
  session.ws.send(JSON.stringify(message));
2323
2860
  const bufferedAfterBytes = session.ws.bufferedAmount;
2324
2861
  if (bufferedAfterBytes > MAX_WS_BUFFERED_BYTES) {
2325
- try {
2326
- session.ws.close(1013, "Backpressure: send buffer exceeded");
2327
- } catch {}
2862
+ session.ws.close(1013, "Backpressure: send buffer exceeded");
2328
2863
  return {
2329
2864
  sent: false,
2330
2865
  readyState,
@@ -2607,14 +3142,8 @@ const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
2607
3142
  const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
2608
3143
  const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
2609
3144
  const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
2610
- const TRANSCRIPT_LOG_MAX_CHARS = 200;
2611
3145
  const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
2612
- const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-D3YO-WtN.js"));
2613
- function sanitizeTranscriptForLog(value) {
2614
- const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
2615
- if (sanitized.length <= TRANSCRIPT_LOG_MAX_CHARS) return sanitized;
2616
- return `${truncateUtf16Safe(sanitized, TRANSCRIPT_LOG_MAX_CHARS)}...`;
2617
- }
3146
+ const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-hmAdGp5h.js"));
2618
3147
  function appendRecentTalkEventMetadata(call, event) {
2619
3148
  const metadata = call.metadata ?? {};
2620
3149
  const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
@@ -2709,6 +3238,14 @@ var VoiceCallWebhookServer = class {
2709
3238
  error: console.error,
2710
3239
  debug: console.debug
2711
3240
  };
3241
+ const rawLogger = this.logger;
3242
+ const rawDebug = rawLogger.debug;
3243
+ this.logger = {
3244
+ info: (msg) => rawLogger.info(`[voice-call] ${msg}`),
3245
+ warn: (msg) => rawLogger.warn(`[voice-call] ${msg}`),
3246
+ error: (msg) => rawLogger.error(`[voice-call] ${msg}`),
3247
+ debug: rawDebug ? (msg) => rawDebug(`[voice-call] ${msg}`) : void 0
3248
+ };
2712
3249
  }
2713
3250
  /**
2714
3251
  * Get the media stream handler (for wiring to provider).
@@ -2777,15 +3314,15 @@ var VoiceCallWebhookServer = class {
2777
3314
  })
2778
3315
  });
2779
3316
  if (!resolution.ok && resolution.code === "missing-configured-provider") {
2780
- console.warn(`[voice-call] Streaming enabled but realtime transcription provider "${resolution.configuredProviderId}" is not registered`);
3317
+ this.logger.warn(`Streaming enabled but realtime transcription provider "${resolution.configuredProviderId}" is not registered`);
2781
3318
  return;
2782
3319
  }
2783
3320
  if (!resolution.ok && resolution.code === "no-registered-provider") {
2784
- console.warn("[voice-call] Streaming enabled but no realtime transcription provider is registered");
3321
+ this.logger.warn("Streaming enabled but no realtime transcription provider is registered");
2785
3322
  return;
2786
3323
  }
2787
3324
  if (!resolution.ok) {
2788
- console.warn(`[voice-call] Streaming enabled but provider "${resolution.provider?.id}" is not configured`);
3325
+ this.logger.warn(`Streaming enabled but provider "${resolution.provider?.id}" is not configured`);
2789
3326
  return;
2790
3327
  }
2791
3328
  const streamConfig = {
@@ -2798,25 +3335,26 @@ var VoiceCallWebhookServer = class {
2798
3335
  maxConnections: streaming.maxConnections,
2799
3336
  resolveClientIp: (request) => this.resolveMediaStreamClientIp(request),
2800
3337
  shouldAcceptStream: ({ callId, token }) => {
3338
+ if (this.provider.name !== "twilio") {
3339
+ this.logger.warn(`Rejecting media stream: provider ${this.provider.name} does not support authenticated classic streaming`);
3340
+ return false;
3341
+ }
2801
3342
  if (!this.manager.getCallByProviderCallId(callId)) return false;
2802
- if (this.provider.name === "twilio") {
2803
- if (!this.provider.isValidStreamToken(callId, token)) {
2804
- console.warn(`[voice-call] Rejecting media stream: invalid token for ${callId}`);
2805
- return false;
2806
- }
3343
+ if (!this.provider.isValidStreamToken(callId, token)) {
3344
+ this.logger.warn(`Rejecting media stream: invalid token for ${callId}`);
3345
+ return false;
2807
3346
  }
2808
3347
  return true;
2809
3348
  },
2810
3349
  onTranscript: (providerCallId, transcript) => {
2811
- const safeTranscript = sanitizeTranscriptForLog(transcript);
2812
- console.log(`[voice-call] Transcript for ${providerCallId}: ${safeTranscript} (chars=${transcript.length})`);
3350
+ this.logger.info(`Transcript received ${providerCallId} chars=${transcript.length}`);
2813
3351
  const call = this.manager.getCallByProviderCallId(providerCallId);
2814
3352
  if (!call) {
2815
- console.warn(`[voice-call] No active call found for provider ID: ${providerCallId}`);
3353
+ this.logger.warn(`No active call found for provider ID: ${providerCallId}`);
2816
3354
  return;
2817
3355
  }
2818
3356
  if (this.shouldSuppressBargeInForInitialMessage(call)) {
2819
- console.log(`[voice-call] Ignoring barge transcript while initial message is still playing (${providerCallId})`);
3357
+ this.logger.info(`Ignoring barge transcript while initial message is still playing (${providerCallId})`);
2820
3358
  return;
2821
3359
  }
2822
3360
  if (this.provider.name === "twilio") this.provider.clearTtsQueue(providerCallId);
@@ -2838,25 +3376,24 @@ var VoiceCallWebhookServer = class {
2838
3376
  this.provider.clearTtsQueue(providerCallId);
2839
3377
  },
2840
3378
  onPartialTranscript: (callId, partial) => {
2841
- const safePartial = sanitizeTranscriptForLog(partial);
2842
- console.log(`[voice-call] Partial for ${callId}: ${safePartial} (chars=${partial.length})`);
3379
+ this.logger.info(`Partial transcript ${callId} chars=${partial.length}`);
2843
3380
  },
2844
3381
  onTalkEvent: (providerCallId, _streamSid, event) => {
2845
3382
  const call = this.manager.getCallByProviderCallId(providerCallId);
2846
3383
  if (call) appendRecentTalkEventMetadata(call, event);
2847
3384
  },
2848
3385
  onConnect: (callId, streamSid) => {
2849
- console.log(`[voice-call] Media stream connected: ${callId} -> ${streamSid}`);
3386
+ this.logger.info(`Media stream connected: ${callId} -> ${streamSid}`);
2850
3387
  this.clearPendingDisconnectHangup(callId);
2851
3388
  if (this.provider.name === "twilio") this.provider.registerCallStream(callId, streamSid);
2852
3389
  },
2853
3390
  onTranscriptionReady: (callId) => {
2854
3391
  this.manager.speakInitialMessage(callId).catch((err) => {
2855
- console.warn(`[voice-call] Failed to speak initial message:`, err);
3392
+ this.logger.warn(`Failed to speak initial message: ${String(err)}`);
2856
3393
  });
2857
3394
  },
2858
3395
  onDisconnect: (callId, streamSid) => {
2859
- console.log(`[voice-call] Media stream disconnected: ${callId} (${streamSid})`);
3396
+ this.logger.info(`Media stream disconnected: ${callId} (${streamSid})`);
2860
3397
  if (this.provider.name === "twilio") this.provider.unregisterCallStream(callId, streamSid);
2861
3398
  this.clearPendingDisconnectHangup(callId);
2862
3399
  const timer = setTimeout(() => {
@@ -2866,9 +3403,9 @@ var VoiceCallWebhookServer = class {
2866
3403
  if (this.provider.name === "twilio") {
2867
3404
  if (this.provider.hasRegisteredStream(callId)) return;
2868
3405
  }
2869
- console.log(`[voice-call] Auto-ending call ${disconnectedCall.callId} after stream disconnect grace`);
3406
+ this.logger.info(`Auto-ending call ${disconnectedCall.callId} after stream disconnect grace`);
2870
3407
  this.manager.endCall(disconnectedCall.callId).catch((err) => {
2871
- console.warn(`[voice-call] Failed to auto-end call ${disconnectedCall.callId}:`, err);
3408
+ this.logger.warn(`Failed to auto-end call ${disconnectedCall.callId}: ${String(err)}`);
2872
3409
  });
2873
3410
  }, STREAM_DISCONNECT_HANGUP_GRACE_MS);
2874
3411
  timer.unref?.();
@@ -2876,7 +3413,7 @@ var VoiceCallWebhookServer = class {
2876
3413
  }
2877
3414
  };
2878
3415
  this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
2879
- console.log("[voice-call] Media streaming initialized");
3416
+ this.logger.info("Media streaming initialized");
2880
3417
  }
2881
3418
  /**
2882
3419
  * Start the webhook server.
@@ -2891,7 +3428,7 @@ var VoiceCallWebhookServer = class {
2891
3428
  this.startPromise = new Promise((resolve, reject) => {
2892
3429
  this.server = http.createServer((req, res) => {
2893
3430
  this.handleRequest(req, res, webhookPath).catch((err) => {
2894
- console.error("[voice-call] Webhook error:", err);
3431
+ this.logger.error(`Webhook error: ${String(err)}`);
2895
3432
  res.statusCode = 500;
2896
3433
  res.end("Internal Server Error");
2897
3434
  });
@@ -2914,11 +3451,11 @@ var VoiceCallWebhookServer = class {
2914
3451
  const url = this.resolveListeningUrl(bind, webhookPath);
2915
3452
  this.listeningUrl = url;
2916
3453
  this.startPromise = null;
2917
- this.logger.info(`[voice-call] Webhook server listening on ${url}`);
3454
+ this.logger.info(`Webhook server listening on ${url}`);
2918
3455
  if (this.mediaStreamHandler) {
2919
3456
  const address = this.server?.address();
2920
3457
  const actualPort = address && typeof address === "object" ? address.port : this.config.serve.port;
2921
- this.logger.info(`[voice-call] Media stream WebSocket on ws://${bind}:${actualPort}${streamPath}`);
3458
+ this.logger.info(`Media stream WebSocket on ws://${bind}:${actualPort}${streamPath}`);
2922
3459
  }
2923
3460
  resolve(url);
2924
3461
  this.stopStaleCallReaper = startStaleCallReaper({
@@ -2999,17 +3536,17 @@ var VoiceCallWebhookServer = class {
2999
3536
  };
3000
3537
  const headerGate = this.verifyPreAuthWebhookHeaders(req.headers);
3001
3538
  if (!headerGate.ok) {
3002
- console.warn(`[voice-call] Webhook rejected before body read: ${headerGate.reason}`);
3539
+ this.logger.warn(`Webhook rejected before body read: ${headerGate.reason}`);
3003
3540
  return {
3004
3541
  statusCode: 401,
3005
3542
  body: "Unauthorized"
3006
3543
  };
3007
3544
  }
3008
3545
  const remoteAddress = req.socket.remoteAddress;
3009
- if (!remoteAddress) console.warn(`[voice-call] Webhook accepted with no remote address; using shared fallback in-flight key`);
3546
+ if (!remoteAddress) this.logger.warn(`Webhook accepted with no remote address; using shared fallback in-flight key`);
3010
3547
  const inFlightKey = remoteAddress || MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY;
3011
3548
  if (!this.webhookInFlightLimiter.tryAcquire(inFlightKey)) {
3012
- console.warn(`[voice-call] Webhook rejected before body read: too many in-flight requests`);
3549
+ this.logger.warn(`Webhook rejected before body read: too many in-flight requests`);
3013
3550
  return {
3014
3551
  statusCode: 429,
3015
3552
  body: "Too Many Requests"
@@ -3040,14 +3577,14 @@ var VoiceCallWebhookServer = class {
3040
3577
  };
3041
3578
  const verification = this.provider.verifyWebhook(ctx);
3042
3579
  if (!verification.ok) {
3043
- console.warn(`[voice-call] Webhook verification failed: ${verification.reason}`);
3580
+ this.logger.warn(`Webhook verification failed: ${verification.reason}`);
3044
3581
  return {
3045
3582
  statusCode: 401,
3046
3583
  body: "Unauthorized"
3047
3584
  };
3048
3585
  }
3049
3586
  if (!verification.verifiedRequestKey) {
3050
- console.warn("[voice-call] Webhook verification succeeded without request identity key");
3587
+ this.logger.warn("Webhook verification succeeded without request identity key");
3051
3588
  return {
3052
3589
  statusCode: 401,
3053
3590
  body: "Unauthorized"
@@ -3055,7 +3592,7 @@ var VoiceCallWebhookServer = class {
3055
3592
  }
3056
3593
  const isReplay = Boolean(verification.isReplay);
3057
3594
  if (isReplay) {
3058
- console.warn("[voice-call] Replay detected; skipping event side effects");
3595
+ this.logger.warn("Replay detected; skipping event side effects");
3059
3596
  if (this.provider.name === "twilio") return buildTwilioReplayTwiML();
3060
3597
  const cachedResponse = await this.getCachedReplayResponse(verification.verifiedRequestKey);
3061
3598
  if (cachedResponse) return cachedResponse;
@@ -3064,7 +3601,7 @@ var VoiceCallWebhookServer = class {
3064
3601
  const initialTwiML = this.provider.consumeInitialTwiML?.(ctx);
3065
3602
  if (initialTwiML !== void 0 && initialTwiML !== null) {
3066
3603
  const params = new URLSearchParams(ctx.rawBody);
3067
- console.log(`[voice-call] Serving provider initial TwiML before realtime handling (callSid=${params.get("CallSid") ?? "unknown"}, direction=${params.get("Direction") ?? "unknown"})`);
3604
+ this.logger.info(`Serving provider initial TwiML before realtime handling (callSid=${params.get("CallSid") ?? "unknown"}, direction=${params.get("Direction") ?? "unknown"})`);
3068
3605
  return {
3069
3606
  statusCode: 200,
3070
3607
  headers: { "Content-Type": "application/xml" },
@@ -3075,10 +3612,10 @@ var VoiceCallWebhookServer = class {
3075
3612
  if (realtimeParams) {
3076
3613
  const direction = realtimeParams.get("Direction");
3077
3614
  if ((!direction || direction === "inbound") && !this.shouldAcceptRealtimeInboundRequest(realtimeParams)) {
3078
- console.log("[voice-call] Realtime inbound call rejected before stream setup");
3615
+ this.logger.info("Realtime inbound call rejected before stream setup");
3079
3616
  return buildRealtimeRejectedTwiML();
3080
3617
  }
3081
- console.log(`[voice-call] Serving realtime TwiML for Twilio call ${realtimeParams.get("CallSid") ?? "unknown"} (direction=${direction ?? "unknown"})`);
3618
+ this.logger.info(`Serving realtime TwiML for Twilio call ${realtimeParams.get("CallSid") ?? "unknown"} (direction=${direction ?? "unknown"})`);
3082
3619
  return this.realtimeHandler.buildTwiMLPayload(req, realtimeParams);
3083
3620
  }
3084
3621
  const parsed = this.provider.parseWebhookEvent(ctx, { verifiedRequestKey: verification.verifiedRequestKey });
@@ -3194,7 +3731,7 @@ var VoiceCallWebhookServer = class {
3194
3731
  for (const event of events) try {
3195
3732
  this.processEventWithAutoResponse(event);
3196
3733
  } catch (err) {
3197
- console.error(`[voice-call] Error processing event ${event.type}:`, err);
3734
+ this.logger.error(`Error processing event ${event.type}: ${String(err)}`);
3198
3735
  }
3199
3736
  }
3200
3737
  processEventWithAutoResponse(event) {
@@ -3203,7 +3740,7 @@ var VoiceCallWebhookServer = class {
3203
3740
  const callMode = result.call.metadata?.mode;
3204
3741
  if (result.call.direction !== "inbound" && callMode !== "conversation") return;
3205
3742
  this.handleInboundResponse(result.call.callId, result.transcript).catch((err) => {
3206
- console.warn(`[voice-call] Failed to auto-respond:`, err);
3743
+ this.logger.warn(`Failed to auto-respond: ${String(err)}`);
3207
3744
  });
3208
3745
  }
3209
3746
  writeWebhookResponse(res, payload) {
@@ -3225,18 +3762,18 @@ var VoiceCallWebhookServer = class {
3225
3762
  * Supports tool calling for richer voice interactions.
3226
3763
  */
3227
3764
  async handleInboundResponse(callId, userMessage) {
3228
- console.log(`[voice-call] Auto-responding to inbound call ${callId}: "${userMessage}"`);
3765
+ this.logger.info(`Auto-responding to inbound call ${callId} chars=${userMessage.length}`);
3229
3766
  const call = this.manager.getCall(callId);
3230
3767
  if (!call) {
3231
- console.warn(`[voice-call] Call ${callId} not found for auto-response`);
3768
+ this.logger.warn(`Call ${callId} not found for auto-response`);
3232
3769
  return;
3233
3770
  }
3234
3771
  if (!this.coreConfig) {
3235
- console.warn("[voice-call] Core config missing; skipping auto-response");
3772
+ this.logger.warn("Core config missing; skipping auto-response");
3236
3773
  return;
3237
3774
  }
3238
3775
  if (!this.agentRuntime) {
3239
- console.warn("[voice-call] Agent runtime missing; skipping auto-response");
3776
+ this.logger.warn("Agent runtime missing; skipping auto-response");
3240
3777
  return;
3241
3778
  }
3242
3779
  try {
@@ -3254,20 +3791,20 @@ var VoiceCallWebhookServer = class {
3254
3791
  transcript: call.transcript,
3255
3792
  userMessage,
3256
3793
  onEarlyText: async (text) => {
3257
- console.log(`[voice-call] Early AI response: "${text}"`);
3794
+ this.logger.info(`Early AI response queued ${callId} chars=${text.length}`);
3258
3795
  return (await this.manager.speak(callId, text, { listenAfterPlayback: true })).success;
3259
3796
  }
3260
3797
  });
3261
3798
  if (result.error) {
3262
- console.error(`[voice-call] Response generation error: ${result.error}`);
3799
+ this.logger.error(`Response generation error: ${result.error}`);
3263
3800
  return;
3264
3801
  }
3265
3802
  if (result.text && !result.deliveredEarly) {
3266
- console.log(`[voice-call] AI response: "${result.text}"`);
3803
+ this.logger.info(`AI response delivered ${callId} chars=${result.text.length}`);
3267
3804
  await this.manager.speak(callId, result.text, { listenAfterPlayback: true });
3268
3805
  }
3269
3806
  } catch (err) {
3270
- console.error(`[voice-call] Auto-response error:`, err);
3807
+ this.logger.error(`Auto-response error: ${String(err)}`);
3271
3808
  }
3272
3809
  }
3273
3810
  };
@@ -3281,12 +3818,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
3281
3818
  "Do not print secret values or dump environment variables; only check whether required configuration is present.",
3282
3819
  "Be accurate, brief, and speakable."
3283
3820
  ].join(" ");
3284
- const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-DksS6q0n.js"));
3285
- const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-BYcsdUkj.js"));
3286
- const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-D5wI4-pi.js"));
3821
+ const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-B9tF5jeJ.js"));
3822
+ const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-U_myvowz.js"));
3823
+ const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-BICQA_yK.js"));
3287
3824
  const loadMockProvider = createLazyRuntimeModule(() => import("./mock-YsvBTX-7.js"));
3288
3825
  const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
3289
- const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-Bz-MNWtD.js"));
3826
+ const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-DJk-eYyf.js"));
3290
3827
  function resolveVoiceCallConsultSessionKey(call) {
3291
3828
  return resolveVoiceCallSessionKey({
3292
3829
  config: call.config,
@@ -3479,6 +4016,14 @@ async function createVoiceCallRuntime(params) {
3479
4016
  coreSession: cfg.session
3480
4017
  });
3481
4018
  const requesterSessionKey = typeof call.metadata?.requesterSessionKey === "string" ? call.metadata.requesterSessionKey : void 0;
4019
+ const modelLockParams = {
4020
+ cfg,
4021
+ agentRuntime,
4022
+ agentId,
4023
+ sessionKey,
4024
+ spawnedBy: requesterSessionKey
4025
+ };
4026
+ assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams);
3482
4027
  const fastContext = await resolveRealtimeFastContextConsult({
3483
4028
  cfg,
3484
4029
  agentId,
@@ -3487,7 +4032,10 @@ async function createVoiceCallRuntime(params) {
3487
4032
  args,
3488
4033
  logger: log
3489
4034
  });
3490
- if (fastContext.handled) return fastContext.result;
4035
+ if (fastContext.handled) {
4036
+ assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams);
4037
+ return fastContext.result;
4038
+ }
3491
4039
  const { provider: agentProvider, model } = resolveVoiceResponseModel({
3492
4040
  voiceConfig: effectiveConfig,
3493
4041
  agentRuntime
@@ -3594,4 +4142,4 @@ async function createVoiceCallRuntime(params) {
3594
4142
  }
3595
4143
  }
3596
4144
  //#endregion
3597
- export { mapVoiceToPolly as _, normalizeProviderStatus as a, cleanupTailscaleExposureRoute as c, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as d, chunkAudio as f, escapeXml as g, resolveUserPath as h, mapProviderStatusToEndReason as i, getTailscaleSelfInfo as l, resolveCallAgentId as m, normalizeProxyIp as n, getHeader as o, resolveVoiceResponseModel as p, isProviderStatusTerminal as r, resolveWebhookExposureStatus as s, createVoiceCallRuntime as t, setupTailscaleExposureRoute as u };
4145
+ export { mapVoiceToPolly as _, normalizeProviderStatus as a, resolveVoiceCallSessionKey as b, cleanupTailscaleExposureRoute as c, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as d, chunkAudio as f, escapeXml as g, resolveUserPath as h, mapProviderStatusToEndReason as i, getTailscaleSelfInfo as l, resolveCallAgentId as m, normalizeProxyIp as n, getHeader as o, resolveVoiceResponseModel as p, isProviderStatusTerminal as r, resolveWebhookExposureStatus as s, createVoiceCallRuntime as t, setupTailscaleExposureRoute as u, VoiceCallConfigSchema as v, validateProviderConfig as x, resolveVoiceCallConfig as y };