@absolutejs/voice 0.0.22-beta.16 → 0.0.22-beta.160

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 (180) hide show
  1. package/README.md +1053 -5
  2. package/dist/agent.d.ts +24 -0
  3. package/dist/agentSquadContract.d.ts +64 -0
  4. package/dist/angular/index.d.ts +11 -0
  5. package/dist/angular/index.js +3072 -1106
  6. package/dist/angular/voice-campaign-dialer-proof.service.d.ts +14 -0
  7. package/dist/angular/voice-controller.service.d.ts +1 -0
  8. package/dist/angular/voice-delivery-runtime.component.d.ts +17 -0
  9. package/dist/angular/voice-delivery-runtime.service.d.ts +16 -0
  10. package/dist/angular/voice-ops-action-center.service.d.ts +13 -0
  11. package/dist/angular/voice-ops-status.component.d.ts +15 -0
  12. package/dist/angular/voice-ops-status.service.d.ts +12 -0
  13. package/dist/angular/voice-provider-capabilities.service.d.ts +12 -0
  14. package/dist/angular/voice-routing-status.service.d.ts +11 -0
  15. package/dist/angular/voice-stream.service.d.ts +3 -0
  16. package/dist/angular/voice-trace-timeline.service.d.ts +12 -0
  17. package/dist/angular/voice-turn-latency.service.d.ts +13 -0
  18. package/dist/angular/voice-turn-quality.service.d.ts +12 -0
  19. package/dist/angular/voice-workflow-status.service.d.ts +12 -0
  20. package/dist/assistantHealth.d.ts +81 -0
  21. package/dist/audit.d.ts +128 -0
  22. package/dist/auditDeliveryRoutes.d.ts +85 -0
  23. package/dist/auditExport.d.ts +34 -0
  24. package/dist/auditRoutes.d.ts +66 -0
  25. package/dist/auditSinks.d.ts +151 -0
  26. package/dist/bargeInRoutes.d.ts +56 -0
  27. package/dist/campaign.d.ts +610 -0
  28. package/dist/campaignDialers.d.ts +90 -0
  29. package/dist/client/actions.d.ts +105 -0
  30. package/dist/client/bargeInMonitor.d.ts +7 -0
  31. package/dist/client/campaignDialerProof.d.ts +23 -0
  32. package/dist/client/connection.d.ts +3 -0
  33. package/dist/client/deliveryRuntime.d.ts +34 -0
  34. package/dist/client/deliveryRuntimeWidget.d.ts +37 -0
  35. package/dist/client/duplex.d.ts +1 -1
  36. package/dist/client/htmxBootstrap.js +747 -15
  37. package/dist/client/index.d.ts +50 -0
  38. package/dist/client/index.js +2757 -20
  39. package/dist/client/liveTurnLatency.d.ts +41 -0
  40. package/dist/client/opsActionCenter.d.ts +54 -0
  41. package/dist/client/opsActionCenterWidget.d.ts +29 -0
  42. package/dist/client/opsActionHistory.d.ts +19 -0
  43. package/dist/client/opsActionHistoryWidget.d.ts +11 -0
  44. package/dist/client/opsStatus.d.ts +19 -0
  45. package/dist/client/opsStatusWidget.d.ts +40 -0
  46. package/dist/client/providerCapabilities.d.ts +19 -0
  47. package/dist/client/providerCapabilitiesWidget.d.ts +32 -0
  48. package/dist/client/providerSimulationControls.d.ts +33 -0
  49. package/dist/client/providerSimulationControlsWidget.d.ts +20 -0
  50. package/dist/client/providerStatusWidget.d.ts +32 -0
  51. package/dist/client/routingStatus.d.ts +19 -0
  52. package/dist/client/routingStatusWidget.d.ts +28 -0
  53. package/dist/client/traceTimeline.d.ts +19 -0
  54. package/dist/client/traceTimelineWidget.d.ts +32 -0
  55. package/dist/client/turnLatency.d.ts +22 -0
  56. package/dist/client/turnLatencyWidget.d.ts +33 -0
  57. package/dist/client/turnQuality.d.ts +19 -0
  58. package/dist/client/turnQualityWidget.d.ts +32 -0
  59. package/dist/client/workflowStatus.d.ts +19 -0
  60. package/dist/dataControl.d.ts +47 -0
  61. package/dist/deliveryRuntime.d.ts +158 -0
  62. package/dist/deliverySinkRoutes.d.ts +117 -0
  63. package/dist/demoReadyRoutes.d.ts +98 -0
  64. package/dist/diagnosticsRoutes.d.ts +44 -0
  65. package/dist/evalRoutes.d.ts +213 -0
  66. package/dist/fileStore.d.ts +11 -2
  67. package/dist/handoff.d.ts +54 -0
  68. package/dist/handoffHealth.d.ts +94 -0
  69. package/dist/index.d.ts +112 -11
  70. package/dist/index.js +17792 -4316
  71. package/dist/liveLatency.d.ts +78 -0
  72. package/dist/modelAdapters.d.ts +23 -2
  73. package/dist/openaiRealtime.d.ts +27 -0
  74. package/dist/openaiTTS.d.ts +18 -0
  75. package/dist/opsActionAuditRoutes.d.ts +99 -0
  76. package/dist/opsConsoleRoutes.d.ts +80 -0
  77. package/dist/opsStatus.d.ts +76 -0
  78. package/dist/opsStatusRoutes.d.ts +33 -0
  79. package/dist/opsWebhook.d.ts +126 -0
  80. package/dist/outcomeContract.d.ts +112 -0
  81. package/dist/phoneAgent.d.ts +62 -0
  82. package/dist/phoneAgentProductionSmoke.d.ts +115 -0
  83. package/dist/postgresStore.d.ts +13 -2
  84. package/dist/productionReadiness.d.ts +335 -0
  85. package/dist/providerAdapters.d.ts +48 -0
  86. package/dist/providerCapabilities.d.ts +92 -0
  87. package/dist/providerHealth.d.ts +1 -0
  88. package/dist/providerRoutingContract.d.ts +38 -0
  89. package/dist/qualityRoutes.d.ts +76 -0
  90. package/dist/queue.d.ts +61 -0
  91. package/dist/react/VoiceDeliveryRuntime.d.ts +7 -0
  92. package/dist/react/VoiceOpsActionCenter.d.ts +5 -0
  93. package/dist/react/VoiceOpsStatus.d.ts +6 -0
  94. package/dist/react/VoiceProviderCapabilities.d.ts +6 -0
  95. package/dist/react/VoiceProviderSimulationControls.d.ts +5 -0
  96. package/dist/react/VoiceProviderStatus.d.ts +6 -0
  97. package/dist/react/VoiceRoutingStatus.d.ts +6 -0
  98. package/dist/react/VoiceTraceTimeline.d.ts +6 -0
  99. package/dist/react/VoiceTurnLatency.d.ts +6 -0
  100. package/dist/react/VoiceTurnQuality.d.ts +6 -0
  101. package/dist/react/index.d.ts +21 -0
  102. package/dist/react/index.js +3396 -33
  103. package/dist/react/useVoiceCampaignDialerProof.d.ts +10 -0
  104. package/dist/react/useVoiceController.d.ts +3 -0
  105. package/dist/react/useVoiceDeliveryRuntime.d.ts +13 -0
  106. package/dist/react/useVoiceOpsActionCenter.d.ts +11 -0
  107. package/dist/react/useVoiceOpsStatus.d.ts +8 -0
  108. package/dist/react/useVoiceProviderCapabilities.d.ts +8 -0
  109. package/dist/react/useVoiceProviderSimulationControls.d.ts +10 -0
  110. package/dist/react/useVoiceRoutingStatus.d.ts +8 -0
  111. package/dist/react/useVoiceStream.d.ts +3 -0
  112. package/dist/react/useVoiceTraceTimeline.d.ts +8 -0
  113. package/dist/react/useVoiceTurnLatency.d.ts +9 -0
  114. package/dist/react/useVoiceTurnQuality.d.ts +8 -0
  115. package/dist/react/useVoiceWorkflowStatus.d.ts +8 -0
  116. package/dist/readinessProfiles.d.ts +19 -0
  117. package/dist/reconnectContract.d.ts +87 -0
  118. package/dist/resilienceRoutes.d.ts +142 -0
  119. package/dist/sessionReplay.d.ts +185 -0
  120. package/dist/simulationSuite.d.ts +120 -0
  121. package/dist/sqliteStore.d.ts +13 -2
  122. package/dist/svelte/createVoiceCampaignDialerProof.d.ts +9 -0
  123. package/dist/svelte/createVoiceDeliveryRuntime.d.ts +11 -0
  124. package/dist/svelte/createVoiceOpsActionCenter.d.ts +10 -0
  125. package/dist/svelte/createVoiceOpsStatus.d.ts +9 -0
  126. package/dist/svelte/createVoiceProviderCapabilities.d.ts +10 -0
  127. package/dist/svelte/createVoiceProviderSimulationControls.d.ts +11 -0
  128. package/dist/svelte/createVoiceProviderStatus.d.ts +4 -2
  129. package/dist/svelte/createVoiceRoutingStatus.d.ts +10 -0
  130. package/dist/svelte/createVoiceTraceTimeline.d.ts +10 -0
  131. package/dist/svelte/createVoiceTurnLatency.d.ts +11 -0
  132. package/dist/svelte/createVoiceTurnQuality.d.ts +10 -0
  133. package/dist/svelte/createVoiceWorkflowStatus.d.ts +8 -0
  134. package/dist/svelte/index.d.ts +11 -0
  135. package/dist/svelte/index.js +2903 -439
  136. package/dist/telephony/contract.d.ts +61 -0
  137. package/dist/telephony/matrix.d.ts +97 -0
  138. package/dist/telephony/plivo.d.ts +254 -0
  139. package/dist/telephony/telnyx.d.ts +247 -0
  140. package/dist/telephony/twilio.d.ts +135 -2
  141. package/dist/telephonyOutcome.d.ts +201 -0
  142. package/dist/testing/index.d.ts +2 -0
  143. package/dist/testing/index.js +2973 -156
  144. package/dist/testing/ioProviderSimulator.d.ts +41 -0
  145. package/dist/testing/providerSimulator.d.ts +44 -0
  146. package/dist/toolContract.d.ts +130 -0
  147. package/dist/toolRuntime.d.ts +50 -0
  148. package/dist/trace.d.ts +19 -1
  149. package/dist/traceDeliveryRoutes.d.ts +86 -0
  150. package/dist/traceTimeline.d.ts +93 -0
  151. package/dist/turnLatency.d.ts +95 -0
  152. package/dist/turnQuality.d.ts +94 -0
  153. package/dist/types.d.ts +170 -4
  154. package/dist/vue/VoiceDeliveryRuntime.d.ts +30 -0
  155. package/dist/vue/VoiceOpsActionCenter.d.ts +13 -0
  156. package/dist/vue/VoiceOpsStatus.d.ts +30 -0
  157. package/dist/vue/VoiceProviderCapabilities.d.ts +51 -0
  158. package/dist/vue/VoiceProviderSimulationControls.d.ts +88 -0
  159. package/dist/vue/VoiceProviderStatus.d.ts +51 -0
  160. package/dist/vue/VoiceRoutingStatus.d.ts +51 -0
  161. package/dist/vue/VoiceTurnLatency.d.ts +69 -0
  162. package/dist/vue/VoiceTurnQuality.d.ts +51 -0
  163. package/dist/vue/index.d.ts +20 -0
  164. package/dist/vue/index.js +3327 -53
  165. package/dist/vue/useVoiceCampaignDialerProof.d.ts +11 -0
  166. package/dist/vue/useVoiceController.d.ts +2 -1
  167. package/dist/vue/useVoiceDeliveryRuntime.d.ts +13 -0
  168. package/dist/vue/useVoiceOpsActionCenter.d.ts +11 -0
  169. package/dist/vue/useVoiceOpsStatus.d.ts +9 -0
  170. package/dist/vue/useVoiceProviderCapabilities.d.ts +9 -0
  171. package/dist/vue/useVoiceProviderSimulationControls.d.ts +24 -0
  172. package/dist/vue/useVoiceProviderStatus.d.ts +1 -1
  173. package/dist/vue/useVoiceRoutingStatus.d.ts +8 -0
  174. package/dist/vue/useVoiceStream.d.ts +4 -1
  175. package/dist/vue/useVoiceTraceTimeline.d.ts +9 -0
  176. package/dist/vue/useVoiceTurnLatency.d.ts +10 -0
  177. package/dist/vue/useVoiceTurnQuality.d.ts +9 -0
  178. package/dist/vue/useVoiceWorkflowStatus.d.ts +9 -0
  179. package/dist/workflowContract.d.ts +91 -0
  180. package/package.json +1 -1
@@ -2105,6 +2105,17 @@ var serverMessageToAction = (message) => {
2105
2105
  sessionId: message.sessionId,
2106
2106
  type: "complete"
2107
2107
  };
2108
+ case "connection":
2109
+ return {
2110
+ reconnect: message.reconnect,
2111
+ type: "connection"
2112
+ };
2113
+ case "call_lifecycle":
2114
+ return {
2115
+ event: message.event,
2116
+ sessionId: message.sessionId,
2117
+ type: "call_lifecycle"
2118
+ };
2108
2119
  case "error":
2109
2120
  return {
2110
2121
  message: normalizeErrorMessage(message.message),
@@ -2120,6 +2131,17 @@ var serverMessageToAction = (message) => {
2120
2131
  transcript: message.transcript,
2121
2132
  type: "partial"
2122
2133
  };
2134
+ case "replay":
2135
+ return {
2136
+ assistantTexts: message.assistantTexts,
2137
+ call: message.call,
2138
+ partial: message.partial,
2139
+ scenarioId: message.scenarioId,
2140
+ sessionId: message.sessionId,
2141
+ status: message.status,
2142
+ turns: message.turns,
2143
+ type: "replay"
2144
+ };
2123
2145
  case "session":
2124
2146
  return {
2125
2147
  sessionId: message.sessionId,
@@ -2148,7 +2170,7 @@ var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
2148
2170
  var noop = () => {};
2149
2171
  var noopUnsubscribe = () => noop;
2150
2172
  var NOOP_CONNECTION = {
2151
- start: () => {},
2173
+ callControl: noop,
2152
2174
  close: noop,
2153
2175
  endTurn: noop,
2154
2176
  getReadyState: () => WS_CLOSED,
@@ -2156,6 +2178,7 @@ var NOOP_CONNECTION = {
2156
2178
  getSessionId: () => "",
2157
2179
  send: noop,
2158
2180
  sendAudio: noop,
2181
+ start: () => {},
2159
2182
  subscribe: noopUnsubscribe
2160
2183
  };
2161
2184
  var createSessionId = () => crypto.randomUUID();
@@ -2177,11 +2200,14 @@ var isVoiceServerMessage = (value) => {
2177
2200
  switch (value.type) {
2178
2201
  case "audio":
2179
2202
  case "assistant":
2203
+ case "call_lifecycle":
2180
2204
  case "complete":
2205
+ case "connection":
2181
2206
  case "error":
2182
2207
  case "final":
2183
2208
  case "partial":
2184
2209
  case "pong":
2210
+ case "replay":
2185
2211
  case "session":
2186
2212
  case "turn":
2187
2213
  return true;
@@ -2218,6 +2244,9 @@ var createVoiceConnection = (path, options = {}) => {
2218
2244
  sessionId: options.sessionId ?? createSessionId(),
2219
2245
  ws: null
2220
2246
  };
2247
+ const emitConnection = (reconnect) => {
2248
+ listeners.forEach((listener) => listener(reconnect));
2249
+ };
2221
2250
  const clearTimers = () => {
2222
2251
  if (state.pingInterval) {
2223
2252
  clearInterval(state.pingInterval);
@@ -2240,9 +2269,28 @@ var createVoiceConnection = (path, options = {}) => {
2240
2269
  }
2241
2270
  };
2242
2271
  const scheduleReconnect = () => {
2272
+ const nextAttemptAt = Date.now() + RECONNECT_DELAY_MS;
2243
2273
  state.reconnectAttempts += 1;
2274
+ emitConnection({
2275
+ reconnect: {
2276
+ attempts: state.reconnectAttempts,
2277
+ lastDisconnectAt: Date.now(),
2278
+ maxAttempts: maxReconnectAttempts,
2279
+ nextAttemptAt,
2280
+ status: "reconnecting"
2281
+ },
2282
+ type: "connection"
2283
+ });
2244
2284
  state.reconnectTimeout = setTimeout(() => {
2245
2285
  if (state.reconnectAttempts > maxReconnectAttempts) {
2286
+ emitConnection({
2287
+ reconnect: {
2288
+ attempts: state.reconnectAttempts,
2289
+ maxAttempts: maxReconnectAttempts,
2290
+ status: "exhausted"
2291
+ },
2292
+ type: "connection"
2293
+ });
2246
2294
  return;
2247
2295
  }
2248
2296
  connect();
@@ -2252,9 +2300,21 @@ var createVoiceConnection = (path, options = {}) => {
2252
2300
  const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId));
2253
2301
  ws.binaryType = "arraybuffer";
2254
2302
  ws.onopen = () => {
2303
+ const wasReconnecting = state.reconnectAttempts > 0;
2255
2304
  state.isConnected = true;
2256
- state.reconnectAttempts = 0;
2257
2305
  flushPendingMessages();
2306
+ if (wasReconnecting) {
2307
+ emitConnection({
2308
+ reconnect: {
2309
+ attempts: state.reconnectAttempts,
2310
+ lastResumedAt: Date.now(),
2311
+ maxAttempts: maxReconnectAttempts,
2312
+ status: "resumed"
2313
+ },
2314
+ type: "connection"
2315
+ });
2316
+ state.reconnectAttempts = 0;
2317
+ }
2258
2318
  listeners.forEach((listener) => listener({
2259
2319
  scenarioId: state.scenarioId ?? undefined,
2260
2320
  sessionId: state.sessionId,
@@ -2284,6 +2344,16 @@ var createVoiceConnection = (path, options = {}) => {
2284
2344
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && state.reconnectAttempts < maxReconnectAttempts;
2285
2345
  if (reconnectable) {
2286
2346
  scheduleReconnect();
2347
+ } else if (shouldReconnect && event.code !== WS_NORMAL_CLOSURE) {
2348
+ emitConnection({
2349
+ reconnect: {
2350
+ attempts: state.reconnectAttempts,
2351
+ lastDisconnectAt: Date.now(),
2352
+ maxAttempts: maxReconnectAttempts,
2353
+ status: "exhausted"
2354
+ },
2355
+ type: "connection"
2356
+ });
2287
2357
  }
2288
2358
  };
2289
2359
  state.ws = ws;
@@ -2317,6 +2387,12 @@ var createVoiceConnection = (path, options = {}) => {
2317
2387
  const endTurn = () => {
2318
2388
  send({ type: "end_turn" });
2319
2389
  };
2390
+ const callControl = (message) => {
2391
+ send({
2392
+ ...message,
2393
+ type: "call_control"
2394
+ });
2395
+ };
2320
2396
  const close = () => {
2321
2397
  clearTimers();
2322
2398
  if (state.ws) {
@@ -2334,7 +2410,7 @@ var createVoiceConnection = (path, options = {}) => {
2334
2410
  };
2335
2411
  connect();
2336
2412
  return {
2337
- start,
2413
+ callControl,
2338
2414
  close,
2339
2415
  endTurn,
2340
2416
  getReadyState: () => state.ws?.readyState ?? WS_CLOSED,
@@ -2342,18 +2418,26 @@ var createVoiceConnection = (path, options = {}) => {
2342
2418
  getSessionId: () => state.sessionId,
2343
2419
  send,
2344
2420
  sendAudio,
2421
+ start,
2345
2422
  subscribe
2346
2423
  };
2347
2424
  };
2348
2425
 
2349
2426
  // src/client/store.ts
2427
+ var createInitialReconnectState = () => ({
2428
+ attempts: 0,
2429
+ maxAttempts: 0,
2430
+ status: "idle"
2431
+ });
2350
2432
  var createInitialState2 = () => ({
2351
2433
  assistantAudio: [],
2352
2434
  assistantTexts: [],
2435
+ call: null,
2353
2436
  error: null,
2354
2437
  isConnected: false,
2355
2438
  scenarioId: null,
2356
2439
  partial: "",
2440
+ reconnect: createInitialReconnectState(),
2357
2441
  sessionId: null,
2358
2442
  status: "idle",
2359
2443
  turns: []
@@ -2393,10 +2477,36 @@ var createVoiceStreamStore = () => {
2393
2477
  status: "completed"
2394
2478
  };
2395
2479
  break;
2480
+ case "call_lifecycle":
2481
+ state = {
2482
+ ...state,
2483
+ call: {
2484
+ ...state.call,
2485
+ disposition: action.event.type === "end" ? action.event.disposition : state.call?.disposition,
2486
+ endedAt: action.event.type === "end" ? action.event.at : state.call?.endedAt,
2487
+ events: [...state.call?.events ?? [], action.event],
2488
+ lastEventAt: action.event.at,
2489
+ startedAt: state.call?.startedAt ?? action.event.at
2490
+ },
2491
+ sessionId: action.sessionId
2492
+ };
2493
+ break;
2396
2494
  case "connected":
2397
2495
  state = {
2398
2496
  ...state,
2399
- isConnected: true
2497
+ isConnected: true,
2498
+ reconnect: state.reconnect.status === "reconnecting" ? {
2499
+ ...state.reconnect,
2500
+ lastResumedAt: Date.now(),
2501
+ nextAttemptAt: undefined,
2502
+ status: "resumed"
2503
+ } : state.reconnect
2504
+ };
2505
+ break;
2506
+ case "connection":
2507
+ state = {
2508
+ ...state,
2509
+ reconnect: action.reconnect
2400
2510
  };
2401
2511
  break;
2402
2512
  case "disconnected":
@@ -2424,6 +2534,26 @@ var createVoiceStreamStore = () => {
2424
2534
  partial: action.transcript.text
2425
2535
  };
2426
2536
  break;
2537
+ case "replay":
2538
+ state = {
2539
+ ...state,
2540
+ assistantTexts: [...action.assistantTexts],
2541
+ call: action.call ?? null,
2542
+ error: null,
2543
+ isConnected: action.status === "active",
2544
+ partial: action.partial,
2545
+ reconnect: state.reconnect.status === "reconnecting" ? {
2546
+ ...state.reconnect,
2547
+ lastResumedAt: Date.now(),
2548
+ nextAttemptAt: undefined,
2549
+ status: "resumed"
2550
+ } : state.reconnect,
2551
+ scenarioId: action.scenarioId ?? state.scenarioId,
2552
+ sessionId: action.sessionId,
2553
+ status: action.status,
2554
+ turns: [...action.turns]
2555
+ };
2556
+ break;
2427
2557
  case "session":
2428
2558
  state = {
2429
2559
  ...state,
@@ -2471,14 +2601,41 @@ var createVoiceStream = (path, options = {}) => {
2471
2601
  const notify = () => {
2472
2602
  subscribers.forEach((subscriber) => subscriber());
2473
2603
  };
2604
+ const reportReconnect = () => {
2605
+ if (!options.reconnectReportPath || typeof fetch === "undefined") {
2606
+ return;
2607
+ }
2608
+ const snapshot = store.getSnapshot();
2609
+ const body = JSON.stringify({
2610
+ at: Date.now(),
2611
+ reconnect: snapshot.reconnect,
2612
+ scenarioId: snapshot.scenarioId,
2613
+ sessionId: connection.getSessionId(),
2614
+ turnIds: snapshot.turns.map((turn) => turn.id)
2615
+ });
2616
+ fetch(options.reconnectReportPath, {
2617
+ body,
2618
+ headers: {
2619
+ "Content-Type": "application/json"
2620
+ },
2621
+ keepalive: true,
2622
+ method: "POST"
2623
+ }).catch(() => {});
2624
+ };
2474
2625
  const unsubscribeConnection = connection.subscribe((message) => {
2475
2626
  const action = serverMessageToAction(message);
2476
2627
  if (action) {
2477
2628
  store.dispatch(action);
2629
+ if (message.type === "connection") {
2630
+ reportReconnect();
2631
+ }
2478
2632
  notify();
2479
2633
  }
2480
2634
  });
2481
2635
  return {
2636
+ callControl(message) {
2637
+ connection.callControl(message);
2638
+ },
2482
2639
  close() {
2483
2640
  unsubscribeConnection();
2484
2641
  connection.close();
@@ -2507,6 +2664,9 @@ var createVoiceStream = (path, options = {}) => {
2507
2664
  get partial() {
2508
2665
  return store.getSnapshot().partial;
2509
2666
  },
2667
+ get reconnect() {
2668
+ return store.getSnapshot().reconnect;
2669
+ },
2510
2670
  get sessionId() {
2511
2671
  return connection.getSessionId();
2512
2672
  },
@@ -2522,6 +2682,9 @@ var createVoiceStream = (path, options = {}) => {
2522
2682
  get assistantAudio() {
2523
2683
  return store.getSnapshot().assistantAudio;
2524
2684
  },
2685
+ get call() {
2686
+ return store.getSnapshot().call;
2687
+ },
2525
2688
  sendAudio(audio) {
2526
2689
  connection.sendAudio(audio);
2527
2690
  },
@@ -2854,10 +3017,12 @@ var resolveVoiceRuntimePreset = (name = "default") => {
2854
3017
  var createInitialState3 = (stream) => ({
2855
3018
  assistantAudio: [...stream.assistantAudio],
2856
3019
  assistantTexts: [...stream.assistantTexts],
3020
+ call: stream.call,
2857
3021
  error: stream.error,
2858
3022
  isConnected: stream.isConnected,
2859
3023
  isRecording: false,
2860
3024
  partial: stream.partial,
3025
+ reconnect: stream.reconnect,
2861
3026
  recordingError: null,
2862
3027
  sessionId: stream.sessionId,
2863
3028
  scenarioId: stream.scenarioId,
@@ -2883,9 +3048,11 @@ var createVoiceController = (path, options = {}) => {
2883
3048
  ...state,
2884
3049
  assistantAudio: [...stream.assistantAudio],
2885
3050
  assistantTexts: [...stream.assistantTexts],
3051
+ call: stream.call,
2886
3052
  error: stream.error,
2887
3053
  isConnected: stream.isConnected,
2888
3054
  partial: stream.partial,
3055
+ reconnect: stream.reconnect,
2889
3056
  sessionId: stream.sessionId,
2890
3057
  scenarioId: stream.scenarioId,
2891
3058
  status: stream.status,
@@ -2910,7 +3077,13 @@ var createVoiceController = (path, options = {}) => {
2910
3077
  capture = createMicrophoneCapture({
2911
3078
  channelCount: options.capture?.channelCount ?? preset.capture.channelCount,
2912
3079
  onLevel: options.capture?.onLevel,
2913
- onAudio: (audio) => stream.sendAudio(audio),
3080
+ onAudio: (audio) => {
3081
+ if (options.capture?.onAudio) {
3082
+ options.capture.onAudio(audio, stream.sendAudio);
3083
+ return;
3084
+ }
3085
+ stream.sendAudio(audio);
3086
+ },
2914
3087
  sampleRateHz: options.capture?.sampleRateHz ?? preset.capture.sampleRateHz
2915
3088
  });
2916
3089
  return capture;
@@ -2960,6 +3133,7 @@ var createVoiceController = (path, options = {}) => {
2960
3133
  bindHTMX(bindingOptions) {
2961
3134
  return bindVoiceHTMX(stream, bindingOptions);
2962
3135
  },
3136
+ callControl: (message) => stream.callControl(message),
2963
3137
  close,
2964
3138
  endTurn: () => stream.endTurn(),
2965
3139
  get error() {
@@ -2979,6 +3153,9 @@ var createVoiceController = (path, options = {}) => {
2979
3153
  get recordingError() {
2980
3154
  return state.recordingError;
2981
3155
  },
3156
+ get reconnect() {
3157
+ return state.reconnect;
3158
+ },
2982
3159
  sendAudio: (audio) => stream.sendAudio(audio),
2983
3160
  get sessionId() {
2984
3161
  return state.sessionId;
@@ -3012,6 +3189,9 @@ var createVoiceController = (path, options = {}) => {
3012
3189
  },
3013
3190
  get assistantAudio() {
3014
3191
  return state.assistantAudio;
3192
+ },
3193
+ get call() {
3194
+ return state.call;
3015
3195
  }
3016
3196
  };
3017
3197
  };
@@ -3021,11 +3201,26 @@ var DEFAULT_INTERRUPT_THRESHOLD = 0.08;
3021
3201
  var shouldInterruptForLevel = (level, options = {}) => (options.enabled ?? true) && level >= (options.interruptThreshold ?? DEFAULT_INTERRUPT_THRESHOLD);
3022
3202
  var bindVoiceBargeIn = (controller, player, options = {}) => {
3023
3203
  let lastPartial = controller.partial;
3024
- const interruptIfPlaying = () => {
3204
+ const interruptIfPlaying = (reason) => {
3025
3205
  if (!player.isPlaying || options.enabled === false) {
3206
+ options.monitor?.recordSkipped({
3207
+ reason,
3208
+ sessionId: controller.sessionId
3209
+ });
3026
3210
  return;
3027
3211
  }
3028
- player.interrupt();
3212
+ options.monitor?.recordRequested({
3213
+ reason,
3214
+ sessionId: controller.sessionId
3215
+ });
3216
+ player.interrupt().then(() => {
3217
+ options.monitor?.recordStopped({
3218
+ latencyMs: player.lastInterruptLatencyMs,
3219
+ playbackStopLatencyMs: player.lastPlaybackStopLatencyMs,
3220
+ reason,
3221
+ sessionId: controller.sessionId
3222
+ });
3223
+ });
3029
3224
  };
3030
3225
  const unsubscribe = controller.subscribe(() => {
3031
3226
  if (options.interruptOnPartial === false) {
@@ -3033,7 +3228,7 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
3033
3228
  return;
3034
3229
  }
3035
3230
  if (!lastPartial && controller.partial) {
3036
- interruptIfPlaying();
3231
+ interruptIfPlaying("partial-transcript");
3037
3232
  }
3038
3233
  lastPartial = controller.partial;
3039
3234
  });
@@ -3043,11 +3238,11 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
3043
3238
  },
3044
3239
  handleLevel: (level) => {
3045
3240
  if (shouldInterruptForLevel(level, options)) {
3046
- interruptIfPlaying();
3241
+ interruptIfPlaying("input-level");
3047
3242
  }
3048
3243
  },
3049
3244
  sendAudio: (audio) => {
3050
- interruptIfPlaying();
3245
+ interruptIfPlaying("manual-audio");
3051
3246
  controller.sendAudio(audio);
3052
3247
  }
3053
3248
  };
@@ -3077,7 +3272,17 @@ var createVoiceDuplexController = (path, options = {}) => {
3077
3272
  audioPlayer,
3078
3273
  close,
3079
3274
  interruptAssistant: async () => {
3275
+ options.bargeIn?.monitor?.recordRequested({
3276
+ reason: "manual-interrupt",
3277
+ sessionId: controller.sessionId
3278
+ });
3080
3279
  await audioPlayer.interrupt();
3280
+ options.bargeIn?.monitor?.recordStopped({
3281
+ latencyMs: audioPlayer.lastInterruptLatencyMs,
3282
+ playbackStopLatencyMs: audioPlayer.lastPlaybackStopLatencyMs,
3283
+ reason: "manual-interrupt",
3284
+ sessionId: controller.sessionId
3285
+ });
3081
3286
  },
3082
3287
  sendAudio: (audio) => {
3083
3288
  bargeInBinding?.sendAudio(audio);
@@ -3468,139 +3673,1538 @@ var loadVoiceTestFixtures = async (fixtureDirectory) => {
3468
3673
  }
3469
3674
  return fixtures;
3470
3675
  };
3471
- // src/store.ts
3472
- var createId = () => crypto.randomUUID();
3473
- var createVoiceSessionRecord = (id, scenarioId) => ({
3474
- committedTurnIds: [],
3475
- createdAt: Date.now(),
3476
- currentTurn: {
3477
- finalText: "",
3478
- lastSpeechAt: undefined,
3479
- lastTranscriptAt: undefined,
3480
- partialEndedAt: undefined,
3481
- partialStartedAt: undefined,
3482
- partialText: "",
3483
- silenceStartedAt: undefined,
3484
- transcripts: []
3485
- },
3486
- id,
3487
- scenarioId,
3488
- reconnect: { attempts: 0 },
3489
- status: "active",
3490
- transcripts: [],
3491
- turns: [],
3492
- lastCommittedTurn: {
3493
- committedAt: 0,
3494
- signature: "",
3495
- text: "",
3496
- transcriptIds: []
3676
+ // src/testing/ioProviderSimulator.ts
3677
+ var defaultFailureMessage = (input) => `Simulated ${input.provider} ${input.kind.toUpperCase()} ${input.operation} failure.`;
3678
+ var resolveRecoveryElapsedMs = (value, provider) => {
3679
+ if (typeof value === "number") {
3680
+ return value;
3497
3681
  }
3682
+ return value?.[provider] ?? 25;
3683
+ };
3684
+ var createHealth = (input) => ({
3685
+ consecutiveFailures: input.status === "healthy" ? 0 : 1,
3686
+ lastFailureAt: input.status === "healthy" ? undefined : input.now,
3687
+ provider: input.provider,
3688
+ status: input.status,
3689
+ suppressedUntil: input.suppressedUntil
3498
3690
  });
3499
- var resetVoiceSessionRecord = (id, existing, scenarioId) => ({
3500
- ...createVoiceSessionRecord(id, scenarioId),
3501
- metadata: existing?.metadata
3502
- });
3503
- var toVoiceSessionSummary = (session) => ({
3504
- createdAt: session.createdAt,
3505
- id: session.id,
3506
- lastActivityAt: session.lastActivityAt,
3507
- status: session.status,
3508
- turnCount: session.turns.length
3509
- });
3510
-
3511
- // src/memoryStore.ts
3512
- var createVoiceMemoryStore = () => {
3513
- const sessions = new Map;
3514
- const get = async (id) => sessions.get(id);
3515
- const getOrCreate = async (id) => {
3516
- let session = sessions.get(id);
3517
- if (!session) {
3518
- session = createVoiceSessionRecord(id);
3519
- sessions.set(id, session);
3691
+ var resolveFallback = async (options, provider) => {
3692
+ const configured = typeof options.fallback === "function" ? await options.fallback(provider) : options.fallback;
3693
+ return (configured ?? options.providers).find((candidate) => candidate !== provider);
3694
+ };
3695
+ var createVoiceIOProviderFailureSimulator = (options) => {
3696
+ if (options.providers.length === 0) {
3697
+ throw new Error("At least one provider is required.");
3698
+ }
3699
+ const now = options.now ?? Date.now;
3700
+ const operation = options.operation ?? "open";
3701
+ const cooldownMs = Math.max(0, options.cooldownMs ?? 30000);
3702
+ const emit = async (event, input) => {
3703
+ await options.onProviderEvent?.(event, input);
3704
+ };
3705
+ const run = async (provider, mode) => {
3706
+ if (!options.providers.includes(provider)) {
3707
+ throw new Error(`${provider} is not configured for simulation.`);
3708
+ }
3709
+ const startedAt = now();
3710
+ const sessionId = options.sessionId?.({ mode, now: startedAt, provider }) ?? `${options.kind}-provider-sim-${startedAt}`;
3711
+ if (mode === "recovery") {
3712
+ await emit({
3713
+ at: startedAt,
3714
+ attempt: 0,
3715
+ elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, provider),
3716
+ kind: options.kind,
3717
+ latencyBudgetMs: options.latencyBudgets?.[provider],
3718
+ operation,
3719
+ provider,
3720
+ providerHealth: createHealth({
3721
+ now: startedAt,
3722
+ provider,
3723
+ status: "healthy"
3724
+ }),
3725
+ selectedProvider: provider,
3726
+ status: "success"
3727
+ }, { mode, provider, sessionId });
3728
+ return {
3729
+ mode,
3730
+ provider,
3731
+ sessionId,
3732
+ status: "simulated"
3733
+ };
3520
3734
  }
3521
- return session;
3522
- };
3523
- const set = async (id, value) => {
3524
- sessions.set(id, value);
3735
+ const fallbackProvider = await resolveFallback(options, provider);
3736
+ const suppressedUntil = startedAt + cooldownMs;
3737
+ await emit({
3738
+ at: startedAt,
3739
+ attempt: 0,
3740
+ elapsedMs: options.failureElapsedMs ?? 10,
3741
+ error: (options.failureMessage ?? defaultFailureMessage)({
3742
+ kind: options.kind,
3743
+ operation,
3744
+ provider
3745
+ }),
3746
+ fallbackProvider,
3747
+ kind: options.kind,
3748
+ latencyBudgetMs: options.latencyBudgets?.[provider],
3749
+ operation,
3750
+ provider,
3751
+ providerHealth: createHealth({
3752
+ now: startedAt,
3753
+ provider,
3754
+ status: "suppressed",
3755
+ suppressedUntil
3756
+ }),
3757
+ selectedProvider: provider,
3758
+ status: "error",
3759
+ suppressedUntil
3760
+ }, { mode, provider, sessionId });
3761
+ if (fallbackProvider) {
3762
+ await emit({
3763
+ at: startedAt + 1,
3764
+ attempt: 1,
3765
+ elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, fallbackProvider),
3766
+ fallbackProvider,
3767
+ kind: options.kind,
3768
+ latencyBudgetMs: options.latencyBudgets?.[fallbackProvider],
3769
+ operation,
3770
+ provider: fallbackProvider,
3771
+ providerHealth: createHealth({
3772
+ now: startedAt + 1,
3773
+ provider: fallbackProvider,
3774
+ status: "healthy"
3775
+ }),
3776
+ selectedProvider: provider,
3777
+ status: "fallback"
3778
+ }, { mode, provider, sessionId });
3779
+ }
3780
+ return {
3781
+ fallbackProvider,
3782
+ mode,
3783
+ provider,
3784
+ sessionId,
3785
+ status: "simulated",
3786
+ suppressedUntil
3787
+ };
3525
3788
  };
3526
- const list = async () => Array.from(sessions.values()).map((session) => toVoiceSessionSummary(session)).sort((first, second) => (second.lastActivityAt ?? second.createdAt) - (first.lastActivityAt ?? first.createdAt));
3527
- const remove = async (id) => {
3528
- sessions.delete(id);
3789
+ return {
3790
+ run
3529
3791
  };
3530
- return { get, getOrCreate, list, remove, set };
3531
3792
  };
3532
-
3533
- // src/session.ts
3534
- import { Buffer } from "buffer";
3535
-
3536
- // src/logger.ts
3537
- var noop2 = () => {};
3538
- var createNoopLogger = () => ({
3539
- debug: noop2,
3540
- error: noop2,
3541
- info: noop2,
3542
- warn: noop2
3543
- });
3544
- var resolveLogger = (logger) => ({
3545
- ...createNoopLogger(),
3546
- ...logger
3547
- });
3548
-
3549
- // src/session.ts
3550
- var DEFAULT_RECONNECT_TIMEOUT = 30000;
3551
- var DEFAULT_MAX_RECONNECT_ATTEMPTS2 = 10;
3552
- var DEFAULT_TRANSCRIPT_STABILITY_MS = 450;
3553
- var DEFAULT_FALLBACK_REPLAY_MS = 8000;
3554
- var DEFAULT_FALLBACK_SETTLE_MS = 220;
3555
- var DEFAULT_FALLBACK_COMPLETION_TIMEOUT_MS = 2500;
3556
- var DEFAULT_FALLBACK_CONFIDENCE_THRESHOLD = 0.6;
3557
- var DEFAULT_FALLBACK_MIN_TEXT_LENGTH = 2;
3558
- var DEFAULT_FALLBACK_MAX_ATTEMPTS_PER_TURN = 1;
3559
- var DEFAULT_DUPLICATE_TURN_WINDOW_MS = 5000;
3560
- var FALLBACK_CONFIDENCE_SELECTION_DELTA = 0.05;
3561
- var FALLBACK_WORD_COUNT_SELECTION_MARGIN_RATIO = 0.12;
3562
- var EXTENDED_VENDOR_COMMIT_SILENCE_THRESHOLD_MS = 200;
3563
- var MAX_VENDOR_COMMIT_GRACE_MS = 1200;
3564
- var DEFAULT_FORMAT = {
3565
- channels: 1,
3566
- container: "raw",
3567
- encoding: "pcm_s16le",
3568
- sampleRateHz: 16000
3793
+ // src/modelAdapters.ts
3794
+ var resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
3795
+ switch (preset) {
3796
+ case "balanced":
3797
+ return {
3798
+ fallbackMode: "provider-error",
3799
+ strategy: "balanced",
3800
+ weights: {
3801
+ cost: 1,
3802
+ latencyMs: 0.005,
3803
+ priority: 1,
3804
+ quality: 10,
3805
+ ...options.weights
3806
+ },
3807
+ ...options
3808
+ };
3809
+ case "cost-cap":
3810
+ return {
3811
+ fallbackMode: "provider-error",
3812
+ strategy: "prefer-cheapest",
3813
+ ...options
3814
+ };
3815
+ case "cost-first":
3816
+ return {
3817
+ fallbackMode: "provider-error",
3818
+ strategy: "prefer-cheapest",
3819
+ ...options
3820
+ };
3821
+ case "latency-first":
3822
+ return {
3823
+ fallbackMode: "provider-error",
3824
+ strategy: "prefer-fastest",
3825
+ ...options
3826
+ };
3827
+ case "quality-first":
3828
+ return {
3829
+ fallbackMode: "provider-error",
3830
+ strategy: "quality-first",
3831
+ ...options
3832
+ };
3833
+ }
3569
3834
  };
3570
- var toError = (value) => value instanceof Error ? value : new Error(String(value));
3571
- var createEmptyCurrentTurn = () => ({
3572
- finalText: "",
3573
- lastSpeechAt: undefined,
3574
- lastTranscriptAt: undefined,
3575
- partialEndedAt: undefined,
3576
- partialStartedAt: undefined,
3577
- partialText: "",
3578
- silenceStartedAt: undefined,
3579
- transcripts: []
3835
+ var OUTPUT_SCHEMA = {
3836
+ additionalProperties: false,
3837
+ properties: {
3838
+ assistantText: {
3839
+ type: "string"
3840
+ },
3841
+ complete: {
3842
+ type: "boolean"
3843
+ },
3844
+ escalate: {
3845
+ additionalProperties: false,
3846
+ properties: {
3847
+ metadata: {
3848
+ additionalProperties: true,
3849
+ type: "object"
3850
+ },
3851
+ reason: {
3852
+ type: "string"
3853
+ }
3854
+ },
3855
+ required: ["reason"],
3856
+ type: "object"
3857
+ },
3858
+ noAnswer: {
3859
+ additionalProperties: false,
3860
+ properties: {
3861
+ metadata: {
3862
+ additionalProperties: true,
3863
+ type: "object"
3864
+ }
3865
+ },
3866
+ type: "object"
3867
+ },
3868
+ result: {
3869
+ additionalProperties: true,
3870
+ type: "object"
3871
+ },
3872
+ transfer: {
3873
+ additionalProperties: false,
3874
+ properties: {
3875
+ metadata: {
3876
+ additionalProperties: true,
3877
+ type: "object"
3878
+ },
3879
+ reason: {
3880
+ type: "string"
3881
+ },
3882
+ target: {
3883
+ type: "string"
3884
+ }
3885
+ },
3886
+ required: ["target"],
3887
+ type: "object"
3888
+ },
3889
+ voicemail: {
3890
+ additionalProperties: false,
3891
+ properties: {
3892
+ metadata: {
3893
+ additionalProperties: true,
3894
+ type: "object"
3895
+ }
3896
+ },
3897
+ type: "object"
3898
+ }
3899
+ },
3900
+ type: "object"
3901
+ };
3902
+ var ROUTE_RESULT_INSTRUCTION = "Return only a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools. Only set transfer, escalate, voicemail, or noAnswer when the user explicitly asks for that lifecycle outcome or a tool result says that exact outcome. Do not infer voicemail from generic words like voice, voice app, or voice integration.";
3903
+ var stripJSONCodeFence = (value) => {
3904
+ const trimmed = value.trim();
3905
+ const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
3906
+ return match?.[1]?.trim() ?? value;
3907
+ };
3908
+ var parseJSON = (value) => {
3909
+ try {
3910
+ const parsed = JSON.parse(stripJSONCodeFence(value));
3911
+ return parsed && typeof parsed === "object" ? parsed : {};
3912
+ } catch {
3913
+ return {
3914
+ assistantText: value
3915
+ };
3916
+ }
3917
+ };
3918
+ var parseJSONValue = (value) => {
3919
+ try {
3920
+ return JSON.parse(value);
3921
+ } catch {
3922
+ return value;
3923
+ }
3924
+ };
3925
+
3926
+ class VoiceProviderTimeoutError extends Error {
3927
+ provider;
3928
+ timeoutMs;
3929
+ constructor(provider, timeoutMs) {
3930
+ super(`Voice provider ${provider} exceeded ${timeoutMs}ms latency budget.`);
3931
+ this.name = "VoiceProviderTimeoutError";
3932
+ this.provider = provider;
3933
+ this.timeoutMs = timeoutMs;
3934
+ }
3935
+ }
3936
+ var getMessageToolCalls = (message) => {
3937
+ const toolCalls = message.metadata?.toolCalls;
3938
+ return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
3939
+ };
3940
+ var createHTTPError = (provider, response) => new Error(`${provider} voice assistant model failed: HTTP ${response.status}`);
3941
+ var sleep = (ms) => new Promise((resolve2) => {
3942
+ setTimeout(resolve2, ms);
3580
3943
  });
3581
- var cloneTranscript = (transcript) => ({ ...transcript });
3582
- var encodeBase64 = (chunk) => Buffer.from(chunk).toString("base64");
3583
- var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
3584
- var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
3585
- var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
3586
- var getBufferedAudioDurationMs = (chunks) => chunks.reduce((total, chunk) => total + getAudioChunkDurationMs(chunk), 0);
3587
- var calculateMeanConfidence = (transcripts) => {
3588
- let sum = 0;
3589
- let total = 0;
3590
- for (const transcript of transcripts) {
3591
- if (typeof transcript.confidence === "number") {
3592
- sum += transcript.confidence;
3593
- total += 1;
3944
+ var errorMessage = (error) => error instanceof Error ? error.message : String(error);
3945
+ var defaultIsRateLimitError = (error) => /(\b429\b|rate limit|quota|too many requests)/i.test(errorMessage(error));
3946
+ var normalizeRouteOutput = (output) => {
3947
+ const result = {};
3948
+ if (typeof output.assistantText === "string") {
3949
+ result.assistantText = output.assistantText;
3950
+ }
3951
+ if (typeof output.complete === "boolean") {
3952
+ result.complete = output.complete;
3953
+ }
3954
+ if (output.result !== undefined) {
3955
+ result.result = output.result;
3956
+ }
3957
+ if (output.transfer && typeof output.transfer === "object") {
3958
+ const transfer = output.transfer;
3959
+ if (typeof transfer.target === "string") {
3960
+ result.transfer = {
3961
+ metadata: transfer.metadata && typeof transfer.metadata === "object" ? transfer.metadata : undefined,
3962
+ reason: typeof transfer.reason === "string" ? transfer.reason : undefined,
3963
+ target: transfer.target
3964
+ };
3594
3965
  }
3595
3966
  }
3596
- if (total === 0) {
3597
- return 0;
3967
+ if (output.escalate && typeof output.escalate === "object") {
3968
+ const escalate = output.escalate;
3969
+ if (typeof escalate.reason === "string") {
3970
+ result.escalate = {
3971
+ metadata: escalate.metadata && typeof escalate.metadata === "object" ? escalate.metadata : undefined,
3972
+ reason: escalate.reason
3973
+ };
3974
+ }
3598
3975
  }
3599
- return sum / total;
3976
+ if (output.voicemail && typeof output.voicemail === "object") {
3977
+ const voicemail = output.voicemail;
3978
+ result.voicemail = {
3979
+ metadata: voicemail.metadata && typeof voicemail.metadata === "object" ? voicemail.metadata : undefined
3980
+ };
3981
+ }
3982
+ if (output.noAnswer && typeof output.noAnswer === "object") {
3983
+ const noAnswer = output.noAnswer;
3984
+ result.noAnswer = {
3985
+ metadata: noAnswer.metadata && typeof noAnswer.metadata === "object" ? noAnswer.metadata : undefined
3986
+ };
3987
+ }
3988
+ return result;
3600
3989
  };
3601
- var createTurnQuality = (transcripts, source, fallbackUsed, fallbackDiagnostics, correctionDiagnostics, costEstimate) => {
3602
- const sampledTranscripts = transcripts.filter((transcript) => typeof transcript.confidence === "number");
3603
- const confidenceSampleCount = sampledTranscripts.length;
3990
+ var createJSONVoiceAssistantModel = (options) => ({
3991
+ generate: async (input) => {
3992
+ const output = await options.generate(input);
3993
+ if ("assistantText" in output || "toolCalls" in output || "complete" in output || "transfer" in output || "escalate" in output) {
3994
+ return output;
3995
+ }
3996
+ return options.mapOutput?.(output) ?? normalizeRouteOutput(output);
3997
+ }
3998
+ });
3999
+ var createVoiceProviderRouter = (options) => {
4000
+ const providerIds = Object.keys(options.providers);
4001
+ const firstProvider = providerIds[0];
4002
+ const policy = typeof options.policy === "string" ? options.policy === "balanced" || options.policy === "cost-cap" || options.policy === "cost-first" || options.policy === "latency-first" || options.policy === "quality-first" ? resolveVoiceProviderRoutingPolicyPreset(options.policy) : {
4003
+ strategy: options.policy
4004
+ } : options.policy;
4005
+ const strategy = policy?.strategy ?? "prefer-selected";
4006
+ const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? "provider-error";
4007
+ const healthOptions = typeof options.providerHealth === "object" ? options.providerHealth : options.providerHealth ? {} : undefined;
4008
+ const healthState = new Map;
4009
+ const now = () => healthOptions?.now?.() ?? Date.now();
4010
+ const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
4011
+ const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
4012
+ const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
4013
+ const getProviderTimeoutMs = (provider) => {
4014
+ const timeoutMs = options.providerProfiles?.[provider]?.timeoutMs ?? options.timeoutMs;
4015
+ return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
4016
+ };
4017
+ const getHealth = (provider) => {
4018
+ const existing = healthState.get(provider);
4019
+ if (existing) {
4020
+ return existing;
4021
+ }
4022
+ const next = {
4023
+ consecutiveFailures: 0,
4024
+ provider,
4025
+ status: "healthy"
4026
+ };
4027
+ healthState.set(provider, next);
4028
+ return next;
4029
+ };
4030
+ const cloneHealth = (provider) => {
4031
+ if (!healthOptions) {
4032
+ return;
4033
+ }
4034
+ return {
4035
+ ...getHealth(provider)
4036
+ };
4037
+ };
4038
+ const getSuppressionRemainingMs = (provider) => {
4039
+ if (!healthOptions) {
4040
+ return;
4041
+ }
4042
+ const suppressedUntil = getHealth(provider).suppressedUntil;
4043
+ return typeof suppressedUntil === "number" ? Math.max(0, suppressedUntil - now()) : undefined;
4044
+ };
4045
+ const isSuppressed = (provider) => {
4046
+ if (!healthOptions) {
4047
+ return false;
4048
+ }
4049
+ const health = getHealth(provider);
4050
+ return typeof health.suppressedUntil === "number" && health.suppressedUntil > now();
4051
+ };
4052
+ const recordProviderSuccess = (provider) => {
4053
+ if (!healthOptions) {
4054
+ return;
4055
+ }
4056
+ const health = getHealth(provider);
4057
+ health.consecutiveFailures = 0;
4058
+ health.status = "healthy";
4059
+ health.suppressedUntil = undefined;
4060
+ return cloneHealth(provider);
4061
+ };
4062
+ const recordProviderError = (provider, isProviderError, rateLimited) => {
4063
+ if (!healthOptions || !isProviderError) {
4064
+ return cloneHealth(provider);
4065
+ }
4066
+ const currentTime = now();
4067
+ const health = getHealth(provider);
4068
+ health.consecutiveFailures += 1;
4069
+ health.lastFailureAt = currentTime;
4070
+ if (rateLimited) {
4071
+ health.lastRateLimitedAt = currentTime;
4072
+ }
4073
+ if (rateLimited || health.consecutiveFailures >= failureThreshold) {
4074
+ health.status = "suppressed";
4075
+ health.suppressedUntil = currentTime + (rateLimited ? rateLimitCooldownMs : cooldownMs);
4076
+ }
4077
+ return cloneHealth(provider);
4078
+ };
4079
+ const resolveAllowedProviders = async (input) => {
4080
+ const allowProviders = policy?.allowProviders ?? options.allowProviders;
4081
+ const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
4082
+ return new Set(allowed ?? providerIds);
4083
+ };
4084
+ const passesBudgetFilters = (provider) => {
4085
+ const profile = options.providerProfiles?.[provider];
4086
+ if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
4087
+ return false;
4088
+ }
4089
+ if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
4090
+ return false;
4091
+ }
4092
+ if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
4093
+ return false;
4094
+ }
4095
+ return true;
4096
+ };
4097
+ const getBalancedScore = (provider) => {
4098
+ const profile = options.providerProfiles?.[provider];
4099
+ if (policy?.scoreProvider) {
4100
+ return policy.scoreProvider(provider, profile);
4101
+ }
4102
+ const weights = policy?.weights ?? {};
4103
+ return (profile?.cost ?? Number.MAX_SAFE_INTEGER) * (weights.cost ?? 1) + (profile?.latencyMs ?? Number.MAX_SAFE_INTEGER) * (weights.latencyMs ?? 0.005) + (profile?.priority ?? 0) * (weights.priority ?? 1) - (profile?.quality ?? 0) * (weights.quality ?? 10);
4104
+ };
4105
+ const sortProviders = (providers) => {
4106
+ if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
4107
+ return providers;
4108
+ }
4109
+ return [...providers].sort((left, right) => {
4110
+ const leftProfile = options.providerProfiles?.[left];
4111
+ const rightProfile = options.providerProfiles?.[right];
4112
+ if (strategy === "quality-first") {
4113
+ return (rightProfile?.quality ?? Number.MIN_SAFE_INTEGER) - (leftProfile?.quality ?? Number.MIN_SAFE_INTEGER) || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.cost ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.cost ?? Number.MAX_SAFE_INTEGER);
4114
+ }
4115
+ if (strategy === "balanced") {
4116
+ return getBalancedScore(left) - getBalancedScore(right);
4117
+ }
4118
+ const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
4119
+ const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
4120
+ return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
4121
+ });
4122
+ };
4123
+ const resolveOrder = async (input) => {
4124
+ const selectedProvider = await options.selectProvider?.(input);
4125
+ const allowedProviders = await resolveAllowedProviders(input);
4126
+ const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
4127
+ const allowedRankedProviders = sortProviders([
4128
+ ...fallbackOrder ?? providerIds
4129
+ ]).filter((provider) => allowedProviders.has(provider));
4130
+ const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
4131
+ const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
4132
+ const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
4133
+ const preferred = selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
4134
+ const seen = new Set;
4135
+ const order = [];
4136
+ const candidates = strategy === "ordered" ? candidateRankedProviders : [
4137
+ preferred,
4138
+ ...candidateRankedProviders,
4139
+ ...providerIds.filter((provider) => !healthOptions || !isSuppressed(provider))
4140
+ ];
4141
+ for (const provider of candidates) {
4142
+ if (!provider || seen.has(provider) || !allowedProviders.has(provider) || !options.providers[provider]) {
4143
+ continue;
4144
+ }
4145
+ seen.add(provider);
4146
+ order.push(provider);
4147
+ }
4148
+ return {
4149
+ order,
4150
+ selectedProvider: preferred
4151
+ };
4152
+ };
4153
+ const emit = async (event, input) => {
4154
+ await options.onProviderEvent?.(event, input);
4155
+ };
4156
+ const runProvider = async (provider, model, input) => {
4157
+ const timeoutMs = getProviderTimeoutMs(provider);
4158
+ if (!timeoutMs) {
4159
+ return model.generate(input);
4160
+ }
4161
+ let timeout;
4162
+ try {
4163
+ return await Promise.race([
4164
+ model.generate(input),
4165
+ new Promise((_, reject) => {
4166
+ timeout = setTimeout(() => reject(new VoiceProviderTimeoutError(provider, timeoutMs)), timeoutMs);
4167
+ })
4168
+ ]);
4169
+ } finally {
4170
+ if (timeout) {
4171
+ clearTimeout(timeout);
4172
+ }
4173
+ }
4174
+ };
4175
+ return {
4176
+ generate: async (input) => {
4177
+ const { order, selectedProvider } = await resolveOrder(input);
4178
+ if (!selectedProvider || order.length === 0) {
4179
+ throw new Error("Voice provider router has no available providers.");
4180
+ }
4181
+ let lastError;
4182
+ for (const [index, provider] of order.entries()) {
4183
+ const model = options.providers[provider];
4184
+ if (!model) {
4185
+ continue;
4186
+ }
4187
+ const startedAt = Date.now();
4188
+ try {
4189
+ const output = await runProvider(provider, model, input);
4190
+ const providerHealth = recordProviderSuccess(provider);
4191
+ await emit({
4192
+ at: Date.now(),
4193
+ attempt: index + 1,
4194
+ elapsedMs: Date.now() - startedAt,
4195
+ fallbackProvider: provider === selectedProvider ? undefined : provider,
4196
+ latencyBudgetMs: getProviderTimeoutMs(provider),
4197
+ provider,
4198
+ providerHealth,
4199
+ recovered: provider !== selectedProvider,
4200
+ selectedProvider,
4201
+ status: provider === selectedProvider ? "success" : "fallback"
4202
+ }, input);
4203
+ return output;
4204
+ } catch (error) {
4205
+ lastError = error;
4206
+ const hasNextProvider = index < order.length - 1;
4207
+ const isProviderError = options.isProviderError?.(error, provider) ?? true;
4208
+ const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
4209
+ const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
4210
+ const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
4211
+ const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
4212
+ const nextProvider = hasNextProvider ? order[index + 1] : undefined;
4213
+ await emit({
4214
+ at: Date.now(),
4215
+ attempt: index + 1,
4216
+ elapsedMs: Date.now() - startedAt,
4217
+ error: errorMessage(error),
4218
+ fallbackProvider: shouldFallback ? nextProvider : undefined,
4219
+ latencyBudgetMs: getProviderTimeoutMs(provider),
4220
+ provider,
4221
+ providerHealth,
4222
+ rateLimited,
4223
+ selectedProvider,
4224
+ suppressionRemainingMs: getSuppressionRemainingMs(provider),
4225
+ suppressedUntil: providerHealth?.suppressedUntil,
4226
+ status: "error",
4227
+ timedOut
4228
+ }, input);
4229
+ if (!hasNextProvider || !shouldFallback) {
4230
+ throw error;
4231
+ }
4232
+ }
4233
+ }
4234
+ throw lastError ?? new Error("Voice provider router did not run a provider.");
4235
+ }
4236
+ };
4237
+ };
4238
+ var messageToOpenAIInput = (message) => {
4239
+ if (message.role === "tool") {
4240
+ return [
4241
+ {
4242
+ call_id: message.toolCallId ?? message.name ?? crypto.randomUUID(),
4243
+ output: message.content,
4244
+ type: "function_call_output"
4245
+ }
4246
+ ];
4247
+ }
4248
+ const toolCalls = getMessageToolCalls(message);
4249
+ if (message.role === "assistant" && toolCalls.length) {
4250
+ return toolCalls.map((toolCall) => ({
4251
+ arguments: JSON.stringify(toolCall.args),
4252
+ call_id: toolCall.id ?? crypto.randomUUID(),
4253
+ name: toolCall.name,
4254
+ type: "function_call"
4255
+ }));
4256
+ }
4257
+ return [
4258
+ {
4259
+ content: message.content,
4260
+ role: message.role === "system" ? "developer" : message.role
4261
+ }
4262
+ ];
4263
+ };
4264
+ var messagesToOpenAIInput = (messages) => messages.flatMap(messageToOpenAIInput);
4265
+ var messageToAnthropicMessage = (message) => {
4266
+ if (message.role === "system") {
4267
+ return;
4268
+ }
4269
+ if (message.role === "tool") {
4270
+ if (!message.toolCallId) {
4271
+ return {
4272
+ content: `Tool result from ${message.name ?? "tool"}: ${message.content}`,
4273
+ role: "user"
4274
+ };
4275
+ }
4276
+ return {
4277
+ content: [
4278
+ {
4279
+ content: message.content,
4280
+ tool_use_id: message.toolCallId,
4281
+ type: "tool_result"
4282
+ }
4283
+ ],
4284
+ role: "user"
4285
+ };
4286
+ }
4287
+ const toolCalls = getMessageToolCalls(message);
4288
+ if (message.role === "assistant" && toolCalls.length) {
4289
+ return {
4290
+ content: [
4291
+ ...message.content ? [
4292
+ {
4293
+ text: message.content,
4294
+ type: "text"
4295
+ }
4296
+ ] : [],
4297
+ ...toolCalls.map((toolCall) => ({
4298
+ id: toolCall.id ?? crypto.randomUUID(),
4299
+ input: toolCall.args,
4300
+ name: toolCall.name,
4301
+ type: "tool_use"
4302
+ }))
4303
+ ],
4304
+ role: "assistant"
4305
+ };
4306
+ }
4307
+ return {
4308
+ content: message.content,
4309
+ role: message.role
4310
+ };
4311
+ };
4312
+ var toGeminiSchema = (schema) => {
4313
+ const next = {};
4314
+ for (const [key, value] of Object.entries(schema)) {
4315
+ if (key === "additionalProperties") {
4316
+ continue;
4317
+ }
4318
+ if (key === "type" && typeof value === "string") {
4319
+ next[key] = value.toUpperCase();
4320
+ continue;
4321
+ }
4322
+ if (Array.isArray(value)) {
4323
+ next[key] = value.map((item) => item && typeof item === "object" ? toGeminiSchema(item) : item);
4324
+ continue;
4325
+ }
4326
+ if (value && typeof value === "object") {
4327
+ next[key] = toGeminiSchema(value);
4328
+ continue;
4329
+ }
4330
+ next[key] = value;
4331
+ }
4332
+ return next;
4333
+ };
4334
+ var messageToGeminiContent = (message) => {
4335
+ if (message.role === "system") {
4336
+ return;
4337
+ }
4338
+ if (message.role === "tool") {
4339
+ return {
4340
+ parts: [
4341
+ {
4342
+ functionResponse: {
4343
+ id: message.toolCallId,
4344
+ name: message.name ?? "tool",
4345
+ response: {
4346
+ result: parseJSONValue(message.content)
4347
+ }
4348
+ }
4349
+ }
4350
+ ],
4351
+ role: "user"
4352
+ };
4353
+ }
4354
+ const toolCalls = getMessageToolCalls(message);
4355
+ if (message.role === "assistant" && toolCalls.length) {
4356
+ return {
4357
+ parts: [
4358
+ ...message.content ? [
4359
+ {
4360
+ text: message.content
4361
+ }
4362
+ ] : [],
4363
+ ...toolCalls.map((toolCall) => ({
4364
+ functionCall: {
4365
+ args: toolCall.args,
4366
+ id: toolCall.id,
4367
+ name: toolCall.name
4368
+ }
4369
+ }))
4370
+ ],
4371
+ role: "model"
4372
+ };
4373
+ }
4374
+ return {
4375
+ parts: [
4376
+ {
4377
+ text: message.content
4378
+ }
4379
+ ],
4380
+ role: message.role === "assistant" ? "model" : "user"
4381
+ };
4382
+ };
4383
+ var extractText = (response) => {
4384
+ if (typeof response.output_text === "string") {
4385
+ return response.output_text;
4386
+ }
4387
+ const output = Array.isArray(response.output) ? response.output : [];
4388
+ for (const item of output) {
4389
+ if (!item || typeof item !== "object") {
4390
+ continue;
4391
+ }
4392
+ const record = item;
4393
+ const content = Array.isArray(record.content) ? record.content : [];
4394
+ for (const contentItem of content) {
4395
+ if (!contentItem || typeof contentItem !== "object") {
4396
+ continue;
4397
+ }
4398
+ const contentRecord = contentItem;
4399
+ if (typeof contentRecord.text === "string") {
4400
+ return contentRecord.text;
4401
+ }
4402
+ }
4403
+ }
4404
+ return "";
4405
+ };
4406
+ var extractToolCalls = (response) => {
4407
+ const output = Array.isArray(response.output) ? response.output : [];
4408
+ const toolCalls = [];
4409
+ for (const item of output) {
4410
+ if (!item || typeof item !== "object") {
4411
+ continue;
4412
+ }
4413
+ const record = item;
4414
+ if (record.type !== "function_call" || typeof record.name !== "string") {
4415
+ continue;
4416
+ }
4417
+ const args = typeof record.arguments === "string" ? parseJSON(record.arguments) : {};
4418
+ toolCalls.push({
4419
+ args,
4420
+ id: typeof record.call_id === "string" ? record.call_id : typeof record.id === "string" ? record.id : undefined,
4421
+ name: record.name
4422
+ });
4423
+ }
4424
+ return toolCalls;
4425
+ };
4426
+ var createOpenAIVoiceAssistantModel = (options) => {
4427
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4428
+ const baseUrl = options.baseUrl ?? "https://api.openai.com/v1";
4429
+ const model = options.model ?? "gpt-4.1-mini";
4430
+ return {
4431
+ generate: async (input) => {
4432
+ const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, {
4433
+ body: JSON.stringify({
4434
+ input: messagesToOpenAIInput(input.messages),
4435
+ instructions: [
4436
+ input.system,
4437
+ "Return a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools."
4438
+ ].filter(Boolean).join(`
4439
+
4440
+ `),
4441
+ max_output_tokens: options.maxOutputTokens,
4442
+ model,
4443
+ temperature: options.temperature,
4444
+ text: {
4445
+ format: {
4446
+ name: "voice_route_result",
4447
+ schema: OUTPUT_SCHEMA,
4448
+ strict: false,
4449
+ type: "json_schema"
4450
+ }
4451
+ },
4452
+ tool_choice: input.tools.length ? "auto" : "none",
4453
+ tools: input.tools.map((tool) => ({
4454
+ description: tool.description,
4455
+ name: tool.name,
4456
+ parameters: tool.parameters ?? {
4457
+ additionalProperties: true,
4458
+ type: "object"
4459
+ },
4460
+ strict: false,
4461
+ type: "function"
4462
+ }))
4463
+ }),
4464
+ headers: {
4465
+ authorization: `Bearer ${options.apiKey}`,
4466
+ "content-type": "application/json"
4467
+ },
4468
+ method: "POST"
4469
+ });
4470
+ if (!response.ok) {
4471
+ throw createHTTPError("OpenAI", response);
4472
+ }
4473
+ const body = await response.json();
4474
+ if (body.usage && typeof body.usage === "object") {
4475
+ await options.onUsage?.(body.usage);
4476
+ }
4477
+ const toolCalls = extractToolCalls(body);
4478
+ if (toolCalls.length) {
4479
+ return {
4480
+ toolCalls
4481
+ };
4482
+ }
4483
+ return normalizeRouteOutput(parseJSON(extractText(body)));
4484
+ }
4485
+ };
4486
+ };
4487
+ var extractAnthropicText = (response) => {
4488
+ const content = Array.isArray(response.content) ? response.content : [];
4489
+ return content.map((item) => item && typeof item === "object" && item.type === "text" && typeof item.text === "string" ? item.text : "").filter(Boolean).join(`
4490
+ `);
4491
+ };
4492
+ var extractAnthropicToolCalls = (response) => {
4493
+ const content = Array.isArray(response.content) ? response.content : [];
4494
+ const toolCalls = [];
4495
+ for (const item of content) {
4496
+ if (!item || typeof item !== "object") {
4497
+ continue;
4498
+ }
4499
+ const record = item;
4500
+ if (record.type !== "tool_use" || typeof record.name !== "string") {
4501
+ continue;
4502
+ }
4503
+ toolCalls.push({
4504
+ args: record.input && typeof record.input === "object" ? record.input : {},
4505
+ id: typeof record.id === "string" ? record.id : undefined,
4506
+ name: record.name
4507
+ });
4508
+ }
4509
+ return toolCalls;
4510
+ };
4511
+ var createAnthropicVoiceAssistantModel = (options) => {
4512
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4513
+ const baseUrl = options.baseUrl ?? "https://api.anthropic.com/v1";
4514
+ const model = options.model ?? "claude-sonnet-4-5";
4515
+ return {
4516
+ generate: async (input) => {
4517
+ const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/messages`, {
4518
+ body: JSON.stringify({
4519
+ max_tokens: options.maxOutputTokens ?? 1024,
4520
+ messages: input.messages.map(messageToAnthropicMessage).filter(Boolean),
4521
+ model,
4522
+ system: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
4523
+
4524
+ `),
4525
+ temperature: options.temperature,
4526
+ tool_choice: input.tools.length ? { type: "auto" } : { type: "none" },
4527
+ tools: input.tools.map((tool) => ({
4528
+ description: tool.description,
4529
+ input_schema: tool.parameters ?? {
4530
+ additionalProperties: true,
4531
+ type: "object"
4532
+ },
4533
+ name: tool.name
4534
+ }))
4535
+ }),
4536
+ headers: {
4537
+ "anthropic-version": options.version ?? "2023-06-01",
4538
+ "content-type": "application/json",
4539
+ "x-api-key": options.apiKey
4540
+ },
4541
+ method: "POST"
4542
+ });
4543
+ if (!response.ok) {
4544
+ throw createHTTPError("Anthropic", response);
4545
+ }
4546
+ const body = await response.json();
4547
+ if (body.usage && typeof body.usage === "object") {
4548
+ await options.onUsage?.(body.usage);
4549
+ }
4550
+ const toolCalls = extractAnthropicToolCalls(body);
4551
+ if (toolCalls.length) {
4552
+ return {
4553
+ assistantText: extractAnthropicText(body) || undefined,
4554
+ toolCalls
4555
+ };
4556
+ }
4557
+ return normalizeRouteOutput(parseJSON(extractAnthropicText(body)));
4558
+ }
4559
+ };
4560
+ };
4561
+ var extractGeminiCandidateParts = (response) => {
4562
+ const candidates = Array.isArray(response.candidates) ? response.candidates : [];
4563
+ const first = candidates[0];
4564
+ if (!first || typeof first !== "object") {
4565
+ return [];
4566
+ }
4567
+ const content = first.content;
4568
+ if (!content || typeof content !== "object") {
4569
+ return [];
4570
+ }
4571
+ const parts = content.parts;
4572
+ return Array.isArray(parts) ? parts : [];
4573
+ };
4574
+ var extractGeminiText = (response) => extractGeminiCandidateParts(response).map((part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : "").filter(Boolean).join(`
4575
+ `);
4576
+ var extractGeminiToolCalls = (response) => {
4577
+ const toolCalls = [];
4578
+ for (const part of extractGeminiCandidateParts(response)) {
4579
+ if (!part || typeof part !== "object") {
4580
+ continue;
4581
+ }
4582
+ const functionCall = part.functionCall;
4583
+ if (!functionCall || typeof functionCall !== "object") {
4584
+ continue;
4585
+ }
4586
+ const record = functionCall;
4587
+ if (typeof record.name !== "string") {
4588
+ continue;
4589
+ }
4590
+ toolCalls.push({
4591
+ args: record.args && typeof record.args === "object" ? record.args : {},
4592
+ id: typeof record.id === "string" ? record.id : undefined,
4593
+ name: record.name
4594
+ });
4595
+ }
4596
+ return toolCalls;
4597
+ };
4598
+ var createGeminiVoiceAssistantModel = (options) => {
4599
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4600
+ const baseUrl = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
4601
+ const model = options.model ?? "gemini-2.5-flash";
4602
+ const maxRetries = Math.max(0, options.maxRetries ?? 2);
4603
+ return {
4604
+ generate: async (input) => {
4605
+ const endpoint = `${baseUrl.replace(/\/$/, "")}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(options.apiKey)}`;
4606
+ let response;
4607
+ for (let attempt = 0;attempt <= maxRetries; attempt += 1) {
4608
+ response = await fetchImpl(endpoint, {
4609
+ body: JSON.stringify({
4610
+ contents: input.messages.map(messageToGeminiContent).filter(Boolean),
4611
+ generationConfig: {
4612
+ maxOutputTokens: options.maxOutputTokens,
4613
+ ...input.tools.length ? {} : {
4614
+ responseMimeType: "application/json",
4615
+ responseSchema: toGeminiSchema(OUTPUT_SCHEMA)
4616
+ },
4617
+ temperature: options.temperature
4618
+ },
4619
+ systemInstruction: {
4620
+ parts: [
4621
+ {
4622
+ text: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
4623
+
4624
+ `)
4625
+ }
4626
+ ]
4627
+ },
4628
+ tools: input.tools.length ? [
4629
+ {
4630
+ functionDeclarations: input.tools.map((tool) => ({
4631
+ description: tool.description,
4632
+ name: tool.name,
4633
+ parameters: toGeminiSchema(tool.parameters ?? {
4634
+ additionalProperties: true,
4635
+ type: "object"
4636
+ })
4637
+ }))
4638
+ }
4639
+ ] : undefined
4640
+ }),
4641
+ headers: {
4642
+ "content-type": "application/json"
4643
+ },
4644
+ method: "POST"
4645
+ });
4646
+ if (response.ok || response.status !== 429 && response.status < 500 || attempt === maxRetries) {
4647
+ break;
4648
+ }
4649
+ const retryAfter = Number(response.headers.get("retry-after"));
4650
+ await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt);
4651
+ }
4652
+ if (!response) {
4653
+ throw new Error("Gemini voice assistant model failed: no response");
4654
+ }
4655
+ if (!response.ok) {
4656
+ throw createHTTPError("Gemini", response);
4657
+ }
4658
+ const body = await response.json();
4659
+ if (body.usageMetadata && typeof body.usageMetadata === "object") {
4660
+ await options.onUsage?.(body.usageMetadata);
4661
+ }
4662
+ const toolCalls = extractGeminiToolCalls(body);
4663
+ if (toolCalls.length) {
4664
+ return {
4665
+ assistantText: extractGeminiText(body) || undefined,
4666
+ toolCalls
4667
+ };
4668
+ }
4669
+ return normalizeRouteOutput(parseJSON(extractGeminiText(body)));
4670
+ }
4671
+ };
4672
+ };
4673
+
4674
+ // src/store.ts
4675
+ var createId = () => crypto.randomUUID();
4676
+ var createVoiceSessionRecord = (id, scenarioId) => ({
4677
+ committedTurnIds: [],
4678
+ createdAt: Date.now(),
4679
+ currentTurn: {
4680
+ finalText: "",
4681
+ lastSpeechAt: undefined,
4682
+ lastTranscriptAt: undefined,
4683
+ partialEndedAt: undefined,
4684
+ partialStartedAt: undefined,
4685
+ partialText: "",
4686
+ silenceStartedAt: undefined,
4687
+ transcripts: []
4688
+ },
4689
+ id,
4690
+ scenarioId,
4691
+ reconnect: { attempts: 0 },
4692
+ status: "active",
4693
+ transcripts: [],
4694
+ turns: [],
4695
+ lastCommittedTurn: {
4696
+ committedAt: 0,
4697
+ signature: "",
4698
+ text: "",
4699
+ transcriptIds: []
4700
+ }
4701
+ });
4702
+ var resetVoiceSessionRecord = (id, existing, scenarioId) => ({
4703
+ ...createVoiceSessionRecord(id, scenarioId),
4704
+ metadata: existing?.metadata
4705
+ });
4706
+ var toVoiceSessionSummary = (session) => ({
4707
+ createdAt: session.createdAt,
4708
+ id: session.id,
4709
+ lastActivityAt: session.lastActivityAt,
4710
+ status: session.status,
4711
+ turnCount: session.turns.length
4712
+ });
4713
+
4714
+ // src/testing/providerSimulator.ts
4715
+ var getContextQuery = (context) => context.query;
4716
+ var titleCaseProvider = (provider) => provider.split(/[-_\s]+/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ");
4717
+ var resolveRequestedProvider = (context, providers) => {
4718
+ const provider = getContextQuery(context).provider;
4719
+ return providers.includes(provider) ? provider : providers[0];
4720
+ };
4721
+ var createVoiceProviderFailureSimulator = (options) => {
4722
+ if (options.providers.length === 0) {
4723
+ throw new Error("At least one provider is required.");
4724
+ }
4725
+ const providerModels = Object.fromEntries(options.providers.map((provider) => [
4726
+ provider,
4727
+ {
4728
+ generate: async (input) => {
4729
+ const query = getContextQuery(input.context);
4730
+ if (provider === query.simulateFailureProvider) {
4731
+ const label = options.providerLabel?.(provider) ?? titleCaseProvider(provider);
4732
+ throw new Error(`${label} voice assistant model failed: HTTP 429`);
4733
+ }
4734
+ if (options.response) {
4735
+ return options.response({
4736
+ ...input,
4737
+ mode: query.recoverProvider === provider ? "recovery" : "failure",
4738
+ provider
4739
+ });
4740
+ }
4741
+ return {
4742
+ assistantText: `Simulated ${provider} provider recovered.`
4743
+ };
4744
+ }
4745
+ }
4746
+ ]));
4747
+ const router = createVoiceProviderRouter({
4748
+ allowProviders: async (input) => {
4749
+ const recoverProvider = getContextQuery(input.context).recoverProvider;
4750
+ if (recoverProvider) {
4751
+ return [recoverProvider];
4752
+ }
4753
+ if (typeof options.allowProviders === "function") {
4754
+ return options.allowProviders(input);
4755
+ }
4756
+ return options.allowProviders ?? options.providers;
4757
+ },
4758
+ fallback: async (input) => {
4759
+ const selectedProvider = resolveRequestedProvider(input.context, options.providers);
4760
+ if (typeof options.fallback === "function") {
4761
+ return options.fallback(selectedProvider, input);
4762
+ }
4763
+ return options.fallback ?? options.providers.filter((provider) => provider !== selectedProvider);
4764
+ },
4765
+ fallbackMode: "provider-error",
4766
+ isProviderError: options.isProviderError,
4767
+ isRateLimitError: options.isRateLimitError,
4768
+ onProviderEvent: options.onProviderEvent,
4769
+ policy: "prefer-selected",
4770
+ providerHealth: options.providerHealth ?? {
4771
+ cooldownMs: 30000,
4772
+ failureThreshold: 1,
4773
+ rateLimitCooldownMs: 120000
4774
+ },
4775
+ providers: providerModels,
4776
+ selectProvider: ({ context }) => resolveRequestedProvider(context, options.providers)
4777
+ });
4778
+ const run = async (provider, mode) => {
4779
+ const now = Date.now();
4780
+ const session = createVoiceSessionRecord(`provider-sim-${now}`, "provider-simulation");
4781
+ const turn = {
4782
+ committedAt: now,
4783
+ id: `provider-sim-turn-${now}`,
4784
+ text: mode === "failure" ? `Simulate ${provider} provider failure.` : `Simulate ${provider} provider recovery.`,
4785
+ transcripts: []
4786
+ };
4787
+ const context = {
4788
+ query: {
4789
+ provider,
4790
+ ...mode === "recovery" ? { recoverProvider: provider } : {},
4791
+ ...mode === "failure" ? { simulateFailureProvider: provider } : {}
4792
+ }
4793
+ };
4794
+ const result = await router.generate({
4795
+ agentId: "provider-simulator",
4796
+ context,
4797
+ messages: [
4798
+ {
4799
+ content: turn.text,
4800
+ role: "user"
4801
+ }
4802
+ ],
4803
+ session,
4804
+ system: "Simulate provider routing without calling external APIs.",
4805
+ tools: [],
4806
+ turn
4807
+ });
4808
+ return {
4809
+ mode,
4810
+ provider,
4811
+ replayHref: options.replayHref === false ? undefined : typeof options.replayHref === "function" ? options.replayHref({
4812
+ provider,
4813
+ sessionId: session.id,
4814
+ turnId: turn.id
4815
+ }) : `${options.replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(session.id)}/replay/htmx`,
4816
+ result,
4817
+ sessionId: session.id,
4818
+ status: "simulated",
4819
+ turnId: turn.id
4820
+ };
4821
+ };
4822
+ return {
4823
+ run
4824
+ };
4825
+ };
4826
+ // src/memoryStore.ts
4827
+ var createVoiceMemoryStore = () => {
4828
+ const sessions = new Map;
4829
+ const get = async (id) => sessions.get(id);
4830
+ const getOrCreate = async (id) => {
4831
+ let session = sessions.get(id);
4832
+ if (!session) {
4833
+ session = createVoiceSessionRecord(id);
4834
+ sessions.set(id, session);
4835
+ }
4836
+ return session;
4837
+ };
4838
+ const set = async (id, value) => {
4839
+ sessions.set(id, value);
4840
+ };
4841
+ const list = async () => Array.from(sessions.values()).map((session) => toVoiceSessionSummary(session)).sort((first, second) => (second.lastActivityAt ?? second.createdAt) - (first.lastActivityAt ?? first.createdAt));
4842
+ const remove = async (id) => {
4843
+ sessions.delete(id);
4844
+ };
4845
+ return { get, getOrCreate, list, remove, set };
4846
+ };
4847
+
4848
+ // src/session.ts
4849
+ import { Buffer as Buffer2 } from "buffer";
4850
+
4851
+ // src/handoff.ts
4852
+ var toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
4853
+ var signHandoffBody = async (input) => {
4854
+ const encoder = new TextEncoder;
4855
+ const key = await crypto.subtle.importKey("raw", encoder.encode(input.secret), {
4856
+ hash: "SHA-256",
4857
+ name: "HMAC"
4858
+ }, false, ["sign"]);
4859
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${input.timestamp}.${input.body}`));
4860
+ return `sha256=${toHex(new Uint8Array(signature))}`;
4861
+ };
4862
+ var toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
4863
+ var createSkippedDelivery = (adapter) => ({
4864
+ adapterId: adapter.id,
4865
+ adapterKind: adapter.kind,
4866
+ status: "skipped"
4867
+ });
4868
+ var aggregateHandoffStatus = (deliveries) => {
4869
+ const statuses = Object.values(deliveries).map((delivery) => delivery.status);
4870
+ if (statuses.some((status) => status === "failed")) {
4871
+ return "failed";
4872
+ }
4873
+ if (statuses.some((status) => status === "delivered")) {
4874
+ return "delivered";
4875
+ }
4876
+ return "skipped";
4877
+ };
4878
+ var createHandoffDeliveryId = (input) => [
4879
+ "voice-handoff",
4880
+ input.sessionId,
4881
+ input.action,
4882
+ Date.now(),
4883
+ crypto.randomUUID()
4884
+ ].join(":");
4885
+ var resolveHandoffDeliveryError = (deliveries) => Object.values(deliveries).map((delivery) => delivery.error).find(Boolean);
4886
+ var defaultWebhookBody = (input) => ({
4887
+ action: input.action,
4888
+ metadata: input.metadata,
4889
+ reason: input.reason,
4890
+ result: input.result,
4891
+ session: {
4892
+ id: input.session.id,
4893
+ scenarioId: input.session.scenarioId,
4894
+ status: input.session.status
4895
+ },
4896
+ source: "absolutejs-voice",
4897
+ target: input.target
4898
+ });
4899
+ var deliverVoiceHandoff = async (input) => {
4900
+ if (!input.config || input.config.adapters.length === 0) {
4901
+ return;
4902
+ }
4903
+ const deliveries = {};
4904
+ for (const adapter of input.config.adapters) {
4905
+ if (adapter.actions && !adapter.actions.includes(input.handoff.action)) {
4906
+ deliveries[adapter.id] = createSkippedDelivery(adapter);
4907
+ continue;
4908
+ }
4909
+ try {
4910
+ const result = await adapter.handoff(input.handoff);
4911
+ deliveries[adapter.id] = {
4912
+ ...result,
4913
+ adapterId: adapter.id,
4914
+ adapterKind: adapter.kind
4915
+ };
4916
+ } catch (error) {
4917
+ deliveries[adapter.id] = {
4918
+ adapterId: adapter.id,
4919
+ adapterKind: adapter.kind,
4920
+ error: toErrorMessage(error),
4921
+ status: "failed"
4922
+ };
4923
+ if (input.config.failMode === "throw") {
4924
+ throw error;
4925
+ }
4926
+ }
4927
+ }
4928
+ return {
4929
+ action: input.handoff.action,
4930
+ deliveries,
4931
+ status: aggregateHandoffStatus(deliveries)
4932
+ };
4933
+ };
4934
+ var createVoiceHandoffDeliveryRecord = (input) => {
4935
+ const now = Date.now();
4936
+ return {
4937
+ action: input.action,
4938
+ context: input.context,
4939
+ createdAt: now,
4940
+ deliveryAttempts: 0,
4941
+ deliveryStatus: "pending",
4942
+ id: input.id ?? createHandoffDeliveryId({
4943
+ action: input.action,
4944
+ sessionId: input.session.id
4945
+ }),
4946
+ metadata: input.metadata,
4947
+ reason: input.reason,
4948
+ result: input.result,
4949
+ session: input.session,
4950
+ sessionId: input.session.id,
4951
+ target: input.target,
4952
+ updatedAt: now
4953
+ };
4954
+ };
4955
+ var applyVoiceHandoffDeliveryResult = (delivery, result) => ({
4956
+ ...delivery,
4957
+ deliveredAt: result.status === "delivered" || result.status === "skipped" ? Date.now() : delivery.deliveredAt,
4958
+ deliveries: result.deliveries,
4959
+ deliveryAttempts: (delivery.deliveryAttempts ?? 0) + 1,
4960
+ deliveryError: result.status === "failed" ? resolveHandoffDeliveryError(result.deliveries) : undefined,
4961
+ deliveryStatus: result.status,
4962
+ updatedAt: Date.now()
4963
+ });
4964
+ var deliverVoiceHandoffDelivery = async (options) => {
4965
+ const result = await deliverVoiceHandoff({
4966
+ config: {
4967
+ adapters: options.adapters,
4968
+ failMode: options.failMode
4969
+ },
4970
+ handoff: {
4971
+ action: options.delivery.action,
4972
+ api: options.api,
4973
+ context: options.delivery.context,
4974
+ metadata: options.delivery.metadata,
4975
+ reason: options.delivery.reason,
4976
+ result: options.delivery.result,
4977
+ session: options.delivery.session,
4978
+ target: options.delivery.target
4979
+ }
4980
+ });
4981
+ return result ? applyVoiceHandoffDeliveryResult(options.delivery, result) : {
4982
+ ...options.delivery,
4983
+ deliveryAttempts: (options.delivery.deliveryAttempts ?? 0) + 1,
4984
+ deliveryStatus: "skipped",
4985
+ updatedAt: Date.now()
4986
+ };
4987
+ };
4988
+ var createVoiceMemoryHandoffDeliveryStore = () => {
4989
+ const deliveries = new Map;
4990
+ return {
4991
+ get: async (id) => deliveries.get(id),
4992
+ list: async () => [...deliveries.values()].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
4993
+ remove: async (id) => {
4994
+ deliveries.delete(id);
4995
+ },
4996
+ set: async (id, delivery) => {
4997
+ deliveries.set(id, delivery);
4998
+ }
4999
+ };
5000
+ };
5001
+ var createVoiceWebhookHandoffAdapter = (options) => ({
5002
+ actions: options.actions,
5003
+ handoff: async (input) => {
5004
+ const fetchImpl = options.fetch ?? globalThis.fetch;
5005
+ if (typeof fetchImpl !== "function") {
5006
+ return {
5007
+ deliveredTo: options.url,
5008
+ error: "Handoff delivery failed: fetch is not available in this runtime.",
5009
+ status: "failed"
5010
+ };
5011
+ }
5012
+ const body = JSON.stringify(await options.body?.(input) ?? defaultWebhookBody(input));
5013
+ const headers = {
5014
+ "content-type": "application/json",
5015
+ ...options.headers
5016
+ };
5017
+ if (options.signingSecret) {
5018
+ const timestamp = String(Date.now());
5019
+ headers["x-absolutejs-timestamp"] = timestamp;
5020
+ headers["x-absolutejs-signature"] = await signHandoffBody({
5021
+ body,
5022
+ secret: options.signingSecret,
5023
+ timestamp
5024
+ });
5025
+ }
5026
+ const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
5027
+ const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
5028
+ try {
5029
+ const response = await fetchImpl(options.url, {
5030
+ body,
5031
+ headers,
5032
+ method: options.method ?? "POST",
5033
+ signal: controller?.signal
5034
+ });
5035
+ if (!response.ok) {
5036
+ return {
5037
+ deliveredTo: options.url,
5038
+ error: `Handoff delivery failed with response ${response.status}.`,
5039
+ status: "failed"
5040
+ };
5041
+ }
5042
+ return {
5043
+ deliveredAt: Date.now(),
5044
+ deliveredTo: options.url,
5045
+ status: "delivered"
5046
+ };
5047
+ } finally {
5048
+ if (timeout) {
5049
+ clearTimeout(timeout);
5050
+ }
5051
+ }
5052
+ },
5053
+ id: options.id,
5054
+ kind: options.kind ?? "webhook"
5055
+ });
5056
+ var escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
5057
+ var defaultTwilioTransferTwiML = (input) => {
5058
+ if (!input.target) {
5059
+ return "<Response><Hangup /></Response>";
5060
+ }
5061
+ return `<Response><Dial>${escapeXml(input.target)}</Dial></Response>`;
5062
+ };
5063
+ var resolveTwilioCallSid = async (resolver, input) => {
5064
+ if (typeof resolver === "function") {
5065
+ return resolver(input);
5066
+ }
5067
+ if (typeof resolver === "string" && resolver.length > 0) {
5068
+ return resolver;
5069
+ }
5070
+ const metadataSid = typeof input.metadata?.callSid === "string" ? input.metadata.callSid : undefined;
5071
+ const sessionMetadata = input.session.metadata && typeof input.session.metadata === "object" ? input.session.metadata : undefined;
5072
+ const sessionSid = typeof sessionMetadata?.callSid === "string" ? sessionMetadata.callSid : undefined;
5073
+ return metadataSid ?? sessionSid;
5074
+ };
5075
+ var createVoiceTwilioRedirectHandoffAdapter = (options) => ({
5076
+ actions: options.actions ?? ["transfer"],
5077
+ handoff: async (input) => {
5078
+ const fetchImpl = options.fetch ?? globalThis.fetch;
5079
+ const callSid = await resolveTwilioCallSid(options.callSid, input);
5080
+ if (!callSid) {
5081
+ return {
5082
+ error: "Twilio handoff requires a callSid.",
5083
+ status: "failed"
5084
+ };
5085
+ }
5086
+ if (typeof fetchImpl !== "function") {
5087
+ return {
5088
+ error: "Twilio handoff failed: fetch is not available in this runtime.",
5089
+ status: "failed"
5090
+ };
5091
+ }
5092
+ const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(options.accountSid)}/Calls/${encodeURIComponent(callSid)}.json`;
5093
+ const body = new URLSearchParams({
5094
+ Twiml: await (options.buildTwiML?.(input) ?? defaultTwilioTransferTwiML(input))
5095
+ });
5096
+ const auth = btoa(`${options.accountSid}:${options.authToken}`);
5097
+ const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
5098
+ const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
5099
+ try {
5100
+ const response = await fetchImpl(url, {
5101
+ body,
5102
+ headers: {
5103
+ authorization: `Basic ${auth}`,
5104
+ "content-type": "application/x-www-form-urlencoded"
5105
+ },
5106
+ method: "POST",
5107
+ signal: controller?.signal
5108
+ });
5109
+ if (!response.ok) {
5110
+ return {
5111
+ deliveredTo: url,
5112
+ error: `Twilio handoff failed with response ${response.status}.`,
5113
+ status: "failed"
5114
+ };
5115
+ }
5116
+ return {
5117
+ deliveredAt: Date.now(),
5118
+ deliveredTo: url,
5119
+ metadata: {
5120
+ callSid
5121
+ },
5122
+ status: "delivered"
5123
+ };
5124
+ } finally {
5125
+ if (timeout) {
5126
+ clearTimeout(timeout);
5127
+ }
5128
+ }
5129
+ },
5130
+ id: options.id ?? "twilio-redirect",
5131
+ kind: "twilio-redirect"
5132
+ });
5133
+
5134
+ // src/logger.ts
5135
+ var noop2 = () => {};
5136
+ var createNoopLogger = () => ({
5137
+ debug: noop2,
5138
+ error: noop2,
5139
+ info: noop2,
5140
+ warn: noop2
5141
+ });
5142
+ var resolveLogger = (logger) => ({
5143
+ ...createNoopLogger(),
5144
+ ...logger
5145
+ });
5146
+
5147
+ // src/session.ts
5148
+ var DEFAULT_RECONNECT_TIMEOUT = 30000;
5149
+ var DEFAULT_MAX_RECONNECT_ATTEMPTS2 = 10;
5150
+ var DEFAULT_TRANSCRIPT_STABILITY_MS = 450;
5151
+ var DEFAULT_FALLBACK_REPLAY_MS = 8000;
5152
+ var DEFAULT_FALLBACK_SETTLE_MS = 220;
5153
+ var DEFAULT_FALLBACK_COMPLETION_TIMEOUT_MS = 2500;
5154
+ var DEFAULT_FALLBACK_CONFIDENCE_THRESHOLD = 0.6;
5155
+ var DEFAULT_FALLBACK_MIN_TEXT_LENGTH = 2;
5156
+ var DEFAULT_FALLBACK_MAX_ATTEMPTS_PER_TURN = 1;
5157
+ var DEFAULT_DUPLICATE_TURN_WINDOW_MS = 5000;
5158
+ var FALLBACK_CONFIDENCE_SELECTION_DELTA = 0.05;
5159
+ var FALLBACK_WORD_COUNT_SELECTION_MARGIN_RATIO = 0.12;
5160
+ var EXTENDED_VENDOR_COMMIT_SILENCE_THRESHOLD_MS = 200;
5161
+ var MAX_VENDOR_COMMIT_GRACE_MS = 1200;
5162
+ var DEFAULT_FORMAT = {
5163
+ channels: 1,
5164
+ container: "raw",
5165
+ encoding: "pcm_s16le",
5166
+ sampleRateHz: 16000
5167
+ };
5168
+ var DEFAULT_REALTIME_FORMAT = {
5169
+ channels: 1,
5170
+ container: "raw",
5171
+ encoding: "pcm_s16le",
5172
+ sampleRateHz: 24000
5173
+ };
5174
+ var toError = (value) => value instanceof Error ? value : new Error(String(value));
5175
+ var createEmptyCurrentTurn = () => ({
5176
+ finalText: "",
5177
+ lastSpeechAt: undefined,
5178
+ lastTranscriptAt: undefined,
5179
+ partialEndedAt: undefined,
5180
+ partialStartedAt: undefined,
5181
+ partialText: "",
5182
+ silenceStartedAt: undefined,
5183
+ transcripts: []
5184
+ });
5185
+ var cloneTranscript = (transcript) => ({ ...transcript });
5186
+ var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
5187
+ var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
5188
+ var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
5189
+ var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
5190
+ var getBufferedAudioDurationMs = (chunks) => chunks.reduce((total, chunk) => total + getAudioChunkDurationMs(chunk), 0);
5191
+ var calculateMeanConfidence = (transcripts) => {
5192
+ let sum = 0;
5193
+ let total = 0;
5194
+ for (const transcript of transcripts) {
5195
+ if (typeof transcript.confidence === "number") {
5196
+ sum += transcript.confidence;
5197
+ total += 1;
5198
+ }
5199
+ }
5200
+ if (total === 0) {
5201
+ return 0;
5202
+ }
5203
+ return sum / total;
5204
+ };
5205
+ var createTurnQuality = (transcripts, source, fallbackUsed, fallbackDiagnostics, correctionDiagnostics, costEstimate) => {
5206
+ const sampledTranscripts = transcripts.filter((transcript) => typeof transcript.confidence === "number");
5207
+ const confidenceSampleCount = sampledTranscripts.length;
3604
5208
  return {
3605
5209
  averageConfidence: confidenceSampleCount > 0 ? sampledTranscripts.reduce((sum, transcript) => sum + transcript.confidence, 0) / confidenceSampleCount : undefined,
3606
5210
  confidenceSampleCount,
@@ -3725,6 +5329,7 @@ var pushCallLifecycleEvent = (session, input) => {
3725
5329
  }
3726
5330
  return lifecycle;
3727
5331
  };
5332
+ var getLatestCallLifecycleEvent = (session) => session.call?.events.at(-1);
3728
5333
  var createVoiceSession = (options) => {
3729
5334
  const logger = resolveLogger(options.logger);
3730
5335
  const reconnect = {
@@ -3749,7 +5354,7 @@ var createVoiceSession = (options) => {
3749
5354
  } : undefined;
3750
5355
  const appendTrace = async (input) => {
3751
5356
  await options.trace?.append({
3752
- at: Date.now(),
5357
+ at: input.at ?? Date.now(),
3753
5358
  metadata: input.metadata,
3754
5359
  payload: input.payload,
3755
5360
  scenarioId: input.session?.scenarioId ?? options.scenarioId,
@@ -3758,6 +5363,13 @@ var createVoiceSession = (options) => {
3758
5363
  type: input.type
3759
5364
  });
3760
5365
  };
5366
+ const appendTurnLatencyStage = async (input) => appendTrace({
5367
+ at: input.at,
5368
+ payload: { stage: input.stage },
5369
+ session: input.session,
5370
+ turnId: input.turnId,
5371
+ type: "turn_latency.stage"
5372
+ });
3761
5373
  const phraseHints = options.phraseHints ?? [];
3762
5374
  const lexicon = options.lexicon ?? [];
3763
5375
  let socket = options.socket;
@@ -3825,6 +5437,76 @@ var createVoiceSession = (options) => {
3825
5437
  });
3826
5438
  }
3827
5439
  };
5440
+ const sendCallLifecycle = async (session) => {
5441
+ const event = getLatestCallLifecycleEvent(session);
5442
+ if (!event) {
5443
+ return;
5444
+ }
5445
+ await send({
5446
+ event,
5447
+ sessionId: options.id,
5448
+ type: "call_lifecycle"
5449
+ });
5450
+ };
5451
+ const sendReplay = async (session) => {
5452
+ await send({
5453
+ assistantTexts: session.turns.flatMap((turn) => turn.assistantText ? [turn.assistantText] : []),
5454
+ call: session.call,
5455
+ partial: session.currentTurn.partialText,
5456
+ scenarioId: session.scenarioId,
5457
+ sessionId: options.id,
5458
+ status: session.status,
5459
+ turns: session.turns,
5460
+ type: "replay"
5461
+ });
5462
+ };
5463
+ const runHandoff = async (input) => {
5464
+ const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
5465
+ action: input.action,
5466
+ context: options.context,
5467
+ metadata: input.metadata,
5468
+ reason: input.reason,
5469
+ result: input.result,
5470
+ session: input.session,
5471
+ target: input.target
5472
+ }) : undefined;
5473
+ if (queuedDelivery) {
5474
+ await options.handoff?.deliveryQueue?.set(queuedDelivery.id, queuedDelivery);
5475
+ }
5476
+ if (options.handoff?.enqueueOnly) {
5477
+ return;
5478
+ }
5479
+ const result = await deliverVoiceHandoff({
5480
+ config: options.handoff,
5481
+ handoff: {
5482
+ action: input.action,
5483
+ api,
5484
+ context: options.context,
5485
+ metadata: input.metadata,
5486
+ reason: input.reason,
5487
+ result: input.result,
5488
+ session: input.session,
5489
+ target: input.target
5490
+ }
5491
+ });
5492
+ if (!result) {
5493
+ return;
5494
+ }
5495
+ if (queuedDelivery) {
5496
+ const updatedDelivery = applyVoiceHandoffDeliveryResult(queuedDelivery, result);
5497
+ await options.handoff?.deliveryQueue?.set(updatedDelivery.id, updatedDelivery);
5498
+ }
5499
+ await appendTrace({
5500
+ metadata: input.metadata,
5501
+ payload: {
5502
+ ...result,
5503
+ reason: input.reason,
5504
+ target: input.target
5505
+ },
5506
+ session: input.session,
5507
+ type: "call.handoff"
5508
+ });
5509
+ };
3828
5510
  const readSession = async () => options.store.getOrCreate(options.id);
3829
5511
  const writeSession = async (mutate) => {
3830
5512
  const session = await options.store.getOrCreate(options.id);
@@ -3881,6 +5563,23 @@ var createVoiceSession = (options) => {
3881
5563
  });
3882
5564
  }
3883
5565
  };
5566
+ const sendAssistantAudio = async (chunk, input) => {
5567
+ const normalizedChunk = chunk instanceof Uint8Array ? new Uint8Array(chunk) : chunk instanceof ArrayBuffer ? new Uint8Array(chunk.slice(0)) : new Uint8Array(chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength));
5568
+ await send({
5569
+ chunkBase64: encodeBase64(normalizedChunk),
5570
+ format: input.format,
5571
+ receivedAt: input.receivedAt,
5572
+ turnId: activeTTSTurnId,
5573
+ type: "audio"
5574
+ });
5575
+ if (activeTTSTurnId) {
5576
+ await appendTurnLatencyStage({
5577
+ at: input.receivedAt,
5578
+ stage: "assistant_audio_received",
5579
+ turnId: activeTTSTurnId
5580
+ });
5581
+ }
5582
+ };
3884
5583
  const scheduleTurnCommit = (delayMs, reason, reset = true) => {
3885
5584
  if (!reset && silenceTimer) {
3886
5585
  return;
@@ -4015,6 +5714,7 @@ var createVoiceSession = (options) => {
4015
5714
  await appendTrace({
4016
5715
  payload: {
4017
5716
  disposition,
5717
+ metadata: input.metadata,
4018
5718
  reason: input.reason,
4019
5719
  target: input.target,
4020
5720
  type: "end"
@@ -4022,6 +5722,7 @@ var createVoiceSession = (options) => {
4022
5722
  session,
4023
5723
  type: "call.lifecycle"
4024
5724
  });
5725
+ await sendCallLifecycle(session);
4025
5726
  await send({
4026
5727
  sessionId: options.id,
4027
5728
  type: "complete"
@@ -4101,6 +5802,15 @@ var createVoiceSession = (options) => {
4101
5802
  session,
4102
5803
  type: "call.lifecycle"
4103
5804
  });
5805
+ await sendCallLifecycle(session);
5806
+ await runHandoff({
5807
+ action: "transfer",
5808
+ metadata: input.metadata,
5809
+ reason: input.reason,
5810
+ result: input.result,
5811
+ session,
5812
+ target: input.target
5813
+ });
4104
5814
  await completeInternal(input.result, {
4105
5815
  disposition: "transferred",
4106
5816
  invokeOnComplete: false,
@@ -4126,6 +5836,14 @@ var createVoiceSession = (options) => {
4126
5836
  session,
4127
5837
  type: "call.lifecycle"
4128
5838
  });
5839
+ await sendCallLifecycle(session);
5840
+ await runHandoff({
5841
+ action: "escalate",
5842
+ metadata: input.metadata,
5843
+ reason: input.reason,
5844
+ result: input.result,
5845
+ session
5846
+ });
4129
5847
  await completeInternal(input.result, {
4130
5848
  disposition: "escalated",
4131
5849
  invokeOnComplete: false,
@@ -4148,6 +5866,13 @@ var createVoiceSession = (options) => {
4148
5866
  session,
4149
5867
  type: "call.lifecycle"
4150
5868
  });
5869
+ await sendCallLifecycle(session);
5870
+ await runHandoff({
5871
+ action: "no-answer",
5872
+ metadata: input?.metadata,
5873
+ result: input?.result,
5874
+ session
5875
+ });
4151
5876
  await completeInternal(input?.result, {
4152
5877
  disposition: "no-answer",
4153
5878
  invokeOnComplete: false,
@@ -4169,6 +5894,13 @@ var createVoiceSession = (options) => {
4169
5894
  session,
4170
5895
  type: "call.lifecycle"
4171
5896
  });
5897
+ await sendCallLifecycle(session);
5898
+ await runHandoff({
5899
+ action: "voicemail",
5900
+ metadata: input?.metadata,
5901
+ result: input?.result,
5902
+ session
5903
+ });
4172
5904
  await completeInternal(input?.result, {
4173
5905
  disposition: "voicemail",
4174
5906
  invokeOnComplete: false,
@@ -4549,8 +6281,12 @@ var createVoiceSession = (options) => {
4549
6281
  if (sttSession) {
4550
6282
  return sttSession;
4551
6283
  }
4552
- const openedSession = await options.stt.open({
4553
- format: DEFAULT_FORMAT,
6284
+ const inputAdapter = options.realtime ?? options.stt;
6285
+ if (!inputAdapter) {
6286
+ throw new Error("Voice session requires either an stt or realtime adapter.");
6287
+ }
6288
+ const openedSession = await inputAdapter.open({
6289
+ format: options.realtime ? options.realtimeInputFormat ?? DEFAULT_REALTIME_FORMAT : DEFAULT_FORMAT,
4554
6290
  languageStrategy: options.languageStrategy,
4555
6291
  lexicon,
4556
6292
  phraseHints,
@@ -4585,6 +6321,16 @@ var createVoiceSession = (options) => {
4585
6321
  openedSession.on("close", (event) => {
4586
6322
  runAdapterEvent("adapter.close", () => handleClose(event));
4587
6323
  });
6324
+ if (options.realtime) {
6325
+ openedSession.on("audio", ({ chunk, format, receivedAt }) => {
6326
+ runAdapterEvent("adapter.audio", async () => {
6327
+ await sendAssistantAudio(chunk, {
6328
+ format,
6329
+ receivedAt
6330
+ });
6331
+ });
6332
+ });
6333
+ }
4588
6334
  return openedSession;
4589
6335
  };
4590
6336
  const ensureTTSSession = async () => {
@@ -4609,13 +6355,9 @@ var createVoiceSession = (options) => {
4609
6355
  if (ttsSession !== openedSession) {
4610
6356
  return;
4611
6357
  }
4612
- const normalizedChunk = chunk instanceof Uint8Array ? new Uint8Array(chunk) : chunk instanceof ArrayBuffer ? new Uint8Array(chunk.slice(0)) : new Uint8Array(chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength));
4613
- await send({
4614
- chunkBase64: encodeBase64(normalizedChunk),
6358
+ await sendAssistantAudio(chunk, {
4615
6359
  format,
4616
- receivedAt,
4617
- turnId: activeTTSTurnId,
4618
- type: "audio"
6360
+ receivedAt
4619
6361
  });
4620
6362
  });
4621
6363
  });
@@ -4675,6 +6417,7 @@ var createVoiceSession = (options) => {
4675
6417
  voicemail: committedOutput?.voicemail
4676
6418
  };
4677
6419
  if (output?.assistantText) {
6420
+ const assistantTextStartedAt = Date.now();
4678
6421
  await writeSession((currentSession) => {
4679
6422
  setTurnResult(currentSession, turn.id, {
4680
6423
  assistantText: output.assistantText
@@ -4685,10 +6428,17 @@ var createVoiceSession = (options) => {
4685
6428
  turnId: turn.id,
4686
6429
  type: "assistant"
4687
6430
  });
6431
+ await appendTurnLatencyStage({
6432
+ at: assistantTextStartedAt,
6433
+ session,
6434
+ stage: "assistant_text_started",
6435
+ turnId: turn.id
6436
+ });
4688
6437
  await appendTrace({
4689
6438
  payload: {
4690
6439
  text: output.assistantText,
4691
- ttsConfigured: Boolean(options.tts)
6440
+ ttsConfigured: Boolean(options.tts),
6441
+ realtimeConfigured: Boolean(options.realtime)
4692
6442
  },
4693
6443
  session,
4694
6444
  turnId: turn.id,
@@ -4699,7 +6449,18 @@ var createVoiceSession = (options) => {
4699
6449
  if (activeTTSSession) {
4700
6450
  const ttsStartedAt = Date.now();
4701
6451
  activeTTSTurnId = turn.id;
6452
+ await appendTurnLatencyStage({
6453
+ at: ttsStartedAt,
6454
+ session,
6455
+ stage: "tts_send_started",
6456
+ turnId: turn.id
6457
+ });
4702
6458
  await activeTTSSession.send(output.assistantText);
6459
+ await appendTurnLatencyStage({
6460
+ session,
6461
+ stage: "tts_send_completed",
6462
+ turnId: turn.id
6463
+ });
4703
6464
  await appendTrace({
4704
6465
  payload: {
4705
6466
  elapsedMs: Date.now() - ttsStartedAt,
@@ -4709,9 +6470,35 @@ var createVoiceSession = (options) => {
4709
6470
  turnId: turn.id,
4710
6471
  type: "turn.assistant"
4711
6472
  });
6473
+ } else if (options.realtime) {
6474
+ const activeRealtimeSession = await ensureAdapter();
6475
+ const realtimeStartedAt = Date.now();
6476
+ activeTTSTurnId = turn.id;
6477
+ await appendTurnLatencyStage({
6478
+ at: realtimeStartedAt,
6479
+ session,
6480
+ stage: "tts_send_started",
6481
+ turnId: turn.id
6482
+ });
6483
+ await activeRealtimeSession.send(output.assistantText);
6484
+ await appendTurnLatencyStage({
6485
+ session,
6486
+ stage: "tts_send_completed",
6487
+ turnId: turn.id
6488
+ });
6489
+ await appendTrace({
6490
+ payload: {
6491
+ elapsedMs: Date.now() - realtimeStartedAt,
6492
+ mode: "realtime",
6493
+ status: "sent"
6494
+ },
6495
+ session,
6496
+ turnId: turn.id,
6497
+ type: "turn.assistant"
6498
+ });
4712
6499
  }
4713
6500
  } catch (error) {
4714
- logger.warn("voice tts send failed", {
6501
+ logger.warn("voice assistant audio send failed", {
4715
6502
  error: toError(error).message,
4716
6503
  sessionId: options.id,
4717
6504
  turnId: turn.id
@@ -4719,7 +6506,7 @@ var createVoiceSession = (options) => {
4719
6506
  await appendTrace({
4720
6507
  payload: {
4721
6508
  error: toError(error).message,
4722
- status: "tts-send-failed"
6509
+ status: options.realtime ? "realtime-send-failed" : "tts-send-failed"
4723
6510
  },
4724
6511
  session,
4725
6512
  turnId: turn.id,
@@ -4896,11 +6683,35 @@ var createVoiceSession = (options) => {
4896
6683
  turnId: turn.id,
4897
6684
  type: "turn.cost"
4898
6685
  });
6686
+ const firstTranscriptAt = turn.transcripts.map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
6687
+ const finalTranscriptAt = turn.transcripts.filter((transcript) => transcript.isFinal).map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
6688
+ if (firstTranscriptAt !== undefined) {
6689
+ await appendTurnLatencyStage({
6690
+ at: firstTranscriptAt,
6691
+ session: updatedSession,
6692
+ stage: "speech_detected",
6693
+ turnId: turn.id
6694
+ });
6695
+ }
6696
+ if (finalTranscriptAt !== undefined) {
6697
+ await appendTurnLatencyStage({
6698
+ at: finalTranscriptAt,
6699
+ session: updatedSession,
6700
+ stage: "final_transcript",
6701
+ turnId: turn.id
6702
+ });
6703
+ }
6704
+ await appendTurnLatencyStage({
6705
+ at: turn.committedAt,
6706
+ session: updatedSession,
6707
+ stage: "turn_committed",
6708
+ turnId: turn.id
6709
+ });
4899
6710
  await send({
4900
6711
  turn,
4901
6712
  type: "turn"
4902
6713
  });
4903
- if (options.sttLifecycle === "turn-scoped") {
6714
+ if (options.stt && options.sttLifecycle === "turn-scoped") {
4904
6715
  await closeAdapter("turn-commit");
4905
6716
  }
4906
6717
  await completeTurn(updatedSession, turn);
@@ -4955,6 +6766,7 @@ var createVoiceSession = (options) => {
4955
6766
  session,
4956
6767
  type: "call.lifecycle"
4957
6768
  });
6769
+ await sendCallLifecycle(session);
4958
6770
  }
4959
6771
  await send({
4960
6772
  sessionId: options.id,
@@ -4962,6 +6774,7 @@ var createVoiceSession = (options) => {
4962
6774
  scenarioId: session.scenarioId,
4963
6775
  type: "session"
4964
6776
  });
6777
+ await sendReplay(session);
4965
6778
  if (shouldFireOnSession) {
4966
6779
  await options.route.onCallStart?.({
4967
6780
  api,
@@ -5545,7 +7358,7 @@ var createVoiceCallReviewFromLiveTelephonyReport = (report, options = {}) => {
5545
7358
  }
5546
7359
  };
5547
7360
  };
5548
- var toErrorMessage = (error) => {
7361
+ var toErrorMessage2 = (error) => {
5549
7362
  if (typeof error === "string" && error.trim().length > 0) {
5550
7363
  return error;
5551
7364
  }
@@ -5632,7 +7445,7 @@ var createVoiceCallReviewRecorder = (options = {}) => {
5632
7445
  };
5633
7446
  },
5634
7447
  recordError: (error) => {
5635
- const message = toErrorMessage(error);
7448
+ const message = toErrorMessage2(error);
5636
7449
  errors.push(message);
5637
7450
  push("turn", "error", {
5638
7451
  reason: message
@@ -6338,10 +8151,870 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
6338
8151
  });
6339
8152
  };
6340
8153
  // src/telephony/twilio.ts
6341
- import { Buffer as Buffer2 } from "buffer";
8154
+ import { Buffer as Buffer3 } from "buffer";
8155
+ import { Elysia as Elysia2 } from "elysia";
8156
+
8157
+ // src/telephonyOutcome.ts
8158
+ import { Elysia } from "elysia";
8159
+ var DEFAULT_COMPLETED_STATUSES = [
8160
+ "answered",
8161
+ "completed",
8162
+ "complete",
8163
+ "connected",
8164
+ "in-progress",
8165
+ "live"
8166
+ ];
8167
+ var DEFAULT_NO_ANSWER_STATUSES = [
8168
+ "busy",
8169
+ "canceled",
8170
+ "cancelled",
8171
+ "failed",
8172
+ "no-answer",
8173
+ "no_answer",
8174
+ "not-answered",
8175
+ "ring-no-answer",
8176
+ "timeout",
8177
+ "unanswered"
8178
+ ];
8179
+ var DEFAULT_VOICEMAIL_STATUSES = [
8180
+ "answering-machine",
8181
+ "machine",
8182
+ "voicemail",
8183
+ "voice-mail"
8184
+ ];
8185
+ var DEFAULT_TRANSFER_STATUSES = ["bridged", "forwarded", "transferred"];
8186
+ var DEFAULT_ESCALATION_STATUSES = ["escalated", "human-required", "operator"];
8187
+ var DEFAULT_FAILED_STATUSES = ["busy", "failed", "no-answer"];
8188
+ var DEFAULT_MACHINE_VOICEMAIL_VALUES = [
8189
+ "answering-machine",
8190
+ "fax",
8191
+ "machine",
8192
+ "machine-end-beep",
8193
+ "machine-end-other",
8194
+ "machine-start",
8195
+ "voicemail"
8196
+ ];
8197
+ var DEFAULT_NO_ANSWER_SIP_CODES = [408, 480, 486, 487, 603];
8198
+ var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
8199
+
8200
+ class VoiceTelephonyWebhookVerificationError extends Error {
8201
+ result;
8202
+ constructor(result) {
8203
+ super(result.ok ? "telephony webhook verified" : result.reason);
8204
+ this.name = "VoiceTelephonyWebhookVerificationError";
8205
+ this.result = result;
8206
+ }
8207
+ }
8208
+ var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
8209
+ const decisions = new Map;
8210
+ return {
8211
+ get: (key) => decisions.get(key),
8212
+ set: (key, decision) => {
8213
+ decisions.set(key, decision);
8214
+ }
8215
+ };
8216
+ };
8217
+ var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
8218
+ var firstString = (source, keys) => {
8219
+ for (const key of keys) {
8220
+ const value = source[key];
8221
+ if (typeof value === "string" && value.trim()) {
8222
+ return value.trim();
8223
+ }
8224
+ if (typeof value === "number" && Number.isFinite(value)) {
8225
+ return String(value);
8226
+ }
8227
+ }
8228
+ };
8229
+ var firstNumber = (source, keys) => {
8230
+ for (const key of keys) {
8231
+ const value = source[key];
8232
+ if (typeof value === "number" && Number.isFinite(value)) {
8233
+ return value;
8234
+ }
8235
+ if (typeof value === "string" && value.trim()) {
8236
+ const parsed = Number(value);
8237
+ if (Number.isFinite(parsed)) {
8238
+ return parsed;
8239
+ }
8240
+ }
8241
+ }
8242
+ };
8243
+ var parseMaybeJSON = (value) => {
8244
+ try {
8245
+ return JSON.parse(value);
8246
+ } catch {
8247
+ return;
8248
+ }
8249
+ };
8250
+ var flattenPayload = (value) => {
8251
+ if (!isRecord(value)) {
8252
+ return {};
8253
+ }
8254
+ const data = isRecord(value.data) ? value.data : undefined;
8255
+ const payload = isRecord(value.payload) ? value.payload : undefined;
8256
+ const event = isRecord(value.event) ? value.event : undefined;
8257
+ return {
8258
+ ...value,
8259
+ ...payload,
8260
+ ...event,
8261
+ ...data,
8262
+ ...isRecord(data?.payload) ? data.payload : undefined
8263
+ };
8264
+ };
8265
+ var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
8266
+ var timingSafeEqual = (left, right) => {
8267
+ const encoder = new TextEncoder;
8268
+ const leftBytes = encoder.encode(left);
8269
+ const rightBytes = encoder.encode(right);
8270
+ if (leftBytes.length !== rightBytes.length) {
8271
+ return false;
8272
+ }
8273
+ let diff = 0;
8274
+ for (let index = 0;index < leftBytes.length; index += 1) {
8275
+ diff |= leftBytes[index] ^ rightBytes[index];
8276
+ }
8277
+ return diff === 0;
8278
+ };
8279
+ var signHmacSHA1Base64 = async (secret, payload) => {
8280
+ const encoder = new TextEncoder;
8281
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
8282
+ hash: "SHA-1",
8283
+ name: "HMAC"
8284
+ }, false, ["sign"]);
8285
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
8286
+ return toBase64(signature);
8287
+ };
8288
+ var sortedParamsForSignature = (body) => Object.entries(flattenPayload(body)).filter(([, value]) => value !== undefined && value !== null).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => `${key}${String(value)}`).join("");
8289
+ var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
8290
+ var metadataValue = (metadata, keys) => {
8291
+ for (const key of keys) {
8292
+ const value = metadata?.[key];
8293
+ if (typeof value === "string" && value.trim()) {
8294
+ return value.trim();
8295
+ }
8296
+ }
8297
+ };
8298
+ var resolveTransferTarget = (event, policy) => {
8299
+ if (typeof event.target === "string" && event.target.trim()) {
8300
+ return event.target.trim();
8301
+ }
8302
+ const metadataTarget = metadataValue(event.metadata, [
8303
+ "transferTarget",
8304
+ "target",
8305
+ "queue",
8306
+ "department"
8307
+ ]);
8308
+ if (metadataTarget) {
8309
+ return metadataTarget;
8310
+ }
8311
+ if (typeof policy.transferTarget === "function") {
8312
+ const target = policy.transferTarget(event);
8313
+ return typeof target === "string" && target.trim() ? target.trim() : undefined;
8314
+ }
8315
+ return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
8316
+ };
8317
+ var mergeMetadata = (event, policy) => ({
8318
+ ...policy.includeProviderPayload ? {
8319
+ answeredBy: event.answeredBy,
8320
+ durationMs: event.durationMs,
8321
+ provider: event.provider,
8322
+ reason: event.reason,
8323
+ sipCode: event.sipCode,
8324
+ status: event.status
8325
+ } : undefined,
8326
+ ...policy.metadata,
8327
+ ...event.metadata
8328
+ });
8329
+ var withDecisionDefaults = (decision, input) => {
8330
+ if (typeof decision === "string") {
8331
+ return buildDecision(decision, input);
8332
+ }
8333
+ return {
8334
+ ...buildDecision(decision.action, input),
8335
+ ...decision,
8336
+ confidence: decision.confidence ?? "high",
8337
+ metadata: {
8338
+ ...mergeMetadata(input.event, input.policy),
8339
+ ...decision.metadata
8340
+ },
8341
+ source: decision.source ?? input.source,
8342
+ target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
8343
+ };
8344
+ };
8345
+ var dispositionForAction = (action) => {
8346
+ switch (action) {
8347
+ case "complete":
8348
+ return "completed";
8349
+ case "escalate":
8350
+ return "escalated";
8351
+ case "no-answer":
8352
+ return "no-answer";
8353
+ case "transfer":
8354
+ return "transferred";
8355
+ case "voicemail":
8356
+ return "voicemail";
8357
+ default:
8358
+ return;
8359
+ }
8360
+ };
8361
+ var buildDecision = (action, input) => ({
8362
+ action,
8363
+ confidence: action === "ignore" ? "low" : "high",
8364
+ disposition: dispositionForAction(action),
8365
+ metadata: mergeMetadata(input.event, input.policy),
8366
+ reason: input.event.reason,
8367
+ source: input.source,
8368
+ target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
8369
+ });
8370
+ var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
8371
+ completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
8372
+ escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
8373
+ failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
8374
+ failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
8375
+ includeProviderPayload: policy.includeProviderPayload ?? true,
8376
+ machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
8377
+ metadata: policy.metadata,
8378
+ minAnsweredDurationMs: policy.minAnsweredDurationMs,
8379
+ noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
8380
+ noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
8381
+ noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
8382
+ statusMap: policy.statusMap,
8383
+ transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
8384
+ transferTarget: policy.transferTarget,
8385
+ voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
8386
+ });
8387
+ var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
8388
+ const policy = createVoiceTelephonyOutcomePolicy(policyInput);
8389
+ const status = normalizeToken(event.status);
8390
+ const provider = normalizeToken(event.provider);
8391
+ const answeredBy = normalizeToken(event.answeredBy);
8392
+ const target = resolveTransferTarget(event, policy);
8393
+ if (status) {
8394
+ const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
8395
+ if (mapped) {
8396
+ return withDecisionDefaults(mapped, {
8397
+ event,
8398
+ policy,
8399
+ source: "policy"
8400
+ });
8401
+ }
8402
+ }
8403
+ if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
8404
+ return buildDecision("voicemail", { event, policy, source: "answered-by" });
8405
+ }
8406
+ if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
8407
+ return buildDecision("no-answer", { event, policy, source: "sip" });
8408
+ }
8409
+ if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
8410
+ return buildDecision("transfer", { event, policy, source: "status" });
8411
+ }
8412
+ if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
8413
+ return buildDecision("voicemail", { event, policy, source: "status" });
8414
+ }
8415
+ if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
8416
+ return buildDecision("escalate", { event, policy, source: "status" });
8417
+ }
8418
+ if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
8419
+ return buildDecision("no-answer", { event, policy, source: "status" });
8420
+ }
8421
+ if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
8422
+ return buildDecision("no-answer", { event, policy, source: "duration" });
8423
+ }
8424
+ if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
8425
+ return {
8426
+ ...buildDecision("no-answer", { event, policy, source: "duration" }),
8427
+ confidence: "medium"
8428
+ };
8429
+ }
8430
+ if (status && normalizeList(policy.completedStatuses, []).has(status)) {
8431
+ return buildDecision("complete", { event, policy, source: "status" });
8432
+ }
8433
+ if (target) {
8434
+ return {
8435
+ ...buildDecision("transfer", { event, policy, source: "explicit-target" }),
8436
+ confidence: "medium"
8437
+ };
8438
+ }
8439
+ return buildDecision("ignore", { event, policy, source: "status" });
8440
+ };
8441
+ var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
8442
+ switch (decision.action) {
8443
+ case "complete":
8444
+ return { complete: true, result };
8445
+ case "escalate":
8446
+ return {
8447
+ escalate: {
8448
+ metadata: decision.metadata,
8449
+ reason: decision.reason ?? "telephony-escalation"
8450
+ },
8451
+ result
8452
+ };
8453
+ case "no-answer":
8454
+ return {
8455
+ noAnswer: {
8456
+ metadata: decision.metadata
8457
+ },
8458
+ result
8459
+ };
8460
+ case "transfer":
8461
+ if (!decision.target) {
8462
+ return { result };
8463
+ }
8464
+ return {
8465
+ result,
8466
+ transfer: {
8467
+ metadata: decision.metadata,
8468
+ reason: decision.reason,
8469
+ target: decision.target
8470
+ }
8471
+ };
8472
+ case "voicemail":
8473
+ return {
8474
+ result,
8475
+ voicemail: {
8476
+ metadata: decision.metadata
8477
+ }
8478
+ };
8479
+ default:
8480
+ return { result };
8481
+ }
8482
+ };
8483
+ var applyVoiceTelephonyOutcome = async (api, decision, result) => {
8484
+ switch (decision.action) {
8485
+ case "complete":
8486
+ await api.complete(result);
8487
+ break;
8488
+ case "escalate":
8489
+ await api.escalate({
8490
+ metadata: decision.metadata,
8491
+ reason: decision.reason ?? "telephony-escalation",
8492
+ result
8493
+ });
8494
+ break;
8495
+ case "no-answer":
8496
+ await api.markNoAnswer({
8497
+ metadata: decision.metadata,
8498
+ result
8499
+ });
8500
+ break;
8501
+ case "transfer":
8502
+ if (!decision.target) {
8503
+ return;
8504
+ }
8505
+ await api.transfer({
8506
+ metadata: decision.metadata,
8507
+ reason: decision.reason,
8508
+ result,
8509
+ target: decision.target
8510
+ });
8511
+ break;
8512
+ case "voicemail":
8513
+ await api.markVoicemail({
8514
+ metadata: decision.metadata,
8515
+ result
8516
+ });
8517
+ break;
8518
+ default:
8519
+ break;
8520
+ }
8521
+ };
8522
+ var parseRequestBodyText = (input) => {
8523
+ const { contentType, text } = input;
8524
+ if (!text) {
8525
+ return {};
8526
+ }
8527
+ if (contentType.includes("application/json")) {
8528
+ return parseMaybeJSON(text) ?? {};
8529
+ }
8530
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
8531
+ return Object.fromEntries(new URLSearchParams(text));
8532
+ }
8533
+ return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
8534
+ };
8535
+ var readRequestBody = async (request) => {
8536
+ const contentType = request.headers.get("content-type") ?? "";
8537
+ const text = await request.text();
8538
+ return {
8539
+ body: parseRequestBodyText({ contentType, text }),
8540
+ rawBody: text
8541
+ };
8542
+ };
8543
+ var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
8544
+ var verifyVoiceTwilioWebhookSignature = async (input) => {
8545
+ if (!input.authToken) {
8546
+ return { ok: false, reason: "missing-secret" };
8547
+ }
8548
+ const signature = input.headers.get("x-twilio-signature");
8549
+ if (!signature) {
8550
+ return { ok: false, reason: "missing-signature" };
8551
+ }
8552
+ const expected = await signVoiceTwilioWebhook({
8553
+ authToken: input.authToken,
8554
+ body: input.body,
8555
+ url: input.url
8556
+ });
8557
+ return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
8558
+ };
8559
+ var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
8560
+ var verifyVoiceTelephonyWebhook = async (input) => {
8561
+ if (input.options.verify) {
8562
+ return input.options.verify({
8563
+ body: input.body,
8564
+ headers: input.request.headers,
8565
+ provider: input.provider,
8566
+ query: input.query,
8567
+ rawBody: input.rawBody,
8568
+ request: input.request
8569
+ });
8570
+ }
8571
+ if (!input.options.signingSecret) {
8572
+ return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
8573
+ }
8574
+ if (input.provider !== "twilio") {
8575
+ return { ok: false, reason: "unsupported-provider" };
8576
+ }
8577
+ return verifyVoiceTwilioWebhookSignature({
8578
+ authToken: input.options.signingSecret,
8579
+ body: input.body,
8580
+ headers: input.request.headers,
8581
+ url: resolveVerificationUrl(input.options.verificationUrl, {
8582
+ query: input.query,
8583
+ request: input.request
8584
+ })
8585
+ });
8586
+ };
8587
+ var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
8588
+ var parseVoiceTelephonyWebhookEvent = (input) => {
8589
+ const payload = flattenPayload(input.body);
8590
+ const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
8591
+ const status = firstString(payload, [
8592
+ "CallStatus",
8593
+ "call_status",
8594
+ "callStatus",
8595
+ "DialCallStatus",
8596
+ "dial_call_status",
8597
+ "status",
8598
+ "event_type",
8599
+ "type"
8600
+ ]);
8601
+ const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
8602
+ "CallDuration",
8603
+ "call_duration",
8604
+ "callDuration",
8605
+ "DialCallDuration",
8606
+ "dial_call_duration",
8607
+ "duration"
8608
+ ]));
8609
+ const sipCode = firstNumber(payload, [
8610
+ "SipResponseCode",
8611
+ "sip_response_code",
8612
+ "sipCode",
8613
+ "sip_code",
8614
+ "hangupCauseCode"
8615
+ ]);
8616
+ const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
8617
+ const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
8618
+ const target = firstString(payload, [
8619
+ "transferTarget",
8620
+ "TransferTarget",
8621
+ "target",
8622
+ "queue",
8623
+ "department"
8624
+ ]);
8625
+ return {
8626
+ answeredBy: firstString(payload, [
8627
+ "AnsweredBy",
8628
+ "answered_by",
8629
+ "answeredBy",
8630
+ "machineDetection",
8631
+ "machine_detection"
8632
+ ]),
8633
+ durationMs,
8634
+ from,
8635
+ metadata: {
8636
+ ...input.query,
8637
+ ...payload
8638
+ },
8639
+ provider,
8640
+ reason: firstString(payload, [
8641
+ "Reason",
8642
+ "reason",
8643
+ "HangupCause",
8644
+ "hangup_cause",
8645
+ "hangupCause"
8646
+ ]),
8647
+ sipCode,
8648
+ status,
8649
+ target,
8650
+ to
8651
+ };
8652
+ };
8653
+ var defaultSessionId = (input) => {
8654
+ const payload = flattenPayload(input.body);
8655
+ const metadataSessionId = input.event.metadata?.sessionId;
8656
+ return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
8657
+ "sessionId",
8658
+ "session_id",
8659
+ "SessionId",
8660
+ "CallSid",
8661
+ "call_sid",
8662
+ "callSid",
8663
+ "CallUUID",
8664
+ "call_uuid",
8665
+ "callControlId",
8666
+ "call_control_id"
8667
+ ]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
8668
+ };
8669
+ var defaultIdempotencyKey = (input) => {
8670
+ const payload = flattenPayload(input.body);
8671
+ const eventId = firstString(payload, [
8672
+ "id",
8673
+ "event_id",
8674
+ "eventId",
8675
+ "EventSid",
8676
+ "event_sid",
8677
+ "MessageSid",
8678
+ "message_sid",
8679
+ "CallSid",
8680
+ "call_sid",
8681
+ "CallUUID",
8682
+ "call_uuid",
8683
+ "callControlId",
8684
+ "call_control_id"
8685
+ ]);
8686
+ const status = normalizeToken(input.event.status) ?? "unknown";
8687
+ if (eventId) {
8688
+ return `${input.provider}:${eventId}:${status}`;
8689
+ }
8690
+ if (input.sessionId) {
8691
+ return `${input.provider}:${input.sessionId}:${status}`;
8692
+ }
8693
+ };
8694
+ var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
8695
+ const provider = options.provider ?? "generic";
8696
+ const query = input.query ?? {};
8697
+ const { body, rawBody } = await readRequestBody(input.request);
8698
+ const verification = await verifyVoiceTelephonyWebhook({
8699
+ body,
8700
+ options,
8701
+ provider,
8702
+ query,
8703
+ rawBody,
8704
+ request: input.request
8705
+ });
8706
+ if (!verification.ok) {
8707
+ throw new VoiceTelephonyWebhookVerificationError(verification);
8708
+ }
8709
+ const event = options.parse ? await options.parse({
8710
+ body,
8711
+ headers: input.request.headers,
8712
+ provider,
8713
+ query,
8714
+ request: input.request
8715
+ }) : parseVoiceTelephonyWebhookEvent({
8716
+ body,
8717
+ headers: input.request.headers,
8718
+ provider,
8719
+ query,
8720
+ request: input.request
8721
+ });
8722
+ const sessionId = await (options.resolveSessionId?.({
8723
+ body,
8724
+ event,
8725
+ query,
8726
+ request: input.request
8727
+ }) ?? defaultSessionId({ body, event, query }));
8728
+ const idempotencyEnabled = options.idempotency?.enabled !== false;
8729
+ const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
8730
+ body,
8731
+ event,
8732
+ provider,
8733
+ query,
8734
+ request: input.request,
8735
+ sessionId
8736
+ }) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
8737
+ const idempotencyStore = options.idempotency?.store;
8738
+ if (idempotencyKey && idempotencyStore) {
8739
+ const existing = await idempotencyStore.get(idempotencyKey);
8740
+ if (existing) {
8741
+ const duplicateDecision = {
8742
+ ...existing,
8743
+ duplicate: true
8744
+ };
8745
+ await options.onDecision?.({
8746
+ ...duplicateDecision,
8747
+ context: options.context,
8748
+ request: input.request
8749
+ });
8750
+ return duplicateDecision;
8751
+ }
8752
+ }
8753
+ const decision = resolveVoiceTelephonyOutcome(event, options.policy);
8754
+ const resultResolver = options.result;
8755
+ const result = typeof resultResolver === "function" ? await resultResolver({
8756
+ decision,
8757
+ event,
8758
+ sessionId
8759
+ }) : resultResolver;
8760
+ const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
8761
+ const shouldApply = typeof options.apply === "function" ? options.apply({
8762
+ applied: false,
8763
+ decision,
8764
+ event,
8765
+ routeResult,
8766
+ sessionId
8767
+ }) : options.apply === true;
8768
+ let applied = false;
8769
+ if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
8770
+ const api = await options.getSessionHandle({
8771
+ context: options.context,
8772
+ decision,
8773
+ event,
8774
+ request: input.request,
8775
+ sessionId
8776
+ });
8777
+ if (api) {
8778
+ await applyVoiceTelephonyOutcome(api, decision, result);
8779
+ applied = true;
8780
+ }
8781
+ }
8782
+ const webhookDecision = {
8783
+ applied,
8784
+ decision,
8785
+ event,
8786
+ idempotencyKey,
8787
+ routeResult,
8788
+ sessionId
8789
+ };
8790
+ if (idempotencyKey && idempotencyStore) {
8791
+ const now = Date.now();
8792
+ await idempotencyStore.set(idempotencyKey, {
8793
+ ...webhookDecision,
8794
+ createdAt: now,
8795
+ updatedAt: now
8796
+ });
8797
+ }
8798
+ await options.onDecision?.({
8799
+ ...webhookDecision,
8800
+ context: options.context,
8801
+ request: input.request
8802
+ });
8803
+ return webhookDecision;
8804
+ };
8805
+ var createVoiceTelephonyWebhookRoutes = (options = {}) => {
8806
+ const path = options.path ?? "/api/voice/telephony/webhook";
8807
+ const handler = createVoiceTelephonyWebhookHandler(options);
8808
+ return new Elysia({
8809
+ name: options.name ?? "absolutejs-voice-telephony-webhooks"
8810
+ }).post(path, async ({ query, request }) => {
8811
+ try {
8812
+ return await handler({ query, request });
8813
+ } catch (error) {
8814
+ if (error instanceof VoiceTelephonyWebhookVerificationError) {
8815
+ return new Response(JSON.stringify({ verification: error.result }), {
8816
+ headers: {
8817
+ "content-type": "application/json"
8818
+ },
8819
+ status: 401
8820
+ });
8821
+ }
8822
+ throw error;
8823
+ }
8824
+ }, {
8825
+ parse: "none"
8826
+ });
8827
+ };
8828
+
8829
+ // src/telephony/twilio.ts
6342
8830
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
6343
8831
  var VOICE_PCM_SAMPLE_RATE = 16000;
6344
- var escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8832
+ var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8833
+ var resolveRequestOrigin = (request) => {
8834
+ const url = new URL(request.url);
8835
+ const forwardedHost = request.headers.get("x-forwarded-host");
8836
+ const forwardedProto = request.headers.get("x-forwarded-proto");
8837
+ const host = forwardedHost ?? request.headers.get("host") ?? url.host;
8838
+ const protocol = forwardedProto ?? url.protocol.replace(":", "");
8839
+ return `${protocol}://${host}`;
8840
+ };
8841
+ var resolveTwilioStreamUrl = async (options, input) => {
8842
+ if (typeof options.twiml?.streamUrl === "function") {
8843
+ return options.twiml.streamUrl(input);
8844
+ }
8845
+ if (typeof options.twiml?.streamUrl === "string") {
8846
+ return options.twiml.streamUrl;
8847
+ }
8848
+ const origin = resolveRequestOrigin(input.request);
8849
+ const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
8850
+ return `${wsOrigin}${input.streamPath}`;
8851
+ };
8852
+ var resolveTwilioStreamParameters = async (parameters, input) => {
8853
+ if (typeof parameters === "function") {
8854
+ return parameters(input);
8855
+ }
8856
+ return parameters;
8857
+ };
8858
+ var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
8859
+ var escapeHtml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8860
+ var getWebhookVerificationUrl = (webhook, input) => {
8861
+ if (!webhook?.verificationUrl) {
8862
+ return;
8863
+ }
8864
+ if (typeof webhook.verificationUrl === "function") {
8865
+ return webhook.verificationUrl(input);
8866
+ }
8867
+ return webhook.verificationUrl;
8868
+ };
8869
+ var buildTwilioVoiceSetupStatus = async (options, input) => {
8870
+ const origin = resolveRequestOrigin(input.request);
8871
+ const stream = await resolveTwilioStreamUrl(options, input);
8872
+ const twiml = joinUrlPath(origin, input.twimlPath);
8873
+ const webhook = joinUrlPath(origin, input.webhookPath);
8874
+ const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
8875
+ const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
8876
+ const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
8877
+ const warnings = [
8878
+ ...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
8879
+ ...signingConfigured ? [] : ["Webhook signature verification is not configured."],
8880
+ ...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
8881
+ ];
8882
+ return {
8883
+ generatedAt: Date.now(),
8884
+ missing,
8885
+ provider: "twilio",
8886
+ ready: missing.length === 0 && signingConfigured && warnings.length === 0,
8887
+ signing: {
8888
+ configured: signingConfigured,
8889
+ mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
8890
+ verificationUrl
8891
+ },
8892
+ urls: {
8893
+ stream,
8894
+ twiml,
8895
+ webhook
8896
+ },
8897
+ warnings
8898
+ };
8899
+ };
8900
+ var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
8901
+ <p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
8902
+ <h1>${escapeHtml2(title)}</h1>
8903
+ <p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
8904
+ <section>
8905
+ <h2>URLs</h2>
8906
+ <ul>
8907
+ <li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
8908
+ <li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
8909
+ <li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
8910
+ </ul>
8911
+ </section>
8912
+ <section>
8913
+ <h2>Signing</h2>
8914
+ <p>Mode: <code>${status.signing.mode}</code></p>
8915
+ ${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
8916
+ </section>
8917
+ ${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
8918
+ ${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
8919
+ </main>`;
8920
+ var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&amp;", "&");
8921
+ var createSmokeCheck = (name, status, message, details) => ({
8922
+ details,
8923
+ message,
8924
+ name,
8925
+ status
8926
+ });
8927
+ var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
8928
+ <p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
8929
+ <h1>${escapeHtml2(title)}</h1>
8930
+ <p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
8931
+ <section>
8932
+ <h2>Checks</h2>
8933
+ <ul>
8934
+ ${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
8935
+ </ul>
8936
+ </section>
8937
+ <section>
8938
+ <h2>Observed URLs</h2>
8939
+ <ul>
8940
+ <li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
8941
+ <li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
8942
+ <li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
8943
+ </ul>
8944
+ </section>
8945
+ </main>`;
8946
+ var runTwilioVoiceSmokeTest = async (input) => {
8947
+ const setup = await buildTwilioVoiceSetupStatus(input.options, input);
8948
+ const checks = [];
8949
+ const twimlUrl = new URL(setup.urls.twiml);
8950
+ twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
8951
+ twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
8952
+ const twimlResponse = await input.app.handle(new Request(twimlUrl, {
8953
+ headers: input.request.headers
8954
+ }));
8955
+ const twiml = await twimlResponse.text();
8956
+ const streamUrl = extractTwilioStreamUrl(twiml);
8957
+ checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
8958
+ status: twimlResponse.status,
8959
+ streamUrl
8960
+ }));
8961
+ checks.push(createSmokeCheck("stream-url", streamUrl?.startsWith("wss://") ? "pass" : "fail", streamUrl?.startsWith("wss://") ? "Media stream URL uses wss://." : "Media stream URL should use wss:// for Twilio.", {
8962
+ streamUrl
8963
+ }));
8964
+ const webhookBody = {
8965
+ CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
8966
+ CallStatus: input.options.smoke?.status ?? "busy",
8967
+ SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
8968
+ };
8969
+ const webhookHeaders = new Headers({
8970
+ "content-type": "application/x-www-form-urlencoded"
8971
+ });
8972
+ const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
8973
+ if (input.options.webhook?.signingSecret) {
8974
+ webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
8975
+ authToken: input.options.webhook.signingSecret,
8976
+ body: webhookBody,
8977
+ url: verificationUrl
8978
+ }));
8979
+ }
8980
+ const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
8981
+ body: new URLSearchParams(webhookBody),
8982
+ headers: webhookHeaders,
8983
+ method: "POST"
8984
+ }));
8985
+ const webhookText = await webhookResponse.text();
8986
+ const webhookPayload = (() => {
8987
+ try {
8988
+ return JSON.parse(webhookText);
8989
+ } catch {
8990
+ return webhookText;
8991
+ }
8992
+ })();
8993
+ checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
8994
+ status: webhookResponse.status
8995
+ }));
8996
+ for (const warning of setup.warnings) {
8997
+ checks.push(createSmokeCheck("setup-warning", "warn", warning));
8998
+ }
8999
+ for (const name of setup.missing) {
9000
+ checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
9001
+ }
9002
+ return {
9003
+ checks,
9004
+ generatedAt: Date.now(),
9005
+ pass: checks.every((check) => check.status !== "fail"),
9006
+ provider: "twilio",
9007
+ setup,
9008
+ twiml: {
9009
+ status: twimlResponse.status,
9010
+ streamUrl
9011
+ },
9012
+ webhook: {
9013
+ body: webhookPayload,
9014
+ status: webhookResponse.status
9015
+ }
9016
+ };
9017
+ };
6345
9018
  var normalizeOnTurn = (handler) => {
6346
9019
  if (handler.length > 1) {
6347
9020
  const directHandler = handler;
@@ -6443,7 +9116,7 @@ var bytesToInt16Array = (bytes) => {
6443
9116
  return output;
6444
9117
  };
6445
9118
  var decodeTwilioMulawBase64 = (payload) => {
6446
- const bytes = Uint8Array.from(Buffer2.from(payload, "base64"));
9119
+ const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
6447
9120
  const samples = new Int16Array(bytes.length);
6448
9121
  for (let index = 0;index < bytes.length; index += 1) {
6449
9122
  samples[index] = decodeMulawSample(bytes[index] ?? 0);
@@ -6455,7 +9128,7 @@ var encodeTwilioMulawBase64 = (samples) => {
6455
9128
  for (let index = 0;index < samples.length; index += 1) {
6456
9129
  bytes[index] = encodeMulawSample(samples[index] ?? 0);
6457
9130
  }
6458
- return Buffer2.from(bytes).toString("base64");
9131
+ return Buffer3.from(bytes).toString("base64");
6459
9132
  };
6460
9133
  var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
6461
9134
  const narrowband = decodeTwilioMulawBase64(payload);
@@ -6464,7 +9137,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
6464
9137
  };
6465
9138
  var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
6466
9139
  if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
6467
- return Buffer2.from(chunk).toString("base64");
9140
+ return Buffer3.from(chunk).toString("base64");
6468
9141
  }
6469
9142
  if (format.encoding !== "pcm_s16le") {
6470
9143
  throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
@@ -6505,7 +9178,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
6505
9178
  return;
6506
9179
  }
6507
9180
  if (message.type === "audio") {
6508
- const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer2.from(message.chunkBase64, "base64")), message.format);
9181
+ const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
6509
9182
  state.hasOutboundAudioSinceLastInbound = true;
6510
9183
  state.reviewRecorder?.recordTwilioOutbound({
6511
9184
  bytes: payload.length,
@@ -6537,8 +9210,8 @@ var createTwilioSocketAdapter = (socket, getState) => ({
6537
9210
  }
6538
9211
  });
6539
9212
  var createTwilioVoiceResponse = (options) => {
6540
- const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml(name)}" value="${escapeXml(String(value))}" />`).join("");
6541
- return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml(options.streamUrl)}"${options.track ? ` track="${escapeXml(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
9213
+ const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml2(name)}" value="${escapeXml2(String(value))}" />`).join("");
9214
+ return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml2(options.streamUrl)}"${options.track ? ` track="${escapeXml2(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml2(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
6542
9215
  };
6543
9216
  var createTwilioMediaStreamBridge = (socket, options) => {
6544
9217
  const runtimePreset = resolveVoiceRuntimePreset(options.preset);
@@ -6718,6 +9391,148 @@ var createTwilioMediaStreamBridge = (socket, options) => {
6718
9391
  }
6719
9392
  };
6720
9393
  };
9394
+ var createTwilioVoiceRoutes = (options) => {
9395
+ const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
9396
+ const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
9397
+ const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
9398
+ const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
9399
+ const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
9400
+ const bridges = new WeakMap;
9401
+ const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
9402
+ const app = new Elysia2({
9403
+ name: options.name ?? "absolutejs-voice-twilio"
9404
+ }).get(twimlPath, async ({ query, request }) => {
9405
+ const streamUrl = await resolveTwilioStreamUrl(options, {
9406
+ query,
9407
+ request,
9408
+ streamPath
9409
+ });
9410
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
9411
+ query,
9412
+ request
9413
+ });
9414
+ return new Response(createTwilioVoiceResponse({
9415
+ parameters,
9416
+ streamName: options.twiml?.streamName,
9417
+ streamUrl,
9418
+ track: options.twiml?.track
9419
+ }), {
9420
+ headers: {
9421
+ "content-type": "text/xml; charset=utf-8"
9422
+ }
9423
+ });
9424
+ }).post(twimlPath, async ({ query, request }) => {
9425
+ const streamUrl = await resolveTwilioStreamUrl(options, {
9426
+ query,
9427
+ request,
9428
+ streamPath
9429
+ });
9430
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
9431
+ query,
9432
+ request
9433
+ });
9434
+ return new Response(createTwilioVoiceResponse({
9435
+ parameters,
9436
+ streamName: options.twiml?.streamName,
9437
+ streamUrl,
9438
+ track: options.twiml?.track
9439
+ }), {
9440
+ headers: {
9441
+ "content-type": "text/xml; charset=utf-8"
9442
+ }
9443
+ });
9444
+ }).ws(streamPath, {
9445
+ close: async (ws, _code, reason) => {
9446
+ const bridge = bridges.get(ws);
9447
+ bridges.delete(ws);
9448
+ await bridge?.close(reason);
9449
+ },
9450
+ message: async (ws, raw) => {
9451
+ let bridge = bridges.get(ws);
9452
+ if (!bridge) {
9453
+ bridge = createTwilioMediaStreamBridge({
9454
+ close: (code, reason) => {
9455
+ ws.close(code, reason);
9456
+ },
9457
+ send: (data) => {
9458
+ ws.send(data);
9459
+ }
9460
+ }, options);
9461
+ bridges.set(ws, bridge);
9462
+ }
9463
+ await bridge.handleMessage(raw);
9464
+ }
9465
+ }).use(createVoiceTelephonyWebhookRoutes({
9466
+ ...options.webhook ?? {},
9467
+ context: options.context,
9468
+ path: webhookPath,
9469
+ policy: webhookPolicy,
9470
+ provider: "twilio"
9471
+ }));
9472
+ if (!setupPath) {
9473
+ if (!smokePath) {
9474
+ return app;
9475
+ }
9476
+ return app.get(smokePath, async ({ query, request }) => {
9477
+ const report = await runTwilioVoiceSmokeTest({
9478
+ app,
9479
+ options,
9480
+ query,
9481
+ request,
9482
+ streamPath,
9483
+ twimlPath,
9484
+ webhookPath
9485
+ });
9486
+ if (query.format === "html") {
9487
+ return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
9488
+ headers: {
9489
+ "content-type": "text/html; charset=utf-8"
9490
+ }
9491
+ });
9492
+ }
9493
+ return report;
9494
+ });
9495
+ }
9496
+ const withSetup = app.get(setupPath, async ({ query, request }) => {
9497
+ const status = await buildTwilioVoiceSetupStatus(options, {
9498
+ query,
9499
+ request,
9500
+ streamPath,
9501
+ twimlPath,
9502
+ webhookPath
9503
+ });
9504
+ if (query.format === "html") {
9505
+ return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
9506
+ headers: {
9507
+ "content-type": "text/html; charset=utf-8"
9508
+ }
9509
+ });
9510
+ }
9511
+ return status;
9512
+ });
9513
+ if (!smokePath) {
9514
+ return withSetup;
9515
+ }
9516
+ return withSetup.get(smokePath, async ({ query, request }) => {
9517
+ const report = await runTwilioVoiceSmokeTest({
9518
+ app,
9519
+ options,
9520
+ query,
9521
+ request,
9522
+ streamPath,
9523
+ twimlPath,
9524
+ webhookPath
9525
+ });
9526
+ if (query.format === "html") {
9527
+ return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
9528
+ headers: {
9529
+ "content-type": "text/html; charset=utf-8"
9530
+ }
9531
+ });
9532
+ }
9533
+ return report;
9534
+ });
9535
+ };
6721
9536
 
6722
9537
  // src/testing/telephony.ts
6723
9538
  var DEFAULT_PCM16_FORMAT = {
@@ -6983,7 +9798,7 @@ var runVoiceTelephonyBenchmark = async (scenarios = getDefaultVoiceTelephonyBenc
6983
9798
  };
6984
9799
  };
6985
9800
  // src/testing/tts.ts
6986
- var DEFAULT_REALTIME_FORMAT = {
9801
+ var DEFAULT_REALTIME_FORMAT2 = {
6987
9802
  channels: 1,
6988
9803
  container: "raw",
6989
9804
  encoding: "pcm_s16le",
@@ -7042,7 +9857,7 @@ var runTTSAdapterFixture = async (adapter, fixture, options = {}) => {
7042
9857
  let audioDurationMs = 0;
7043
9858
  let audioChunkCount = 0;
7044
9859
  const session = adapter.kind === "realtime" ? await adapter.open({
7045
- format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT,
9860
+ format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT2,
7046
9861
  sessionId: `tts-benchmark:${fixture.id}`,
7047
9862
  ...openOptions ?? {}
7048
9863
  }) : await adapter.open({
@@ -7208,6 +10023,8 @@ export {
7208
10023
  getDefaultVoiceDuplexBenchmarkScenarios,
7209
10024
  getDefaultTTSBenchmarkFixtures,
7210
10025
  evaluateSTTBenchmarkAcceptance,
10026
+ createVoiceProviderFailureSimulator,
10027
+ createVoiceIOProviderFailureSimulator,
7211
10028
  createVoiceCallReviewRecorder,
7212
10029
  createVoiceCallReviewFromLiveTelephonyReport,
7213
10030
  createTelephonyVoiceTestFixtures,