@absolutejs/voice 0.0.22-beta.28 → 0.0.22-beta.281

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 (228) hide show
  1. package/README.md +3234 -228
  2. package/dist/agent.d.ts +62 -0
  3. package/dist/agentSquadContract.d.ts +98 -0
  4. package/dist/angular/index.d.ts +15 -0
  5. package/dist/angular/index.js +3401 -1153
  6. package/dist/angular/voice-agent-squad-status.service.d.ts +12 -0
  7. package/dist/angular/voice-campaign-dialer-proof.service.d.ts +14 -0
  8. package/dist/angular/voice-controller.service.d.ts +1 -0
  9. package/dist/angular/voice-delivery-runtime.component.d.ts +17 -0
  10. package/dist/angular/voice-delivery-runtime.service.d.ts +16 -0
  11. package/dist/angular/voice-live-ops.service.d.ts +11 -0
  12. package/dist/angular/voice-ops-action-center.service.d.ts +13 -0
  13. package/dist/angular/voice-ops-status.component.d.ts +15 -0
  14. package/dist/angular/voice-ops-status.service.d.ts +12 -0
  15. package/dist/angular/voice-platform-coverage.service.d.ts +12 -0
  16. package/dist/angular/voice-proof-trends.service.d.ts +12 -0
  17. package/dist/angular/voice-provider-capabilities.service.d.ts +12 -0
  18. package/dist/angular/voice-provider-contracts.service.d.ts +12 -0
  19. package/dist/angular/voice-routing-status.service.d.ts +11 -0
  20. package/dist/angular/voice-stream.service.d.ts +1 -0
  21. package/dist/angular/voice-trace-timeline.service.d.ts +12 -0
  22. package/dist/angular/voice-turn-latency.service.d.ts +13 -0
  23. package/dist/angular/voice-turn-quality.service.d.ts +12 -0
  24. package/dist/angular/voice-workflow-status.service.d.ts +12 -0
  25. package/dist/audit.d.ts +128 -0
  26. package/dist/auditDeliveryRoutes.d.ts +85 -0
  27. package/dist/auditExport.d.ts +34 -0
  28. package/dist/auditRoutes.d.ts +66 -0
  29. package/dist/auditSinks.d.ts +151 -0
  30. package/dist/bargeInRoutes.d.ts +56 -0
  31. package/dist/campaign.d.ts +768 -0
  32. package/dist/campaignDialers.d.ts +111 -0
  33. package/dist/client/actions.d.ts +83 -0
  34. package/dist/client/agentSquadStatus.d.ts +37 -0
  35. package/dist/client/agentSquadStatusWidget.d.ts +24 -0
  36. package/dist/client/bargeInMonitor.d.ts +7 -0
  37. package/dist/client/campaignDialerProof.d.ts +23 -0
  38. package/dist/client/deliveryRuntime.d.ts +34 -0
  39. package/dist/client/deliveryRuntimeWidget.d.ts +37 -0
  40. package/dist/client/duplex.d.ts +1 -1
  41. package/dist/client/htmxBootstrap.js +703 -13
  42. package/dist/client/index.d.ts +66 -0
  43. package/dist/client/index.js +5073 -19
  44. package/dist/client/liveOps.d.ts +22 -0
  45. package/dist/client/liveOpsWidget.d.ts +23 -0
  46. package/dist/client/liveTurnLatency.d.ts +41 -0
  47. package/dist/client/opsActionCenter.d.ts +54 -0
  48. package/dist/client/opsActionCenterWidget.d.ts +29 -0
  49. package/dist/client/opsActionHistory.d.ts +19 -0
  50. package/dist/client/opsActionHistoryWidget.d.ts +11 -0
  51. package/dist/client/opsStatus.d.ts +19 -0
  52. package/dist/client/opsStatusWidget.d.ts +40 -0
  53. package/dist/client/platformCoverage.d.ts +19 -0
  54. package/dist/client/platformCoverageWidget.d.ts +37 -0
  55. package/dist/client/proofTrends.d.ts +19 -0
  56. package/dist/client/proofTrendsWidget.d.ts +37 -0
  57. package/dist/client/providerCapabilities.d.ts +19 -0
  58. package/dist/client/providerCapabilitiesWidget.d.ts +32 -0
  59. package/dist/client/providerContracts.d.ts +19 -0
  60. package/dist/client/providerContractsWidget.d.ts +37 -0
  61. package/dist/client/providerSimulationControls.d.ts +33 -0
  62. package/dist/client/providerSimulationControlsWidget.d.ts +20 -0
  63. package/dist/client/providerStatusWidget.d.ts +32 -0
  64. package/dist/client/routingStatus.d.ts +19 -0
  65. package/dist/client/routingStatusWidget.d.ts +28 -0
  66. package/dist/client/traceTimeline.d.ts +19 -0
  67. package/dist/client/traceTimelineWidget.d.ts +36 -0
  68. package/dist/client/turnLatency.d.ts +22 -0
  69. package/dist/client/turnLatencyWidget.d.ts +33 -0
  70. package/dist/client/turnQuality.d.ts +19 -0
  71. package/dist/client/turnQualityWidget.d.ts +32 -0
  72. package/dist/client/workflowStatus.d.ts +19 -0
  73. package/dist/dataControl.d.ts +180 -0
  74. package/dist/deliveryRuntime.d.ts +158 -0
  75. package/dist/deliverySinkRoutes.d.ts +117 -0
  76. package/dist/demoReadyRoutes.d.ts +98 -0
  77. package/dist/diagnosticsRoutes.d.ts +44 -0
  78. package/dist/evalRoutes.d.ts +219 -0
  79. package/dist/fileStore.d.ts +14 -2
  80. package/dist/guardrails.d.ts +128 -0
  81. package/dist/handoff.d.ts +54 -0
  82. package/dist/handoffHealth.d.ts +94 -0
  83. package/dist/incidentBundle.d.ts +116 -0
  84. package/dist/index.d.ts +138 -13
  85. package/dist/index.js +26908 -4937
  86. package/dist/latencySlo.d.ts +56 -0
  87. package/dist/liveLatency.d.ts +78 -0
  88. package/dist/liveOps.d.ts +190 -0
  89. package/dist/modelAdapters.d.ts +23 -2
  90. package/dist/observabilityExport.d.ts +481 -0
  91. package/dist/openaiRealtime.d.ts +27 -0
  92. package/dist/openaiTTS.d.ts +18 -0
  93. package/dist/operationsRecord.d.ts +210 -0
  94. package/dist/opsActionAuditRoutes.d.ts +99 -0
  95. package/dist/opsConsoleRoutes.d.ts +80 -0
  96. package/dist/opsRecovery.d.ts +137 -0
  97. package/dist/opsStatus.d.ts +76 -0
  98. package/dist/opsStatusRoutes.d.ts +33 -0
  99. package/dist/outcomeContract.d.ts +146 -0
  100. package/dist/phoneAgent.d.ts +139 -0
  101. package/dist/phoneAgentProductionSmoke.d.ts +115 -0
  102. package/dist/platformCoverage.d.ts +91 -0
  103. package/dist/postCallAnalysis.d.ts +98 -0
  104. package/dist/postgresStore.d.ts +13 -2
  105. package/dist/productionReadiness.d.ts +521 -0
  106. package/dist/proofTrends.d.ts +133 -0
  107. package/dist/providerAdapters.d.ts +48 -0
  108. package/dist/providerCapabilities.d.ts +92 -0
  109. package/dist/providerHealth.d.ts +1 -0
  110. package/dist/providerRoutingContract.d.ts +71 -0
  111. package/dist/providerSlo.d.ts +142 -0
  112. package/dist/providerStackRecommendations.d.ts +187 -0
  113. package/dist/qualityRoutes.d.ts +76 -0
  114. package/dist/queue.d.ts +61 -0
  115. package/dist/react/VoiceAgentSquadStatus.d.ts +5 -0
  116. package/dist/react/VoiceDeliveryRuntime.d.ts +7 -0
  117. package/dist/react/VoiceOpsActionCenter.d.ts +5 -0
  118. package/dist/react/VoiceOpsStatus.d.ts +6 -0
  119. package/dist/react/VoicePlatformCoverage.d.ts +6 -0
  120. package/dist/react/VoiceProofTrends.d.ts +6 -0
  121. package/dist/react/VoiceProviderCapabilities.d.ts +6 -0
  122. package/dist/react/VoiceProviderContracts.d.ts +6 -0
  123. package/dist/react/VoiceProviderSimulationControls.d.ts +5 -0
  124. package/dist/react/VoiceProviderStatus.d.ts +6 -0
  125. package/dist/react/VoiceRoutingStatus.d.ts +6 -0
  126. package/dist/react/VoiceTraceTimeline.d.ts +6 -0
  127. package/dist/react/VoiceTurnLatency.d.ts +6 -0
  128. package/dist/react/VoiceTurnQuality.d.ts +6 -0
  129. package/dist/react/index.d.ts +30 -0
  130. package/dist/react/index.js +4769 -31
  131. package/dist/react/useVoiceAgentSquadStatus.d.ts +8 -0
  132. package/dist/react/useVoiceCampaignDialerProof.d.ts +10 -0
  133. package/dist/react/useVoiceController.d.ts +1 -0
  134. package/dist/react/useVoiceDeliveryRuntime.d.ts +13 -0
  135. package/dist/react/useVoiceLiveOps.d.ts +9 -0
  136. package/dist/react/useVoiceOpsActionCenter.d.ts +11 -0
  137. package/dist/react/useVoiceOpsStatus.d.ts +8 -0
  138. package/dist/react/useVoicePlatformCoverage.d.ts +8 -0
  139. package/dist/react/useVoiceProofTrends.d.ts +8 -0
  140. package/dist/react/useVoiceProviderCapabilities.d.ts +8 -0
  141. package/dist/react/useVoiceProviderContracts.d.ts +8 -0
  142. package/dist/react/useVoiceProviderSimulationControls.d.ts +10 -0
  143. package/dist/react/useVoiceRoutingStatus.d.ts +8 -0
  144. package/dist/react/useVoiceStream.d.ts +1 -0
  145. package/dist/react/useVoiceTraceTimeline.d.ts +8 -0
  146. package/dist/react/useVoiceTurnLatency.d.ts +9 -0
  147. package/dist/react/useVoiceTurnQuality.d.ts +8 -0
  148. package/dist/react/useVoiceWorkflowStatus.d.ts +8 -0
  149. package/dist/readinessProfiles.d.ts +38 -0
  150. package/dist/reconnectContract.d.ts +87 -0
  151. package/dist/resilienceRoutes.d.ts +143 -0
  152. package/dist/sessionReplay.d.ts +12 -0
  153. package/dist/simulationSuite.d.ts +143 -0
  154. package/dist/sloCalibration.d.ts +104 -0
  155. package/dist/sqliteStore.d.ts +13 -2
  156. package/dist/svelte/createVoiceAgentSquadStatus.d.ts +9 -0
  157. package/dist/svelte/createVoiceCampaignDialerProof.d.ts +9 -0
  158. package/dist/svelte/createVoiceDeliveryRuntime.d.ts +11 -0
  159. package/dist/svelte/createVoiceLiveOps.d.ts +13 -0
  160. package/dist/svelte/createVoiceOpsActionCenter.d.ts +10 -0
  161. package/dist/svelte/createVoiceOpsStatus.d.ts +9 -0
  162. package/dist/svelte/createVoicePlatformCoverage.d.ts +7 -0
  163. package/dist/svelte/createVoiceProofTrends.d.ts +7 -0
  164. package/dist/svelte/createVoiceProviderCapabilities.d.ts +10 -0
  165. package/dist/svelte/createVoiceProviderContracts.d.ts +10 -0
  166. package/dist/svelte/createVoiceProviderSimulationControls.d.ts +11 -0
  167. package/dist/svelte/createVoiceProviderStatus.d.ts +4 -2
  168. package/dist/svelte/createVoiceRoutingStatus.d.ts +10 -0
  169. package/dist/svelte/createVoiceTraceTimeline.d.ts +10 -0
  170. package/dist/svelte/createVoiceTurnLatency.d.ts +11 -0
  171. package/dist/svelte/createVoiceTurnQuality.d.ts +10 -0
  172. package/dist/svelte/createVoiceWorkflowStatus.d.ts +8 -0
  173. package/dist/svelte/index.d.ts +16 -0
  174. package/dist/svelte/index.js +4838 -420
  175. package/dist/telephony/contract.d.ts +61 -0
  176. package/dist/telephony/matrix.d.ts +97 -0
  177. package/dist/telephony/plivo.d.ts +303 -0
  178. package/dist/telephony/security.d.ts +182 -0
  179. package/dist/telephony/telnyx.d.ts +291 -0
  180. package/dist/telephony/twilio.d.ts +135 -2
  181. package/dist/telephonyOutcome.d.ts +273 -0
  182. package/dist/testing/index.d.ts +1 -0
  183. package/dist/testing/index.js +2047 -42
  184. package/dist/testing/ioProviderSimulator.d.ts +41 -0
  185. package/dist/toolContract.d.ts +161 -0
  186. package/dist/toolRuntime.d.ts +50 -0
  187. package/dist/trace.d.ts +19 -1
  188. package/dist/traceDeliveryRoutes.d.ts +86 -0
  189. package/dist/traceTimeline.d.ts +97 -0
  190. package/dist/turnLatency.d.ts +95 -0
  191. package/dist/turnQuality.d.ts +94 -0
  192. package/dist/types.d.ts +158 -3
  193. package/dist/voiceMonitoring.d.ts +442 -0
  194. package/dist/vue/VoiceDeliveryRuntime.d.ts +30 -0
  195. package/dist/vue/VoiceOpsActionCenter.d.ts +13 -0
  196. package/dist/vue/VoiceOpsStatus.d.ts +30 -0
  197. package/dist/vue/VoicePlatformCoverage.d.ts +23 -0
  198. package/dist/vue/VoiceProofTrends.d.ts +21 -0
  199. package/dist/vue/VoiceProviderCapabilities.d.ts +51 -0
  200. package/dist/vue/VoiceProviderContracts.d.ts +21 -0
  201. package/dist/vue/VoiceProviderSimulationControls.d.ts +88 -0
  202. package/dist/vue/VoiceProviderStatus.d.ts +51 -0
  203. package/dist/vue/VoiceRoutingStatus.d.ts +51 -0
  204. package/dist/vue/VoiceTurnLatency.d.ts +69 -0
  205. package/dist/vue/VoiceTurnQuality.d.ts +51 -0
  206. package/dist/vue/index.d.ts +28 -0
  207. package/dist/vue/index.js +4550 -56
  208. package/dist/vue/useVoiceAgentSquadStatus.d.ts +9 -0
  209. package/dist/vue/useVoiceCampaignDialerProof.d.ts +11 -0
  210. package/dist/vue/useVoiceController.d.ts +2 -1
  211. package/dist/vue/useVoiceDeliveryRuntime.d.ts +13 -0
  212. package/dist/vue/useVoiceLiveOps.d.ts +9 -0
  213. package/dist/vue/useVoiceOpsActionCenter.d.ts +11 -0
  214. package/dist/vue/useVoiceOpsStatus.d.ts +9 -0
  215. package/dist/vue/useVoicePlatformCoverage.d.ts +9 -0
  216. package/dist/vue/useVoiceProofTrends.d.ts +9 -0
  217. package/dist/vue/useVoiceProviderCapabilities.d.ts +9 -0
  218. package/dist/vue/useVoiceProviderContracts.d.ts +9 -0
  219. package/dist/vue/useVoiceProviderSimulationControls.d.ts +24 -0
  220. package/dist/vue/useVoiceProviderStatus.d.ts +1 -1
  221. package/dist/vue/useVoiceRoutingStatus.d.ts +8 -0
  222. package/dist/vue/useVoiceStream.d.ts +2 -1
  223. package/dist/vue/useVoiceTraceTimeline.d.ts +9 -0
  224. package/dist/vue/useVoiceTurnLatency.d.ts +10 -0
  225. package/dist/vue/useVoiceTurnQuality.d.ts +9 -0
  226. package/dist/vue/useVoiceWorkflowStatus.d.ts +9 -0
  227. package/dist/workflowContract.d.ts +91 -0
  228. package/package.json +1 -1
@@ -2105,6 +2105,11 @@ 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
+ };
2108
2113
  case "call_lifecycle":
2109
2114
  return {
2110
2115
  event: message.event,
@@ -2126,6 +2131,17 @@ var serverMessageToAction = (message) => {
2126
2131
  transcript: message.transcript,
2127
2132
  type: "partial"
2128
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
+ };
2129
2145
  case "session":
2130
2146
  return {
2131
2147
  sessionId: message.sessionId,
@@ -2186,10 +2202,12 @@ var isVoiceServerMessage = (value) => {
2186
2202
  case "assistant":
2187
2203
  case "call_lifecycle":
2188
2204
  case "complete":
2205
+ case "connection":
2189
2206
  case "error":
2190
2207
  case "final":
2191
2208
  case "partial":
2192
2209
  case "pong":
2210
+ case "replay":
2193
2211
  case "session":
2194
2212
  case "turn":
2195
2213
  return true;
@@ -2226,6 +2244,9 @@ var createVoiceConnection = (path, options = {}) => {
2226
2244
  sessionId: options.sessionId ?? createSessionId(),
2227
2245
  ws: null
2228
2246
  };
2247
+ const emitConnection = (reconnect) => {
2248
+ listeners.forEach((listener) => listener(reconnect));
2249
+ };
2229
2250
  const clearTimers = () => {
2230
2251
  if (state.pingInterval) {
2231
2252
  clearInterval(state.pingInterval);
@@ -2248,9 +2269,28 @@ var createVoiceConnection = (path, options = {}) => {
2248
2269
  }
2249
2270
  };
2250
2271
  const scheduleReconnect = () => {
2272
+ const nextAttemptAt = Date.now() + RECONNECT_DELAY_MS;
2251
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
+ });
2252
2284
  state.reconnectTimeout = setTimeout(() => {
2253
2285
  if (state.reconnectAttempts > maxReconnectAttempts) {
2286
+ emitConnection({
2287
+ reconnect: {
2288
+ attempts: state.reconnectAttempts,
2289
+ maxAttempts: maxReconnectAttempts,
2290
+ status: "exhausted"
2291
+ },
2292
+ type: "connection"
2293
+ });
2254
2294
  return;
2255
2295
  }
2256
2296
  connect();
@@ -2260,9 +2300,21 @@ var createVoiceConnection = (path, options = {}) => {
2260
2300
  const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId));
2261
2301
  ws.binaryType = "arraybuffer";
2262
2302
  ws.onopen = () => {
2303
+ const wasReconnecting = state.reconnectAttempts > 0;
2263
2304
  state.isConnected = true;
2264
- state.reconnectAttempts = 0;
2265
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
+ }
2266
2318
  listeners.forEach((listener) => listener({
2267
2319
  scenarioId: state.scenarioId ?? undefined,
2268
2320
  sessionId: state.sessionId,
@@ -2292,6 +2344,16 @@ var createVoiceConnection = (path, options = {}) => {
2292
2344
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && state.reconnectAttempts < maxReconnectAttempts;
2293
2345
  if (reconnectable) {
2294
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
+ });
2295
2357
  }
2296
2358
  };
2297
2359
  state.ws = ws;
@@ -2362,6 +2424,11 @@ var createVoiceConnection = (path, options = {}) => {
2362
2424
  };
2363
2425
 
2364
2426
  // src/client/store.ts
2427
+ var createInitialReconnectState = () => ({
2428
+ attempts: 0,
2429
+ maxAttempts: 0,
2430
+ status: "idle"
2431
+ });
2365
2432
  var createInitialState2 = () => ({
2366
2433
  assistantAudio: [],
2367
2434
  assistantTexts: [],
@@ -2370,6 +2437,7 @@ var createInitialState2 = () => ({
2370
2437
  isConnected: false,
2371
2438
  scenarioId: null,
2372
2439
  partial: "",
2440
+ reconnect: createInitialReconnectState(),
2373
2441
  sessionId: null,
2374
2442
  status: "idle",
2375
2443
  turns: []
@@ -2426,7 +2494,19 @@ var createVoiceStreamStore = () => {
2426
2494
  case "connected":
2427
2495
  state = {
2428
2496
  ...state,
2429
- 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
2430
2510
  };
2431
2511
  break;
2432
2512
  case "disconnected":
@@ -2454,6 +2534,26 @@ var createVoiceStreamStore = () => {
2454
2534
  partial: action.transcript.text
2455
2535
  };
2456
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;
2457
2557
  case "session":
2458
2558
  state = {
2459
2559
  ...state,
@@ -2501,10 +2601,34 @@ var createVoiceStream = (path, options = {}) => {
2501
2601
  const notify = () => {
2502
2602
  subscribers.forEach((subscriber) => subscriber());
2503
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
+ };
2504
2625
  const unsubscribeConnection = connection.subscribe((message) => {
2505
2626
  const action = serverMessageToAction(message);
2506
2627
  if (action) {
2507
2628
  store.dispatch(action);
2629
+ if (message.type === "connection") {
2630
+ reportReconnect();
2631
+ }
2508
2632
  notify();
2509
2633
  }
2510
2634
  });
@@ -2540,6 +2664,9 @@ var createVoiceStream = (path, options = {}) => {
2540
2664
  get partial() {
2541
2665
  return store.getSnapshot().partial;
2542
2666
  },
2667
+ get reconnect() {
2668
+ return store.getSnapshot().reconnect;
2669
+ },
2543
2670
  get sessionId() {
2544
2671
  return connection.getSessionId();
2545
2672
  },
@@ -2895,6 +3022,7 @@ var createInitialState3 = (stream) => ({
2895
3022
  isConnected: stream.isConnected,
2896
3023
  isRecording: false,
2897
3024
  partial: stream.partial,
3025
+ reconnect: stream.reconnect,
2898
3026
  recordingError: null,
2899
3027
  sessionId: stream.sessionId,
2900
3028
  scenarioId: stream.scenarioId,
@@ -2924,6 +3052,7 @@ var createVoiceController = (path, options = {}) => {
2924
3052
  error: stream.error,
2925
3053
  isConnected: stream.isConnected,
2926
3054
  partial: stream.partial,
3055
+ reconnect: stream.reconnect,
2927
3056
  sessionId: stream.sessionId,
2928
3057
  scenarioId: stream.scenarioId,
2929
3058
  status: stream.status,
@@ -2948,7 +3077,13 @@ var createVoiceController = (path, options = {}) => {
2948
3077
  capture = createMicrophoneCapture({
2949
3078
  channelCount: options.capture?.channelCount ?? preset.capture.channelCount,
2950
3079
  onLevel: options.capture?.onLevel,
2951
- 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
+ },
2952
3087
  sampleRateHz: options.capture?.sampleRateHz ?? preset.capture.sampleRateHz
2953
3088
  });
2954
3089
  return capture;
@@ -3018,6 +3153,9 @@ var createVoiceController = (path, options = {}) => {
3018
3153
  get recordingError() {
3019
3154
  return state.recordingError;
3020
3155
  },
3156
+ get reconnect() {
3157
+ return state.reconnect;
3158
+ },
3021
3159
  sendAudio: (audio) => stream.sendAudio(audio),
3022
3160
  get sessionId() {
3023
3161
  return state.sessionId;
@@ -3063,11 +3201,26 @@ var DEFAULT_INTERRUPT_THRESHOLD = 0.08;
3063
3201
  var shouldInterruptForLevel = (level, options = {}) => (options.enabled ?? true) && level >= (options.interruptThreshold ?? DEFAULT_INTERRUPT_THRESHOLD);
3064
3202
  var bindVoiceBargeIn = (controller, player, options = {}) => {
3065
3203
  let lastPartial = controller.partial;
3066
- const interruptIfPlaying = () => {
3204
+ const interruptIfPlaying = (reason) => {
3067
3205
  if (!player.isPlaying || options.enabled === false) {
3206
+ options.monitor?.recordSkipped({
3207
+ reason,
3208
+ sessionId: controller.sessionId
3209
+ });
3068
3210
  return;
3069
3211
  }
3070
- 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
+ });
3071
3224
  };
3072
3225
  const unsubscribe = controller.subscribe(() => {
3073
3226
  if (options.interruptOnPartial === false) {
@@ -3075,7 +3228,7 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
3075
3228
  return;
3076
3229
  }
3077
3230
  if (!lastPartial && controller.partial) {
3078
- interruptIfPlaying();
3231
+ interruptIfPlaying("partial-transcript");
3079
3232
  }
3080
3233
  lastPartial = controller.partial;
3081
3234
  });
@@ -3085,11 +3238,11 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
3085
3238
  },
3086
3239
  handleLevel: (level) => {
3087
3240
  if (shouldInterruptForLevel(level, options)) {
3088
- interruptIfPlaying();
3241
+ interruptIfPlaying("input-level");
3089
3242
  }
3090
3243
  },
3091
3244
  sendAudio: (audio) => {
3092
- interruptIfPlaying();
3245
+ interruptIfPlaying("manual-audio");
3093
3246
  controller.sendAudio(audio);
3094
3247
  }
3095
3248
  };
@@ -3119,7 +3272,17 @@ var createVoiceDuplexController = (path, options = {}) => {
3119
3272
  audioPlayer,
3120
3273
  close,
3121
3274
  interruptAssistant: async () => {
3275
+ options.bargeIn?.monitor?.recordRequested({
3276
+ reason: "manual-interrupt",
3277
+ sessionId: controller.sessionId
3278
+ });
3122
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
+ });
3123
3286
  },
3124
3287
  sendAudio: (audio) => {
3125
3288
  bargeInBinding?.sendAudio(audio);
@@ -3510,7 +3673,165 @@ var loadVoiceTestFixtures = async (fixtureDirectory) => {
3510
3673
  }
3511
3674
  return fixtures;
3512
3675
  };
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;
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
3690
+ });
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
+ };
3734
+ }
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
+ };
3788
+ };
3789
+ return {
3790
+ run
3791
+ };
3792
+ };
3513
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
+ }
3834
+ };
3514
3835
  var OUTPUT_SCHEMA = {
3515
3836
  additionalProperties: false,
3516
3837
  properties: {
@@ -3601,6 +3922,17 @@ var parseJSONValue = (value) => {
3601
3922
  return value;
3602
3923
  }
3603
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
+ }
3604
3936
  var getMessageToolCalls = (message) => {
3605
3937
  const toolCalls = message.metadata?.toolCalls;
3606
3938
  return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
@@ -3667,7 +3999,7 @@ var createJSONVoiceAssistantModel = (options) => ({
3667
3999
  var createVoiceProviderRouter = (options) => {
3668
4000
  const providerIds = Object.keys(options.providers);
3669
4001
  const firstProvider = providerIds[0];
3670
- const policy = typeof options.policy === "string" ? {
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) : {
3671
4003
  strategy: options.policy
3672
4004
  } : options.policy;
3673
4005
  const strategy = policy?.strategy ?? "prefer-selected";
@@ -3678,6 +4010,10 @@ var createVoiceProviderRouter = (options) => {
3678
4010
  const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
3679
4011
  const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
3680
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
+ };
3681
4017
  const getHealth = (provider) => {
3682
4018
  const existing = healthState.get(provider);
3683
4019
  if (existing) {
@@ -3745,13 +4081,40 @@ var createVoiceProviderRouter = (options) => {
3745
4081
  const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
3746
4082
  return new Set(allowed ?? providerIds);
3747
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
+ };
3748
4105
  const sortProviders = (providers) => {
3749
- if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest") {
4106
+ if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
3750
4107
  return providers;
3751
4108
  }
3752
4109
  return [...providers].sort((left, right) => {
3753
4110
  const leftProfile = options.providerProfiles?.[left];
3754
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
+ }
3755
4118
  const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
3756
4119
  const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
3757
4120
  return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
@@ -3761,12 +4124,13 @@ var createVoiceProviderRouter = (options) => {
3761
4124
  const selectedProvider = await options.selectProvider?.(input);
3762
4125
  const allowedProviders = await resolveAllowedProviders(input);
3763
4126
  const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
3764
- const rankedProviders = sortProviders([
4127
+ const allowedRankedProviders = sortProviders([
3765
4128
  ...fallbackOrder ?? providerIds
3766
4129
  ]).filter((provider) => allowedProviders.has(provider));
4130
+ const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
3767
4131
  const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
3768
4132
  const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
3769
- const preferred = selectedProvider && allowedProviders.has(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
4133
+ const preferred = selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
3770
4134
  const seen = new Set;
3771
4135
  const order = [];
3772
4136
  const candidates = strategy === "ordered" ? candidateRankedProviders : [
@@ -3789,6 +4153,25 @@ var createVoiceProviderRouter = (options) => {
3789
4153
  const emit = async (event, input) => {
3790
4154
  await options.onProviderEvent?.(event, input);
3791
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
+ };
3792
4175
  return {
3793
4176
  generate: async (input) => {
3794
4177
  const { order, selectedProvider } = await resolveOrder(input);
@@ -3803,12 +4186,14 @@ var createVoiceProviderRouter = (options) => {
3803
4186
  }
3804
4187
  const startedAt = Date.now();
3805
4188
  try {
3806
- const output = await model.generate(input);
4189
+ const output = await runProvider(provider, model, input);
3807
4190
  const providerHealth = recordProviderSuccess(provider);
3808
4191
  await emit({
3809
4192
  at: Date.now(),
4193
+ attempt: index + 1,
3810
4194
  elapsedMs: Date.now() - startedAt,
3811
4195
  fallbackProvider: provider === selectedProvider ? undefined : provider,
4196
+ latencyBudgetMs: getProviderTimeoutMs(provider),
3812
4197
  provider,
3813
4198
  providerHealth,
3814
4199
  recovered: provider !== selectedProvider,
@@ -3820,22 +4205,26 @@ var createVoiceProviderRouter = (options) => {
3820
4205
  lastError = error;
3821
4206
  const hasNextProvider = index < order.length - 1;
3822
4207
  const isProviderError = options.isProviderError?.(error, provider) ?? true;
4208
+ const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
3823
4209
  const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
3824
4210
  const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
3825
4211
  const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
3826
4212
  const nextProvider = hasNextProvider ? order[index + 1] : undefined;
3827
4213
  await emit({
3828
4214
  at: Date.now(),
4215
+ attempt: index + 1,
3829
4216
  elapsedMs: Date.now() - startedAt,
3830
4217
  error: errorMessage(error),
3831
4218
  fallbackProvider: shouldFallback ? nextProvider : undefined,
4219
+ latencyBudgetMs: getProviderTimeoutMs(provider),
3832
4220
  provider,
3833
4221
  providerHealth,
3834
4222
  rateLimited,
3835
4223
  selectedProvider,
3836
4224
  suppressionRemainingMs: getSuppressionRemainingMs(provider),
3837
4225
  suppressedUntil: providerHealth?.suppressedUntil,
3838
- status: "error"
4226
+ status: "error",
4227
+ timedOut
3839
4228
  }, input);
3840
4229
  if (!hasNextProvider || !shouldFallback) {
3841
4230
  throw error;
@@ -4457,7 +4846,290 @@ var createVoiceMemoryStore = () => {
4457
4846
  };
4458
4847
 
4459
4848
  // src/session.ts
4460
- import { Buffer } from "buffer";
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
+ });
4461
5133
 
4462
5134
  // src/logger.ts
4463
5135
  var noop2 = () => {};
@@ -4493,6 +5165,12 @@ var DEFAULT_FORMAT = {
4493
5165
  encoding: "pcm_s16le",
4494
5166
  sampleRateHz: 16000
4495
5167
  };
5168
+ var DEFAULT_REALTIME_FORMAT = {
5169
+ channels: 1,
5170
+ container: "raw",
5171
+ encoding: "pcm_s16le",
5172
+ sampleRateHz: 24000
5173
+ };
4496
5174
  var toError = (value) => value instanceof Error ? value : new Error(String(value));
4497
5175
  var createEmptyCurrentTurn = () => ({
4498
5176
  finalText: "",
@@ -4505,7 +5183,7 @@ var createEmptyCurrentTurn = () => ({
4505
5183
  transcripts: []
4506
5184
  });
4507
5185
  var cloneTranscript = (transcript) => ({ ...transcript });
4508
- var encodeBase64 = (chunk) => Buffer.from(chunk).toString("base64");
5186
+ var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
4509
5187
  var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
4510
5188
  var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
4511
5189
  var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
@@ -4676,7 +5354,7 @@ var createVoiceSession = (options) => {
4676
5354
  } : undefined;
4677
5355
  const appendTrace = async (input) => {
4678
5356
  await options.trace?.append({
4679
- at: Date.now(),
5357
+ at: input.at ?? Date.now(),
4680
5358
  metadata: input.metadata,
4681
5359
  payload: input.payload,
4682
5360
  scenarioId: input.session?.scenarioId ?? options.scenarioId,
@@ -4685,6 +5363,13 @@ var createVoiceSession = (options) => {
4685
5363
  type: input.type
4686
5364
  });
4687
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
+ });
4688
5373
  const phraseHints = options.phraseHints ?? [];
4689
5374
  const lexicon = options.lexicon ?? [];
4690
5375
  let socket = options.socket;
@@ -4763,6 +5448,65 @@ var createVoiceSession = (options) => {
4763
5448
  type: "call_lifecycle"
4764
5449
  });
4765
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
+ };
4766
5510
  const readSession = async () => options.store.getOrCreate(options.id);
4767
5511
  const writeSession = async (mutate) => {
4768
5512
  const session = await options.store.getOrCreate(options.id);
@@ -4819,6 +5563,23 @@ var createVoiceSession = (options) => {
4819
5563
  });
4820
5564
  }
4821
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
+ };
4822
5583
  const scheduleTurnCommit = (delayMs, reason, reset = true) => {
4823
5584
  if (!reset && silenceTimer) {
4824
5585
  return;
@@ -5042,6 +5803,14 @@ var createVoiceSession = (options) => {
5042
5803
  type: "call.lifecycle"
5043
5804
  });
5044
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
+ });
5045
5814
  await completeInternal(input.result, {
5046
5815
  disposition: "transferred",
5047
5816
  invokeOnComplete: false,
@@ -5068,6 +5837,13 @@ var createVoiceSession = (options) => {
5068
5837
  type: "call.lifecycle"
5069
5838
  });
5070
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
+ });
5071
5847
  await completeInternal(input.result, {
5072
5848
  disposition: "escalated",
5073
5849
  invokeOnComplete: false,
@@ -5091,7 +5867,13 @@ var createVoiceSession = (options) => {
5091
5867
  type: "call.lifecycle"
5092
5868
  });
5093
5869
  await sendCallLifecycle(session);
5094
- await completeInternal(input?.result, {
5870
+ await runHandoff({
5871
+ action: "no-answer",
5872
+ metadata: input?.metadata,
5873
+ result: input?.result,
5874
+ session
5875
+ });
5876
+ await completeInternal(input?.result, {
5095
5877
  disposition: "no-answer",
5096
5878
  invokeOnComplete: false,
5097
5879
  metadata: input?.metadata
@@ -5113,6 +5895,12 @@ var createVoiceSession = (options) => {
5113
5895
  type: "call.lifecycle"
5114
5896
  });
5115
5897
  await sendCallLifecycle(session);
5898
+ await runHandoff({
5899
+ action: "voicemail",
5900
+ metadata: input?.metadata,
5901
+ result: input?.result,
5902
+ session
5903
+ });
5116
5904
  await completeInternal(input?.result, {
5117
5905
  disposition: "voicemail",
5118
5906
  invokeOnComplete: false,
@@ -5493,8 +6281,12 @@ var createVoiceSession = (options) => {
5493
6281
  if (sttSession) {
5494
6282
  return sttSession;
5495
6283
  }
5496
- const openedSession = await options.stt.open({
5497
- 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,
5498
6290
  languageStrategy: options.languageStrategy,
5499
6291
  lexicon,
5500
6292
  phraseHints,
@@ -5529,6 +6321,16 @@ var createVoiceSession = (options) => {
5529
6321
  openedSession.on("close", (event) => {
5530
6322
  runAdapterEvent("adapter.close", () => handleClose(event));
5531
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
+ }
5532
6334
  return openedSession;
5533
6335
  };
5534
6336
  const ensureTTSSession = async () => {
@@ -5553,13 +6355,9 @@ var createVoiceSession = (options) => {
5553
6355
  if (ttsSession !== openedSession) {
5554
6356
  return;
5555
6357
  }
5556
- 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));
5557
- await send({
5558
- chunkBase64: encodeBase64(normalizedChunk),
6358
+ await sendAssistantAudio(chunk, {
5559
6359
  format,
5560
- receivedAt,
5561
- turnId: activeTTSTurnId,
5562
- type: "audio"
6360
+ receivedAt
5563
6361
  });
5564
6362
  });
5565
6363
  });
@@ -5603,9 +6401,32 @@ var createVoiceSession = (options) => {
5603
6401
  });
5604
6402
  };
5605
6403
  const completeTurn = async (session, turn) => {
6404
+ const liveOpsControl = await options.liveOps?.getControl(options.id);
6405
+ if (liveOpsControl?.assistantPaused || liveOpsControl?.operatorTakeover) {
6406
+ await appendTrace({
6407
+ metadata: {
6408
+ source: "voice-live-ops"
6409
+ },
6410
+ payload: {
6411
+ action: "turn.skipped",
6412
+ control: liveOpsControl,
6413
+ reason: liveOpsControl.operatorTakeover ? "operator-takeover" : "assistant-paused",
6414
+ status: "skipped"
6415
+ },
6416
+ session,
6417
+ turnId: turn.id,
6418
+ type: "operator.action"
6419
+ });
6420
+ return;
6421
+ }
6422
+ const injectedInstruction = liveOpsControl?.injectedInstruction?.trim();
5606
6423
  const committedOutput = await options.route.onTurn({
5607
6424
  api,
5608
6425
  context: options.context,
6426
+ liveOps: liveOpsControl ? {
6427
+ control: liveOpsControl,
6428
+ injectedInstruction
6429
+ } : undefined,
5609
6430
  session,
5610
6431
  turn
5611
6432
  });
@@ -5619,6 +6440,7 @@ var createVoiceSession = (options) => {
5619
6440
  voicemail: committedOutput?.voicemail
5620
6441
  };
5621
6442
  if (output?.assistantText) {
6443
+ const assistantTextStartedAt = Date.now();
5622
6444
  await writeSession((currentSession) => {
5623
6445
  setTurnResult(currentSession, turn.id, {
5624
6446
  assistantText: output.assistantText
@@ -5629,10 +6451,17 @@ var createVoiceSession = (options) => {
5629
6451
  turnId: turn.id,
5630
6452
  type: "assistant"
5631
6453
  });
6454
+ await appendTurnLatencyStage({
6455
+ at: assistantTextStartedAt,
6456
+ session,
6457
+ stage: "assistant_text_started",
6458
+ turnId: turn.id
6459
+ });
5632
6460
  await appendTrace({
5633
6461
  payload: {
5634
6462
  text: output.assistantText,
5635
- ttsConfigured: Boolean(options.tts)
6463
+ ttsConfigured: Boolean(options.tts),
6464
+ realtimeConfigured: Boolean(options.realtime)
5636
6465
  },
5637
6466
  session,
5638
6467
  turnId: turn.id,
@@ -5643,7 +6472,18 @@ var createVoiceSession = (options) => {
5643
6472
  if (activeTTSSession) {
5644
6473
  const ttsStartedAt = Date.now();
5645
6474
  activeTTSTurnId = turn.id;
6475
+ await appendTurnLatencyStage({
6476
+ at: ttsStartedAt,
6477
+ session,
6478
+ stage: "tts_send_started",
6479
+ turnId: turn.id
6480
+ });
5646
6481
  await activeTTSSession.send(output.assistantText);
6482
+ await appendTurnLatencyStage({
6483
+ session,
6484
+ stage: "tts_send_completed",
6485
+ turnId: turn.id
6486
+ });
5647
6487
  await appendTrace({
5648
6488
  payload: {
5649
6489
  elapsedMs: Date.now() - ttsStartedAt,
@@ -5653,9 +6493,35 @@ var createVoiceSession = (options) => {
5653
6493
  turnId: turn.id,
5654
6494
  type: "turn.assistant"
5655
6495
  });
6496
+ } else if (options.realtime) {
6497
+ const activeRealtimeSession = await ensureAdapter();
6498
+ const realtimeStartedAt = Date.now();
6499
+ activeTTSTurnId = turn.id;
6500
+ await appendTurnLatencyStage({
6501
+ at: realtimeStartedAt,
6502
+ session,
6503
+ stage: "tts_send_started",
6504
+ turnId: turn.id
6505
+ });
6506
+ await activeRealtimeSession.send(output.assistantText);
6507
+ await appendTurnLatencyStage({
6508
+ session,
6509
+ stage: "tts_send_completed",
6510
+ turnId: turn.id
6511
+ });
6512
+ await appendTrace({
6513
+ payload: {
6514
+ elapsedMs: Date.now() - realtimeStartedAt,
6515
+ mode: "realtime",
6516
+ status: "sent"
6517
+ },
6518
+ session,
6519
+ turnId: turn.id,
6520
+ type: "turn.assistant"
6521
+ });
5656
6522
  }
5657
6523
  } catch (error) {
5658
- logger.warn("voice tts send failed", {
6524
+ logger.warn("voice assistant audio send failed", {
5659
6525
  error: toError(error).message,
5660
6526
  sessionId: options.id,
5661
6527
  turnId: turn.id
@@ -5663,7 +6529,7 @@ var createVoiceSession = (options) => {
5663
6529
  await appendTrace({
5664
6530
  payload: {
5665
6531
  error: toError(error).message,
5666
- status: "tts-send-failed"
6532
+ status: options.realtime ? "realtime-send-failed" : "tts-send-failed"
5667
6533
  },
5668
6534
  session,
5669
6535
  turnId: turn.id,
@@ -5840,11 +6706,35 @@ var createVoiceSession = (options) => {
5840
6706
  turnId: turn.id,
5841
6707
  type: "turn.cost"
5842
6708
  });
6709
+ const firstTranscriptAt = turn.transcripts.map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
6710
+ 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];
6711
+ if (firstTranscriptAt !== undefined) {
6712
+ await appendTurnLatencyStage({
6713
+ at: firstTranscriptAt,
6714
+ session: updatedSession,
6715
+ stage: "speech_detected",
6716
+ turnId: turn.id
6717
+ });
6718
+ }
6719
+ if (finalTranscriptAt !== undefined) {
6720
+ await appendTurnLatencyStage({
6721
+ at: finalTranscriptAt,
6722
+ session: updatedSession,
6723
+ stage: "final_transcript",
6724
+ turnId: turn.id
6725
+ });
6726
+ }
6727
+ await appendTurnLatencyStage({
6728
+ at: turn.committedAt,
6729
+ session: updatedSession,
6730
+ stage: "turn_committed",
6731
+ turnId: turn.id
6732
+ });
5843
6733
  await send({
5844
6734
  turn,
5845
6735
  type: "turn"
5846
6736
  });
5847
- if (options.sttLifecycle === "turn-scoped") {
6737
+ if (options.stt && options.sttLifecycle === "turn-scoped") {
5848
6738
  await closeAdapter("turn-commit");
5849
6739
  }
5850
6740
  await completeTurn(updatedSession, turn);
@@ -5907,6 +6797,7 @@ var createVoiceSession = (options) => {
5907
6797
  scenarioId: session.scenarioId,
5908
6798
  type: "session"
5909
6799
  });
6800
+ await sendReplay(session);
5910
6801
  if (shouldFireOnSession) {
5911
6802
  await options.route.onCallStart?.({
5912
6803
  api,
@@ -6490,7 +7381,7 @@ var createVoiceCallReviewFromLiveTelephonyReport = (report, options = {}) => {
6490
7381
  }
6491
7382
  };
6492
7383
  };
6493
- var toErrorMessage = (error) => {
7384
+ var toErrorMessage2 = (error) => {
6494
7385
  if (typeof error === "string" && error.trim().length > 0) {
6495
7386
  return error;
6496
7387
  }
@@ -6577,7 +7468,7 @@ var createVoiceCallReviewRecorder = (options = {}) => {
6577
7468
  };
6578
7469
  },
6579
7470
  recordError: (error) => {
6580
- const message = toErrorMessage(error);
7471
+ const message = toErrorMessage2(error);
6581
7472
  errors.push(message);
6582
7473
  push("turn", "error", {
6583
7474
  reason: message
@@ -7283,10 +8174,981 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
7283
8174
  });
7284
8175
  };
7285
8176
  // src/telephony/twilio.ts
7286
- import { Buffer as Buffer2 } from "buffer";
8177
+ import { Buffer as Buffer3 } from "buffer";
8178
+ import { Elysia as Elysia2 } from "elysia";
8179
+
8180
+ // src/telephonyOutcome.ts
8181
+ import { Elysia } from "elysia";
8182
+ var DEFAULT_COMPLETED_STATUSES = [
8183
+ "answered",
8184
+ "completed",
8185
+ "complete",
8186
+ "connected",
8187
+ "in-progress",
8188
+ "live"
8189
+ ];
8190
+ var DEFAULT_NO_ANSWER_STATUSES = [
8191
+ "busy",
8192
+ "canceled",
8193
+ "cancelled",
8194
+ "failed",
8195
+ "no-answer",
8196
+ "no_answer",
8197
+ "not-answered",
8198
+ "ring-no-answer",
8199
+ "timeout",
8200
+ "unanswered"
8201
+ ];
8202
+ var DEFAULT_VOICEMAIL_STATUSES = [
8203
+ "answering-machine",
8204
+ "machine",
8205
+ "voicemail",
8206
+ "voice-mail"
8207
+ ];
8208
+ var DEFAULT_TRANSFER_STATUSES = ["bridged", "forwarded", "transferred"];
8209
+ var DEFAULT_ESCALATION_STATUSES = ["escalated", "human-required", "operator"];
8210
+ var DEFAULT_FAILED_STATUSES = ["busy", "failed", "no-answer"];
8211
+ var DEFAULT_MACHINE_VOICEMAIL_VALUES = [
8212
+ "answering-machine",
8213
+ "fax",
8214
+ "machine",
8215
+ "machine-end-beep",
8216
+ "machine-end-other",
8217
+ "machine-start",
8218
+ "voicemail"
8219
+ ];
8220
+ var DEFAULT_NO_ANSWER_SIP_CODES = [408, 480, 486, 487, 603];
8221
+ var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
8222
+ var uniqueSorted = (values) => Array.from(new Set(values)).sort();
8223
+ var findMissing = (values, required) => {
8224
+ if (!required?.length) {
8225
+ return [];
8226
+ }
8227
+ const valueSet = new Set(values);
8228
+ return required.filter((value) => !valueSet.has(value));
8229
+ };
8230
+
8231
+ class VoiceTelephonyWebhookVerificationError extends Error {
8232
+ result;
8233
+ constructor(result) {
8234
+ super(result.ok ? "telephony webhook verified" : result.reason);
8235
+ this.name = "VoiceTelephonyWebhookVerificationError";
8236
+ this.result = result;
8237
+ }
8238
+ }
8239
+ var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
8240
+ const decisions = new Map;
8241
+ return {
8242
+ get: (key) => decisions.get(key),
8243
+ set: (key, decision) => {
8244
+ decisions.set(key, decision);
8245
+ }
8246
+ };
8247
+ };
8248
+ var isTelephonyWebhookProvider = (value) => value === "generic" || value === "plivo" || value === "telnyx" || value === "twilio";
8249
+ var isTelephonyOutcomeAction = (value) => value === "complete" || value === "escalate" || value === "ignore" || value === "no-answer" || value === "transfer" || value === "voicemail";
8250
+ var isCallDisposition = (value) => value === "completed" || value === "escalated" || value === "failed" || value === "no-answer" || value === "transferred" || value === "voicemail";
8251
+ var evaluateVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
8252
+ const issues = [];
8253
+ const decisions = input.decisions ?? [];
8254
+ const verificationAttempts = input.verificationAttempts ?? [];
8255
+ const actions = uniqueSorted(decisions.map((decision) => decision.decision?.action ?? decision.action).filter(isTelephonyOutcomeAction));
8256
+ const dispositions = uniqueSorted(decisions.map((decision) => decision.decision?.disposition ?? decision.disposition).filter(isCallDisposition));
8257
+ const providers = uniqueSorted(decisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
8258
+ const sources = uniqueSorted(decisions.map((decision) => decision.decision?.source ?? decision.source).filter((source) => typeof source === "string"));
8259
+ const applied = decisions.filter((decision) => decision.applied === true).length;
8260
+ const duplicateDecisions = decisions.filter((decision) => decision.duplicate === true);
8261
+ const duplicateProviders = uniqueSorted(duplicateDecisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
8262
+ const duplicateIdempotencyKeys = new Set(duplicateDecisions.map((decision) => decision.idempotencyKey).filter((key) => typeof key === "string" && key.length > 0)).size;
8263
+ const duplicateCampaignOutcomesApplied = duplicateDecisions.filter((decision) => isRecord(decision.campaignOutcome) && decision.campaignOutcome.applied === true).length;
8264
+ const duplicateOutcomeReasons = uniqueSorted(duplicateDecisions.map((decision) => isRecord(decision.campaignOutcome) ? decision.campaignOutcome.reason : undefined).filter((reason) => typeof reason === "string"));
8265
+ const routeResults = decisions.filter((decision) => isRecord(decision.routeResult)).length;
8266
+ const missingSessionIds = decisions.filter((decision) => !decision.sessionId).length;
8267
+ const rejectedVerificationAttempts = verificationAttempts.filter((attempt) => attempt.rejected === true || attempt.status === 401 || attempt.verification?.ok === false && attempt.verification.reason === "invalid-signature");
8268
+ const rejectedVerificationProviders = uniqueSorted(rejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
8269
+ const replayRejectedVerificationAttempts = rejectedVerificationAttempts.filter((attempt) => attempt.replayRejected === true);
8270
+ const replayRejectedVerificationProviders = uniqueSorted(replayRejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
8271
+ const rejectedVerificationSideEffects = rejectedVerificationAttempts.reduce((total, attempt) => total + Math.max(0, attempt.sideEffects ?? 0), 0);
8272
+ if (input.minDecisions !== undefined && decisions.length < input.minDecisions) {
8273
+ issues.push(`Expected at least ${String(input.minDecisions)} telephony webhook decision(s), found ${String(decisions.length)}.`);
8274
+ }
8275
+ if (input.minApplied !== undefined && applied < input.minApplied) {
8276
+ issues.push(`Expected at least ${String(input.minApplied)} applied telephony webhook decision(s), found ${String(applied)}.`);
8277
+ }
8278
+ if (input.minDuplicates !== undefined && duplicateDecisions.length < input.minDuplicates) {
8279
+ issues.push(`Expected at least ${String(input.minDuplicates)} duplicate telephony webhook decision(s), found ${String(duplicateDecisions.length)}.`);
8280
+ }
8281
+ if (input.minDuplicateIdempotencyKeys !== undefined && duplicateIdempotencyKeys < input.minDuplicateIdempotencyKeys) {
8282
+ issues.push(`Expected at least ${String(input.minDuplicateIdempotencyKeys)} duplicate telephony webhook idempotency key(s), found ${String(duplicateIdempotencyKeys)}.`);
8283
+ }
8284
+ if (input.maxDuplicateCampaignOutcomesApplied !== undefined && duplicateCampaignOutcomesApplied > input.maxDuplicateCampaignOutcomesApplied) {
8285
+ issues.push(`Expected at most ${String(input.maxDuplicateCampaignOutcomesApplied)} duplicate telephony webhook campaign outcome application(s), found ${String(duplicateCampaignOutcomesApplied)}.`);
8286
+ }
8287
+ if (input.minRejectedVerificationAttempts !== undefined && rejectedVerificationAttempts.length < input.minRejectedVerificationAttempts) {
8288
+ issues.push(`Expected at least ${String(input.minRejectedVerificationAttempts)} rejected telephony webhook verification attempt(s), found ${String(rejectedVerificationAttempts.length)}.`);
8289
+ }
8290
+ if (input.maxRejectedVerificationSideEffects !== undefined && rejectedVerificationSideEffects > input.maxRejectedVerificationSideEffects) {
8291
+ issues.push(`Expected at most ${String(input.maxRejectedVerificationSideEffects)} rejected telephony webhook side effect(s), found ${String(rejectedVerificationSideEffects)}.`);
8292
+ }
8293
+ if (input.minReplayRejectedVerificationAttempts !== undefined && replayRejectedVerificationAttempts.length < input.minReplayRejectedVerificationAttempts) {
8294
+ issues.push(`Expected at least ${String(input.minReplayRejectedVerificationAttempts)} replay-rejected telephony webhook verification attempt(s), found ${String(replayRejectedVerificationAttempts.length)}.`);
8295
+ }
8296
+ if (input.maxMissingSessionIds !== undefined && missingSessionIds > input.maxMissingSessionIds) {
8297
+ issues.push(`Expected at most ${String(input.maxMissingSessionIds)} telephony webhook decision(s) without sessionId, found ${String(missingSessionIds)}.`);
8298
+ }
8299
+ if (input.requireRouteResults && routeResults < decisions.length) {
8300
+ issues.push(`Expected every telephony webhook decision to include a route result, found ${String(routeResults)} of ${String(decisions.length)}.`);
8301
+ }
8302
+ for (const provider of findMissing(providers, input.requiredProviders)) {
8303
+ issues.push(`Missing telephony webhook provider: ${provider}.`);
8304
+ }
8305
+ for (const provider of findMissing(duplicateProviders, input.requiredDuplicateProviders)) {
8306
+ issues.push(`Missing duplicate telephony webhook provider: ${provider}.`);
8307
+ }
8308
+ for (const provider of findMissing(rejectedVerificationProviders, input.requiredRejectedVerificationProviders)) {
8309
+ issues.push(`Missing rejected telephony webhook verification provider: ${provider}.`);
8310
+ }
8311
+ for (const provider of findMissing(replayRejectedVerificationProviders, input.requiredReplayRejectedVerificationProviders)) {
8312
+ issues.push(`Missing replay-rejected telephony webhook verification provider: ${provider}.`);
8313
+ }
8314
+ for (const action of findMissing(actions, input.requiredActions)) {
8315
+ issues.push(`Missing telephony webhook action: ${action}.`);
8316
+ }
8317
+ for (const disposition of findMissing(dispositions, input.requiredDispositions)) {
8318
+ issues.push(`Missing telephony webhook disposition: ${disposition}.`);
8319
+ }
8320
+ return {
8321
+ actions,
8322
+ applied,
8323
+ decisions: decisions.length,
8324
+ dispositions,
8325
+ duplicateCampaignOutcomesApplied,
8326
+ duplicateIdempotencyKeys,
8327
+ duplicateOutcomeReasons,
8328
+ duplicateProviders,
8329
+ duplicates: duplicateDecisions.length,
8330
+ issues,
8331
+ missingSessionIds,
8332
+ ok: issues.length === 0,
8333
+ providers,
8334
+ rejectedVerificationAttempts: rejectedVerificationAttempts.length,
8335
+ rejectedVerificationProviders,
8336
+ rejectedVerificationSideEffects,
8337
+ replayRejectedVerificationAttempts: replayRejectedVerificationAttempts.length,
8338
+ replayRejectedVerificationProviders,
8339
+ routeResults,
8340
+ sources,
8341
+ verificationAttempts: verificationAttempts.length
8342
+ };
8343
+ };
8344
+ var assertVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
8345
+ const assertion = evaluateVoiceTelephonyWebhookNormalizationEvidence(input);
8346
+ if (!assertion.ok) {
8347
+ throw new Error(`Voice telephony webhook normalization evidence assertion failed: ${assertion.issues.join(" ")}`);
8348
+ }
8349
+ return assertion;
8350
+ };
8351
+ var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
8352
+ var firstString = (source, keys) => {
8353
+ for (const key of keys) {
8354
+ const value = source[key];
8355
+ if (typeof value === "string" && value.trim()) {
8356
+ return value.trim();
8357
+ }
8358
+ if (typeof value === "number" && Number.isFinite(value)) {
8359
+ return String(value);
8360
+ }
8361
+ }
8362
+ };
8363
+ var firstNumber = (source, keys) => {
8364
+ for (const key of keys) {
8365
+ const value = source[key];
8366
+ if (typeof value === "number" && Number.isFinite(value)) {
8367
+ return value;
8368
+ }
8369
+ if (typeof value === "string" && value.trim()) {
8370
+ const parsed = Number(value);
8371
+ if (Number.isFinite(parsed)) {
8372
+ return parsed;
8373
+ }
8374
+ }
8375
+ }
8376
+ };
8377
+ var parseMaybeJSON = (value) => {
8378
+ try {
8379
+ return JSON.parse(value);
8380
+ } catch {
8381
+ return;
8382
+ }
8383
+ };
8384
+ var flattenPayload = (value) => {
8385
+ if (!isRecord(value)) {
8386
+ return {};
8387
+ }
8388
+ const data = isRecord(value.data) ? value.data : undefined;
8389
+ const payload = isRecord(value.payload) ? value.payload : undefined;
8390
+ const event = isRecord(value.event) ? value.event : undefined;
8391
+ return {
8392
+ ...value,
8393
+ ...payload,
8394
+ ...event,
8395
+ ...data,
8396
+ ...isRecord(data?.payload) ? data.payload : undefined
8397
+ };
8398
+ };
8399
+ var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
8400
+ var timingSafeEqual = (left, right) => {
8401
+ const encoder = new TextEncoder;
8402
+ const leftBytes = encoder.encode(left);
8403
+ const rightBytes = encoder.encode(right);
8404
+ if (leftBytes.length !== rightBytes.length) {
8405
+ return false;
8406
+ }
8407
+ let diff = 0;
8408
+ for (let index = 0;index < leftBytes.length; index += 1) {
8409
+ diff |= leftBytes[index] ^ rightBytes[index];
8410
+ }
8411
+ return diff === 0;
8412
+ };
8413
+ var signHmacSHA1Base64 = async (secret, payload) => {
8414
+ const encoder = new TextEncoder;
8415
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
8416
+ hash: "SHA-1",
8417
+ name: "HMAC"
8418
+ }, false, ["sign"]);
8419
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
8420
+ return toBase64(signature);
8421
+ };
8422
+ 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("");
8423
+ var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
8424
+ var metadataValue = (metadata, keys) => {
8425
+ for (const key of keys) {
8426
+ const value = metadata?.[key];
8427
+ if (typeof value === "string" && value.trim()) {
8428
+ return value.trim();
8429
+ }
8430
+ }
8431
+ };
8432
+ var resolveTransferTarget = (event, policy) => {
8433
+ if (typeof event.target === "string" && event.target.trim()) {
8434
+ return event.target.trim();
8435
+ }
8436
+ const metadataTarget = metadataValue(event.metadata, [
8437
+ "transferTarget",
8438
+ "target",
8439
+ "queue",
8440
+ "department"
8441
+ ]);
8442
+ if (metadataTarget) {
8443
+ return metadataTarget;
8444
+ }
8445
+ if (typeof policy.transferTarget === "function") {
8446
+ const target = policy.transferTarget(event);
8447
+ return typeof target === "string" && target.trim() ? target.trim() : undefined;
8448
+ }
8449
+ return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
8450
+ };
8451
+ var mergeMetadata = (event, policy) => ({
8452
+ ...policy.includeProviderPayload ? {
8453
+ answeredBy: event.answeredBy,
8454
+ durationMs: event.durationMs,
8455
+ provider: event.provider,
8456
+ reason: event.reason,
8457
+ sipCode: event.sipCode,
8458
+ status: event.status
8459
+ } : undefined,
8460
+ ...policy.metadata,
8461
+ ...event.metadata
8462
+ });
8463
+ var withDecisionDefaults = (decision, input) => {
8464
+ if (typeof decision === "string") {
8465
+ return buildDecision(decision, input);
8466
+ }
8467
+ return {
8468
+ ...buildDecision(decision.action, input),
8469
+ ...decision,
8470
+ confidence: decision.confidence ?? "high",
8471
+ metadata: {
8472
+ ...mergeMetadata(input.event, input.policy),
8473
+ ...decision.metadata
8474
+ },
8475
+ source: decision.source ?? input.source,
8476
+ target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
8477
+ };
8478
+ };
8479
+ var dispositionForAction = (action) => {
8480
+ switch (action) {
8481
+ case "complete":
8482
+ return "completed";
8483
+ case "escalate":
8484
+ return "escalated";
8485
+ case "no-answer":
8486
+ return "no-answer";
8487
+ case "transfer":
8488
+ return "transferred";
8489
+ case "voicemail":
8490
+ return "voicemail";
8491
+ default:
8492
+ return;
8493
+ }
8494
+ };
8495
+ var buildDecision = (action, input) => ({
8496
+ action,
8497
+ confidence: action === "ignore" ? "low" : "high",
8498
+ disposition: dispositionForAction(action),
8499
+ metadata: mergeMetadata(input.event, input.policy),
8500
+ reason: input.event.reason,
8501
+ source: input.source,
8502
+ target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
8503
+ });
8504
+ var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
8505
+ completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
8506
+ escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
8507
+ failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
8508
+ failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
8509
+ includeProviderPayload: policy.includeProviderPayload ?? true,
8510
+ machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
8511
+ metadata: policy.metadata,
8512
+ minAnsweredDurationMs: policy.minAnsweredDurationMs,
8513
+ noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
8514
+ noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
8515
+ noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
8516
+ statusMap: policy.statusMap,
8517
+ transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
8518
+ transferTarget: policy.transferTarget,
8519
+ voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
8520
+ });
8521
+ var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
8522
+ const policy = createVoiceTelephonyOutcomePolicy(policyInput);
8523
+ const status = normalizeToken(event.status);
8524
+ const provider = normalizeToken(event.provider);
8525
+ const answeredBy = normalizeToken(event.answeredBy);
8526
+ const target = resolveTransferTarget(event, policy);
8527
+ if (status) {
8528
+ const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
8529
+ if (mapped) {
8530
+ return withDecisionDefaults(mapped, {
8531
+ event,
8532
+ policy,
8533
+ source: "policy"
8534
+ });
8535
+ }
8536
+ }
8537
+ if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
8538
+ return buildDecision("voicemail", { event, policy, source: "answered-by" });
8539
+ }
8540
+ if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
8541
+ return buildDecision("no-answer", { event, policy, source: "sip" });
8542
+ }
8543
+ if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
8544
+ return buildDecision("transfer", { event, policy, source: "status" });
8545
+ }
8546
+ if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
8547
+ return buildDecision("voicemail", { event, policy, source: "status" });
8548
+ }
8549
+ if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
8550
+ return buildDecision("escalate", { event, policy, source: "status" });
8551
+ }
8552
+ if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
8553
+ return buildDecision("no-answer", { event, policy, source: "status" });
8554
+ }
8555
+ if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
8556
+ return buildDecision("no-answer", { event, policy, source: "duration" });
8557
+ }
8558
+ if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
8559
+ return {
8560
+ ...buildDecision("no-answer", { event, policy, source: "duration" }),
8561
+ confidence: "medium"
8562
+ };
8563
+ }
8564
+ if (status && normalizeList(policy.completedStatuses, []).has(status)) {
8565
+ return buildDecision("complete", { event, policy, source: "status" });
8566
+ }
8567
+ if (target) {
8568
+ return {
8569
+ ...buildDecision("transfer", { event, policy, source: "explicit-target" }),
8570
+ confidence: "medium"
8571
+ };
8572
+ }
8573
+ return buildDecision("ignore", { event, policy, source: "status" });
8574
+ };
8575
+ var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
8576
+ switch (decision.action) {
8577
+ case "complete":
8578
+ return { complete: true, result };
8579
+ case "escalate":
8580
+ return {
8581
+ escalate: {
8582
+ metadata: decision.metadata,
8583
+ reason: decision.reason ?? "telephony-escalation"
8584
+ },
8585
+ result
8586
+ };
8587
+ case "no-answer":
8588
+ return {
8589
+ noAnswer: {
8590
+ metadata: decision.metadata
8591
+ },
8592
+ result
8593
+ };
8594
+ case "transfer":
8595
+ if (!decision.target) {
8596
+ return { result };
8597
+ }
8598
+ return {
8599
+ result,
8600
+ transfer: {
8601
+ metadata: decision.metadata,
8602
+ reason: decision.reason,
8603
+ target: decision.target
8604
+ }
8605
+ };
8606
+ case "voicemail":
8607
+ return {
8608
+ result,
8609
+ voicemail: {
8610
+ metadata: decision.metadata
8611
+ }
8612
+ };
8613
+ default:
8614
+ return { result };
8615
+ }
8616
+ };
8617
+ var applyVoiceTelephonyOutcome = async (api, decision, result) => {
8618
+ switch (decision.action) {
8619
+ case "complete":
8620
+ await api.complete(result);
8621
+ break;
8622
+ case "escalate":
8623
+ await api.escalate({
8624
+ metadata: decision.metadata,
8625
+ reason: decision.reason ?? "telephony-escalation",
8626
+ result
8627
+ });
8628
+ break;
8629
+ case "no-answer":
8630
+ await api.markNoAnswer({
8631
+ metadata: decision.metadata,
8632
+ result
8633
+ });
8634
+ break;
8635
+ case "transfer":
8636
+ if (!decision.target) {
8637
+ return;
8638
+ }
8639
+ await api.transfer({
8640
+ metadata: decision.metadata,
8641
+ reason: decision.reason,
8642
+ result,
8643
+ target: decision.target
8644
+ });
8645
+ break;
8646
+ case "voicemail":
8647
+ await api.markVoicemail({
8648
+ metadata: decision.metadata,
8649
+ result
8650
+ });
8651
+ break;
8652
+ default:
8653
+ break;
8654
+ }
8655
+ };
8656
+ var parseRequestBodyText = (input) => {
8657
+ const { contentType, text } = input;
8658
+ if (!text) {
8659
+ return {};
8660
+ }
8661
+ if (contentType.includes("application/json")) {
8662
+ return parseMaybeJSON(text) ?? {};
8663
+ }
8664
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
8665
+ return Object.fromEntries(new URLSearchParams(text));
8666
+ }
8667
+ return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
8668
+ };
8669
+ var readRequestBody = async (request) => {
8670
+ const contentType = request.headers.get("content-type") ?? "";
8671
+ const text = await request.text();
8672
+ return {
8673
+ body: parseRequestBodyText({ contentType, text }),
8674
+ rawBody: text
8675
+ };
8676
+ };
8677
+ var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
8678
+ var verifyVoiceTwilioWebhookSignature = async (input) => {
8679
+ if (!input.authToken) {
8680
+ return { ok: false, reason: "missing-secret" };
8681
+ }
8682
+ const signature = input.headers.get("x-twilio-signature");
8683
+ if (!signature) {
8684
+ return { ok: false, reason: "missing-signature" };
8685
+ }
8686
+ const expected = await signVoiceTwilioWebhook({
8687
+ authToken: input.authToken,
8688
+ body: input.body,
8689
+ url: input.url
8690
+ });
8691
+ return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
8692
+ };
8693
+ var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
8694
+ var verifyVoiceTelephonyWebhook = async (input) => {
8695
+ if (input.options.verify) {
8696
+ return input.options.verify({
8697
+ body: input.body,
8698
+ headers: input.request.headers,
8699
+ provider: input.provider,
8700
+ query: input.query,
8701
+ rawBody: input.rawBody,
8702
+ request: input.request
8703
+ });
8704
+ }
8705
+ if (!input.options.signingSecret) {
8706
+ return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
8707
+ }
8708
+ if (input.provider !== "twilio") {
8709
+ return { ok: false, reason: "unsupported-provider" };
8710
+ }
8711
+ return verifyVoiceTwilioWebhookSignature({
8712
+ authToken: input.options.signingSecret,
8713
+ body: input.body,
8714
+ headers: input.request.headers,
8715
+ url: resolveVerificationUrl(input.options.verificationUrl, {
8716
+ query: input.query,
8717
+ request: input.request
8718
+ })
8719
+ });
8720
+ };
8721
+ var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
8722
+ var parseVoiceTelephonyWebhookEvent = (input) => {
8723
+ const payload = flattenPayload(input.body);
8724
+ const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
8725
+ const status = firstString(payload, [
8726
+ "CallStatus",
8727
+ "call_status",
8728
+ "callStatus",
8729
+ "DialCallStatus",
8730
+ "dial_call_status",
8731
+ "status",
8732
+ "event_type",
8733
+ "type"
8734
+ ]);
8735
+ const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
8736
+ "CallDuration",
8737
+ "call_duration",
8738
+ "callDuration",
8739
+ "DialCallDuration",
8740
+ "dial_call_duration",
8741
+ "duration"
8742
+ ]));
8743
+ const sipCode = firstNumber(payload, [
8744
+ "SipResponseCode",
8745
+ "sip_response_code",
8746
+ "sipCode",
8747
+ "sip_code",
8748
+ "hangupCauseCode"
8749
+ ]);
8750
+ const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
8751
+ const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
8752
+ const target = firstString(payload, [
8753
+ "transferTarget",
8754
+ "TransferTarget",
8755
+ "target",
8756
+ "queue",
8757
+ "department"
8758
+ ]);
8759
+ return {
8760
+ answeredBy: firstString(payload, [
8761
+ "AnsweredBy",
8762
+ "answered_by",
8763
+ "answeredBy",
8764
+ "machineDetection",
8765
+ "machine_detection"
8766
+ ]),
8767
+ durationMs,
8768
+ from,
8769
+ metadata: {
8770
+ ...input.query,
8771
+ ...payload
8772
+ },
8773
+ provider,
8774
+ reason: firstString(payload, [
8775
+ "Reason",
8776
+ "reason",
8777
+ "HangupCause",
8778
+ "hangup_cause",
8779
+ "hangupCause"
8780
+ ]),
8781
+ sipCode,
8782
+ status,
8783
+ target,
8784
+ to
8785
+ };
8786
+ };
8787
+ var defaultSessionId = (input) => {
8788
+ const payload = flattenPayload(input.body);
8789
+ const metadataSessionId = input.event.metadata?.sessionId;
8790
+ return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
8791
+ "sessionId",
8792
+ "session_id",
8793
+ "SessionId",
8794
+ "CallSid",
8795
+ "call_sid",
8796
+ "callSid",
8797
+ "CallUUID",
8798
+ "call_uuid",
8799
+ "callControlId",
8800
+ "call_control_id"
8801
+ ]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
8802
+ };
8803
+ var defaultIdempotencyKey = (input) => {
8804
+ const payload = flattenPayload(input.body);
8805
+ const eventId = firstString(payload, [
8806
+ "id",
8807
+ "event_id",
8808
+ "eventId",
8809
+ "EventSid",
8810
+ "event_sid",
8811
+ "MessageSid",
8812
+ "message_sid",
8813
+ "CallSid",
8814
+ "call_sid",
8815
+ "CallUUID",
8816
+ "call_uuid",
8817
+ "callControlId",
8818
+ "call_control_id"
8819
+ ]);
8820
+ const status = normalizeToken(input.event.status) ?? "unknown";
8821
+ if (eventId) {
8822
+ return `${input.provider}:${eventId}:${status}`;
8823
+ }
8824
+ if (input.sessionId) {
8825
+ return `${input.provider}:${input.sessionId}:${status}`;
8826
+ }
8827
+ };
8828
+ var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
8829
+ const provider = options.provider ?? "generic";
8830
+ const query = input.query ?? {};
8831
+ const { body, rawBody } = await readRequestBody(input.request);
8832
+ const verification = await verifyVoiceTelephonyWebhook({
8833
+ body,
8834
+ options,
8835
+ provider,
8836
+ query,
8837
+ rawBody,
8838
+ request: input.request
8839
+ });
8840
+ if (!verification.ok) {
8841
+ throw new VoiceTelephonyWebhookVerificationError(verification);
8842
+ }
8843
+ const event = options.parse ? await options.parse({
8844
+ body,
8845
+ headers: input.request.headers,
8846
+ provider,
8847
+ query,
8848
+ request: input.request
8849
+ }) : parseVoiceTelephonyWebhookEvent({
8850
+ body,
8851
+ headers: input.request.headers,
8852
+ provider,
8853
+ query,
8854
+ request: input.request
8855
+ });
8856
+ const sessionId = await (options.resolveSessionId?.({
8857
+ body,
8858
+ event,
8859
+ query,
8860
+ request: input.request
8861
+ }) ?? defaultSessionId({ body, event, query }));
8862
+ const idempotencyEnabled = options.idempotency?.enabled !== false;
8863
+ const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
8864
+ body,
8865
+ event,
8866
+ provider,
8867
+ query,
8868
+ request: input.request,
8869
+ sessionId
8870
+ }) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
8871
+ const idempotencyStore = options.idempotency?.store;
8872
+ if (idempotencyKey && idempotencyStore) {
8873
+ const existing = await idempotencyStore.get(idempotencyKey);
8874
+ if (existing) {
8875
+ const duplicateDecision = {
8876
+ ...existing,
8877
+ duplicate: true
8878
+ };
8879
+ await options.onDecision?.({
8880
+ ...duplicateDecision,
8881
+ context: options.context,
8882
+ request: input.request
8883
+ });
8884
+ return duplicateDecision;
8885
+ }
8886
+ }
8887
+ const decision = resolveVoiceTelephonyOutcome(event, options.policy);
8888
+ const resultResolver = options.result;
8889
+ const result = typeof resultResolver === "function" ? await resultResolver({
8890
+ decision,
8891
+ event,
8892
+ sessionId
8893
+ }) : resultResolver;
8894
+ const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
8895
+ const shouldApply = typeof options.apply === "function" ? options.apply({
8896
+ applied: false,
8897
+ decision,
8898
+ event,
8899
+ routeResult,
8900
+ sessionId
8901
+ }) : options.apply === true;
8902
+ let applied = false;
8903
+ if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
8904
+ const api = await options.getSessionHandle({
8905
+ context: options.context,
8906
+ decision,
8907
+ event,
8908
+ request: input.request,
8909
+ sessionId
8910
+ });
8911
+ if (api) {
8912
+ await applyVoiceTelephonyOutcome(api, decision, result);
8913
+ applied = true;
8914
+ }
8915
+ }
8916
+ const webhookDecision = {
8917
+ applied,
8918
+ decision,
8919
+ event,
8920
+ idempotencyKey,
8921
+ routeResult,
8922
+ sessionId
8923
+ };
8924
+ if (idempotencyKey && idempotencyStore) {
8925
+ const now = Date.now();
8926
+ await idempotencyStore.set(idempotencyKey, {
8927
+ ...webhookDecision,
8928
+ createdAt: now,
8929
+ updatedAt: now
8930
+ });
8931
+ }
8932
+ await options.onDecision?.({
8933
+ ...webhookDecision,
8934
+ context: options.context,
8935
+ request: input.request
8936
+ });
8937
+ return webhookDecision;
8938
+ };
8939
+ var createVoiceTelephonyWebhookRoutes = (options = {}) => {
8940
+ const path = options.path ?? "/api/voice/telephony/webhook";
8941
+ const handler = createVoiceTelephonyWebhookHandler(options);
8942
+ return new Elysia({
8943
+ name: options.name ?? "absolutejs-voice-telephony-webhooks"
8944
+ }).post(path, async ({ query, request }) => {
8945
+ try {
8946
+ return await handler({ query, request });
8947
+ } catch (error) {
8948
+ if (error instanceof VoiceTelephonyWebhookVerificationError) {
8949
+ return new Response(JSON.stringify({ verification: error.result }), {
8950
+ headers: {
8951
+ "content-type": "application/json"
8952
+ },
8953
+ status: 401
8954
+ });
8955
+ }
8956
+ throw error;
8957
+ }
8958
+ }, {
8959
+ parse: "none"
8960
+ });
8961
+ };
8962
+
8963
+ // src/telephony/twilio.ts
7287
8964
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
7288
8965
  var VOICE_PCM_SAMPLE_RATE = 16000;
7289
- var escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8966
+ var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8967
+ var resolveRequestOrigin = (request) => {
8968
+ const url = new URL(request.url);
8969
+ const forwardedHost = request.headers.get("x-forwarded-host");
8970
+ const forwardedProto = request.headers.get("x-forwarded-proto");
8971
+ const host = forwardedHost ?? request.headers.get("host") ?? url.host;
8972
+ const protocol = forwardedProto ?? url.protocol.replace(":", "");
8973
+ return `${protocol}://${host}`;
8974
+ };
8975
+ var resolveTwilioStreamUrl = async (options, input) => {
8976
+ if (typeof options.twiml?.streamUrl === "function") {
8977
+ return options.twiml.streamUrl(input);
8978
+ }
8979
+ if (typeof options.twiml?.streamUrl === "string") {
8980
+ return options.twiml.streamUrl;
8981
+ }
8982
+ const origin = resolveRequestOrigin(input.request);
8983
+ const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
8984
+ return `${wsOrigin}${input.streamPath}`;
8985
+ };
8986
+ var resolveTwilioStreamParameters = async (parameters, input) => {
8987
+ if (typeof parameters === "function") {
8988
+ return parameters(input);
8989
+ }
8990
+ return parameters;
8991
+ };
8992
+ var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
8993
+ var escapeHtml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8994
+ var getWebhookVerificationUrl = (webhook, input) => {
8995
+ if (!webhook?.verificationUrl) {
8996
+ return;
8997
+ }
8998
+ if (typeof webhook.verificationUrl === "function") {
8999
+ return webhook.verificationUrl(input);
9000
+ }
9001
+ return webhook.verificationUrl;
9002
+ };
9003
+ var buildTwilioVoiceSetupStatus = async (options, input) => {
9004
+ const origin = resolveRequestOrigin(input.request);
9005
+ const stream = await resolveTwilioStreamUrl(options, input);
9006
+ const twiml = joinUrlPath(origin, input.twimlPath);
9007
+ const webhook = joinUrlPath(origin, input.webhookPath);
9008
+ const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
9009
+ const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
9010
+ const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
9011
+ const warnings = [
9012
+ ...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
9013
+ ...signingConfigured ? [] : ["Webhook signature verification is not configured."],
9014
+ ...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
9015
+ ];
9016
+ return {
9017
+ generatedAt: Date.now(),
9018
+ missing,
9019
+ provider: "twilio",
9020
+ ready: missing.length === 0 && signingConfigured && warnings.length === 0,
9021
+ signing: {
9022
+ configured: signingConfigured,
9023
+ mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
9024
+ verificationUrl
9025
+ },
9026
+ urls: {
9027
+ stream,
9028
+ twiml,
9029
+ webhook
9030
+ },
9031
+ warnings
9032
+ };
9033
+ };
9034
+ var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
9035
+ <p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
9036
+ <h1>${escapeHtml2(title)}</h1>
9037
+ <p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
9038
+ <section>
9039
+ <h2>URLs</h2>
9040
+ <ul>
9041
+ <li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
9042
+ <li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
9043
+ <li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
9044
+ </ul>
9045
+ </section>
9046
+ <section>
9047
+ <h2>Signing</h2>
9048
+ <p>Mode: <code>${status.signing.mode}</code></p>
9049
+ ${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
9050
+ </section>
9051
+ ${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
9052
+ ${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
9053
+ </main>`;
9054
+ var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&amp;", "&");
9055
+ var createSmokeCheck = (name, status, message, details) => ({
9056
+ details,
9057
+ message,
9058
+ name,
9059
+ status
9060
+ });
9061
+ var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
9062
+ <p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
9063
+ <h1>${escapeHtml2(title)}</h1>
9064
+ <p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
9065
+ <section>
9066
+ <h2>Checks</h2>
9067
+ <ul>
9068
+ ${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
9069
+ </ul>
9070
+ </section>
9071
+ <section>
9072
+ <h2>Observed URLs</h2>
9073
+ <ul>
9074
+ <li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
9075
+ <li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
9076
+ <li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
9077
+ </ul>
9078
+ </section>
9079
+ </main>`;
9080
+ var runTwilioVoiceSmokeTest = async (input) => {
9081
+ const setup = await buildTwilioVoiceSetupStatus(input.options, input);
9082
+ const checks = [];
9083
+ const twimlUrl = new URL(setup.urls.twiml);
9084
+ twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
9085
+ twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
9086
+ const twimlResponse = await input.app.handle(new Request(twimlUrl, {
9087
+ headers: input.request.headers
9088
+ }));
9089
+ const twiml = await twimlResponse.text();
9090
+ const streamUrl = extractTwilioStreamUrl(twiml);
9091
+ checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
9092
+ status: twimlResponse.status,
9093
+ streamUrl
9094
+ }));
9095
+ 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.", {
9096
+ streamUrl
9097
+ }));
9098
+ const webhookBody = {
9099
+ CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
9100
+ CallStatus: input.options.smoke?.status ?? "busy",
9101
+ SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
9102
+ };
9103
+ const webhookHeaders = new Headers({
9104
+ "content-type": "application/x-www-form-urlencoded"
9105
+ });
9106
+ const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
9107
+ if (input.options.webhook?.signingSecret) {
9108
+ webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
9109
+ authToken: input.options.webhook.signingSecret,
9110
+ body: webhookBody,
9111
+ url: verificationUrl
9112
+ }));
9113
+ }
9114
+ const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
9115
+ body: new URLSearchParams(webhookBody),
9116
+ headers: webhookHeaders,
9117
+ method: "POST"
9118
+ }));
9119
+ const webhookText = await webhookResponse.text();
9120
+ const webhookPayload = (() => {
9121
+ try {
9122
+ return JSON.parse(webhookText);
9123
+ } catch {
9124
+ return webhookText;
9125
+ }
9126
+ })();
9127
+ checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
9128
+ status: webhookResponse.status
9129
+ }));
9130
+ for (const warning of setup.warnings) {
9131
+ checks.push(createSmokeCheck("setup-warning", "warn", warning));
9132
+ }
9133
+ for (const name of setup.missing) {
9134
+ checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
9135
+ }
9136
+ return {
9137
+ checks,
9138
+ generatedAt: Date.now(),
9139
+ pass: checks.every((check) => check.status !== "fail"),
9140
+ provider: "twilio",
9141
+ setup,
9142
+ twiml: {
9143
+ status: twimlResponse.status,
9144
+ streamUrl
9145
+ },
9146
+ webhook: {
9147
+ body: webhookPayload,
9148
+ status: webhookResponse.status
9149
+ }
9150
+ };
9151
+ };
7290
9152
  var normalizeOnTurn = (handler) => {
7291
9153
  if (handler.length > 1) {
7292
9154
  const directHandler = handler;
@@ -7388,7 +9250,7 @@ var bytesToInt16Array = (bytes) => {
7388
9250
  return output;
7389
9251
  };
7390
9252
  var decodeTwilioMulawBase64 = (payload) => {
7391
- const bytes = Uint8Array.from(Buffer2.from(payload, "base64"));
9253
+ const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
7392
9254
  const samples = new Int16Array(bytes.length);
7393
9255
  for (let index = 0;index < bytes.length; index += 1) {
7394
9256
  samples[index] = decodeMulawSample(bytes[index] ?? 0);
@@ -7400,7 +9262,7 @@ var encodeTwilioMulawBase64 = (samples) => {
7400
9262
  for (let index = 0;index < samples.length; index += 1) {
7401
9263
  bytes[index] = encodeMulawSample(samples[index] ?? 0);
7402
9264
  }
7403
- return Buffer2.from(bytes).toString("base64");
9265
+ return Buffer3.from(bytes).toString("base64");
7404
9266
  };
7405
9267
  var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
7406
9268
  const narrowband = decodeTwilioMulawBase64(payload);
@@ -7409,7 +9271,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
7409
9271
  };
7410
9272
  var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
7411
9273
  if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
7412
- return Buffer2.from(chunk).toString("base64");
9274
+ return Buffer3.from(chunk).toString("base64");
7413
9275
  }
7414
9276
  if (format.encoding !== "pcm_s16le") {
7415
9277
  throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
@@ -7450,7 +9312,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
7450
9312
  return;
7451
9313
  }
7452
9314
  if (message.type === "audio") {
7453
- const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer2.from(message.chunkBase64, "base64")), message.format);
9315
+ const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
7454
9316
  state.hasOutboundAudioSinceLastInbound = true;
7455
9317
  state.reviewRecorder?.recordTwilioOutbound({
7456
9318
  bytes: payload.length,
@@ -7482,8 +9344,8 @@ var createTwilioSocketAdapter = (socket, getState) => ({
7482
9344
  }
7483
9345
  });
7484
9346
  var createTwilioVoiceResponse = (options) => {
7485
- const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml(name)}" value="${escapeXml(String(value))}" />`).join("");
7486
- 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>`;
9347
+ const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml2(name)}" value="${escapeXml2(String(value))}" />`).join("");
9348
+ 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>`;
7487
9349
  };
7488
9350
  var createTwilioMediaStreamBridge = (socket, options) => {
7489
9351
  const runtimePreset = resolveVoiceRuntimePreset(options.preset);
@@ -7663,6 +9525,148 @@ var createTwilioMediaStreamBridge = (socket, options) => {
7663
9525
  }
7664
9526
  };
7665
9527
  };
9528
+ var createTwilioVoiceRoutes = (options) => {
9529
+ const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
9530
+ const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
9531
+ const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
9532
+ const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
9533
+ const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
9534
+ const bridges = new WeakMap;
9535
+ const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
9536
+ const app = new Elysia2({
9537
+ name: options.name ?? "absolutejs-voice-twilio"
9538
+ }).get(twimlPath, async ({ query, request }) => {
9539
+ const streamUrl = await resolveTwilioStreamUrl(options, {
9540
+ query,
9541
+ request,
9542
+ streamPath
9543
+ });
9544
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
9545
+ query,
9546
+ request
9547
+ });
9548
+ return new Response(createTwilioVoiceResponse({
9549
+ parameters,
9550
+ streamName: options.twiml?.streamName,
9551
+ streamUrl,
9552
+ track: options.twiml?.track
9553
+ }), {
9554
+ headers: {
9555
+ "content-type": "text/xml; charset=utf-8"
9556
+ }
9557
+ });
9558
+ }).post(twimlPath, async ({ query, request }) => {
9559
+ const streamUrl = await resolveTwilioStreamUrl(options, {
9560
+ query,
9561
+ request,
9562
+ streamPath
9563
+ });
9564
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
9565
+ query,
9566
+ request
9567
+ });
9568
+ return new Response(createTwilioVoiceResponse({
9569
+ parameters,
9570
+ streamName: options.twiml?.streamName,
9571
+ streamUrl,
9572
+ track: options.twiml?.track
9573
+ }), {
9574
+ headers: {
9575
+ "content-type": "text/xml; charset=utf-8"
9576
+ }
9577
+ });
9578
+ }).ws(streamPath, {
9579
+ close: async (ws, _code, reason) => {
9580
+ const bridge = bridges.get(ws);
9581
+ bridges.delete(ws);
9582
+ await bridge?.close(reason);
9583
+ },
9584
+ message: async (ws, raw) => {
9585
+ let bridge = bridges.get(ws);
9586
+ if (!bridge) {
9587
+ bridge = createTwilioMediaStreamBridge({
9588
+ close: (code, reason) => {
9589
+ ws.close(code, reason);
9590
+ },
9591
+ send: (data) => {
9592
+ ws.send(data);
9593
+ }
9594
+ }, options);
9595
+ bridges.set(ws, bridge);
9596
+ }
9597
+ await bridge.handleMessage(raw);
9598
+ }
9599
+ }).use(createVoiceTelephonyWebhookRoutes({
9600
+ ...options.webhook ?? {},
9601
+ context: options.context,
9602
+ path: webhookPath,
9603
+ policy: webhookPolicy,
9604
+ provider: "twilio"
9605
+ }));
9606
+ if (!setupPath) {
9607
+ if (!smokePath) {
9608
+ return app;
9609
+ }
9610
+ return app.get(smokePath, async ({ query, request }) => {
9611
+ const report = await runTwilioVoiceSmokeTest({
9612
+ app,
9613
+ options,
9614
+ query,
9615
+ request,
9616
+ streamPath,
9617
+ twimlPath,
9618
+ webhookPath
9619
+ });
9620
+ if (query.format === "html") {
9621
+ return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
9622
+ headers: {
9623
+ "content-type": "text/html; charset=utf-8"
9624
+ }
9625
+ });
9626
+ }
9627
+ return report;
9628
+ });
9629
+ }
9630
+ const withSetup = app.get(setupPath, async ({ query, request }) => {
9631
+ const status = await buildTwilioVoiceSetupStatus(options, {
9632
+ query,
9633
+ request,
9634
+ streamPath,
9635
+ twimlPath,
9636
+ webhookPath
9637
+ });
9638
+ if (query.format === "html") {
9639
+ return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
9640
+ headers: {
9641
+ "content-type": "text/html; charset=utf-8"
9642
+ }
9643
+ });
9644
+ }
9645
+ return status;
9646
+ });
9647
+ if (!smokePath) {
9648
+ return withSetup;
9649
+ }
9650
+ return withSetup.get(smokePath, async ({ query, request }) => {
9651
+ const report = await runTwilioVoiceSmokeTest({
9652
+ app,
9653
+ options,
9654
+ query,
9655
+ request,
9656
+ streamPath,
9657
+ twimlPath,
9658
+ webhookPath
9659
+ });
9660
+ if (query.format === "html") {
9661
+ return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
9662
+ headers: {
9663
+ "content-type": "text/html; charset=utf-8"
9664
+ }
9665
+ });
9666
+ }
9667
+ return report;
9668
+ });
9669
+ };
7666
9670
 
7667
9671
  // src/testing/telephony.ts
7668
9672
  var DEFAULT_PCM16_FORMAT = {
@@ -7928,7 +9932,7 @@ var runVoiceTelephonyBenchmark = async (scenarios = getDefaultVoiceTelephonyBenc
7928
9932
  };
7929
9933
  };
7930
9934
  // src/testing/tts.ts
7931
- var DEFAULT_REALTIME_FORMAT = {
9935
+ var DEFAULT_REALTIME_FORMAT2 = {
7932
9936
  channels: 1,
7933
9937
  container: "raw",
7934
9938
  encoding: "pcm_s16le",
@@ -7987,7 +9991,7 @@ var runTTSAdapterFixture = async (adapter, fixture, options = {}) => {
7987
9991
  let audioDurationMs = 0;
7988
9992
  let audioChunkCount = 0;
7989
9993
  const session = adapter.kind === "realtime" ? await adapter.open({
7990
- format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT,
9994
+ format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT2,
7991
9995
  sessionId: `tts-benchmark:${fixture.id}`,
7992
9996
  ...openOptions ?? {}
7993
9997
  }) : await adapter.open({
@@ -8154,6 +10158,7 @@ export {
8154
10158
  getDefaultTTSBenchmarkFixtures,
8155
10159
  evaluateSTTBenchmarkAcceptance,
8156
10160
  createVoiceProviderFailureSimulator,
10161
+ createVoiceIOProviderFailureSimulator,
8157
10162
  createVoiceCallReviewRecorder,
8158
10163
  createVoiceCallReviewFromLiveTelephonyReport,
8159
10164
  createTelephonyVoiceTestFixtures,