@absolutejs/voice 0.0.22-beta.26 → 0.0.22-beta.260

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 (226) 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 +3387 -1093
  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 +3 -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 +105 -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/connection.d.ts +3 -0
  39. package/dist/client/deliveryRuntime.d.ts +34 -0
  40. package/dist/client/deliveryRuntimeWidget.d.ts +37 -0
  41. package/dist/client/duplex.d.ts +1 -1
  42. package/dist/client/htmxBootstrap.js +747 -15
  43. package/dist/client/index.d.ts +66 -0
  44. package/dist/client/index.js +4972 -21
  45. package/dist/client/liveOps.d.ts +22 -0
  46. package/dist/client/liveOpsWidget.d.ts +23 -0
  47. package/dist/client/liveTurnLatency.d.ts +41 -0
  48. package/dist/client/opsActionCenter.d.ts +54 -0
  49. package/dist/client/opsActionCenterWidget.d.ts +29 -0
  50. package/dist/client/opsActionHistory.d.ts +19 -0
  51. package/dist/client/opsActionHistoryWidget.d.ts +11 -0
  52. package/dist/client/opsStatus.d.ts +19 -0
  53. package/dist/client/opsStatusWidget.d.ts +40 -0
  54. package/dist/client/platformCoverage.d.ts +19 -0
  55. package/dist/client/platformCoverageWidget.d.ts +37 -0
  56. package/dist/client/proofTrends.d.ts +19 -0
  57. package/dist/client/proofTrendsWidget.d.ts +37 -0
  58. package/dist/client/providerCapabilities.d.ts +19 -0
  59. package/dist/client/providerCapabilitiesWidget.d.ts +32 -0
  60. package/dist/client/providerContracts.d.ts +19 -0
  61. package/dist/client/providerContractsWidget.d.ts +37 -0
  62. package/dist/client/providerSimulationControls.d.ts +33 -0
  63. package/dist/client/providerSimulationControlsWidget.d.ts +20 -0
  64. package/dist/client/providerStatusWidget.d.ts +32 -0
  65. package/dist/client/routingStatus.d.ts +19 -0
  66. package/dist/client/routingStatusWidget.d.ts +28 -0
  67. package/dist/client/traceTimeline.d.ts +19 -0
  68. package/dist/client/traceTimelineWidget.d.ts +36 -0
  69. package/dist/client/turnLatency.d.ts +22 -0
  70. package/dist/client/turnLatencyWidget.d.ts +33 -0
  71. package/dist/client/turnQuality.d.ts +19 -0
  72. package/dist/client/turnQualityWidget.d.ts +32 -0
  73. package/dist/client/workflowStatus.d.ts +19 -0
  74. package/dist/dataControl.d.ts +180 -0
  75. package/dist/deliveryRuntime.d.ts +158 -0
  76. package/dist/deliverySinkRoutes.d.ts +117 -0
  77. package/dist/demoReadyRoutes.d.ts +98 -0
  78. package/dist/diagnosticsRoutes.d.ts +44 -0
  79. package/dist/evalRoutes.d.ts +219 -0
  80. package/dist/fileStore.d.ts +14 -2
  81. package/dist/guardrails.d.ts +128 -0
  82. package/dist/handoff.d.ts +54 -0
  83. package/dist/handoffHealth.d.ts +94 -0
  84. package/dist/incidentBundle.d.ts +116 -0
  85. package/dist/index.d.ts +132 -13
  86. package/dist/index.js +25379 -4971
  87. package/dist/latencySlo.d.ts +56 -0
  88. package/dist/liveLatency.d.ts +78 -0
  89. package/dist/liveOps.d.ts +122 -0
  90. package/dist/modelAdapters.d.ts +23 -2
  91. package/dist/observabilityExport.d.ts +481 -0
  92. package/dist/openaiRealtime.d.ts +27 -0
  93. package/dist/openaiTTS.d.ts +18 -0
  94. package/dist/operationsRecord.d.ts +210 -0
  95. package/dist/opsActionAuditRoutes.d.ts +99 -0
  96. package/dist/opsConsoleRoutes.d.ts +80 -0
  97. package/dist/opsRecovery.d.ts +137 -0
  98. package/dist/opsStatus.d.ts +76 -0
  99. package/dist/opsStatusRoutes.d.ts +33 -0
  100. package/dist/outcomeContract.d.ts +146 -0
  101. package/dist/phoneAgent.d.ts +116 -0
  102. package/dist/phoneAgentProductionSmoke.d.ts +115 -0
  103. package/dist/platformCoverage.d.ts +91 -0
  104. package/dist/postCallAnalysis.d.ts +98 -0
  105. package/dist/postgresStore.d.ts +13 -2
  106. package/dist/productionReadiness.d.ts +484 -0
  107. package/dist/proofTrends.d.ts +133 -0
  108. package/dist/providerAdapters.d.ts +48 -0
  109. package/dist/providerCapabilities.d.ts +92 -0
  110. package/dist/providerHealth.d.ts +1 -0
  111. package/dist/providerRoutingContract.d.ts +71 -0
  112. package/dist/providerSlo.d.ts +142 -0
  113. package/dist/providerStackRecommendations.d.ts +187 -0
  114. package/dist/qualityRoutes.d.ts +76 -0
  115. package/dist/queue.d.ts +61 -0
  116. package/dist/react/VoiceAgentSquadStatus.d.ts +5 -0
  117. package/dist/react/VoiceDeliveryRuntime.d.ts +7 -0
  118. package/dist/react/VoiceOpsActionCenter.d.ts +5 -0
  119. package/dist/react/VoiceOpsStatus.d.ts +6 -0
  120. package/dist/react/VoicePlatformCoverage.d.ts +6 -0
  121. package/dist/react/VoiceProofTrends.d.ts +6 -0
  122. package/dist/react/VoiceProviderCapabilities.d.ts +6 -0
  123. package/dist/react/VoiceProviderContracts.d.ts +6 -0
  124. package/dist/react/VoiceProviderSimulationControls.d.ts +5 -0
  125. package/dist/react/VoiceProviderStatus.d.ts +6 -0
  126. package/dist/react/VoiceRoutingStatus.d.ts +6 -0
  127. package/dist/react/VoiceTraceTimeline.d.ts +6 -0
  128. package/dist/react/VoiceTurnLatency.d.ts +6 -0
  129. package/dist/react/VoiceTurnQuality.d.ts +6 -0
  130. package/dist/react/index.d.ts +30 -0
  131. package/dist/react/index.js +4817 -33
  132. package/dist/react/useVoiceAgentSquadStatus.d.ts +8 -0
  133. package/dist/react/useVoiceCampaignDialerProof.d.ts +10 -0
  134. package/dist/react/useVoiceController.d.ts +3 -0
  135. package/dist/react/useVoiceDeliveryRuntime.d.ts +13 -0
  136. package/dist/react/useVoiceLiveOps.d.ts +9 -0
  137. package/dist/react/useVoiceOpsActionCenter.d.ts +11 -0
  138. package/dist/react/useVoiceOpsStatus.d.ts +8 -0
  139. package/dist/react/useVoicePlatformCoverage.d.ts +8 -0
  140. package/dist/react/useVoiceProofTrends.d.ts +8 -0
  141. package/dist/react/useVoiceProviderCapabilities.d.ts +8 -0
  142. package/dist/react/useVoiceProviderContracts.d.ts +8 -0
  143. package/dist/react/useVoiceProviderSimulationControls.d.ts +10 -0
  144. package/dist/react/useVoiceRoutingStatus.d.ts +8 -0
  145. package/dist/react/useVoiceStream.d.ts +3 -0
  146. package/dist/react/useVoiceTraceTimeline.d.ts +8 -0
  147. package/dist/react/useVoiceTurnLatency.d.ts +9 -0
  148. package/dist/react/useVoiceTurnQuality.d.ts +8 -0
  149. package/dist/react/useVoiceWorkflowStatus.d.ts +8 -0
  150. package/dist/readinessProfiles.d.ts +37 -0
  151. package/dist/reconnectContract.d.ts +87 -0
  152. package/dist/resilienceRoutes.d.ts +143 -0
  153. package/dist/sessionReplay.d.ts +12 -0
  154. package/dist/simulationSuite.d.ts +143 -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 +4754 -439
  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 +254 -0
  178. package/dist/telephony/telnyx.d.ts +247 -0
  179. package/dist/telephony/twilio.d.ts +135 -2
  180. package/dist/telephonyOutcome.d.ts +201 -0
  181. package/dist/testing/index.d.ts +1 -0
  182. package/dist/testing/index.js +2024 -69
  183. package/dist/testing/ioProviderSimulator.d.ts +41 -0
  184. package/dist/toolContract.d.ts +161 -0
  185. package/dist/toolRuntime.d.ts +50 -0
  186. package/dist/trace.d.ts +19 -1
  187. package/dist/traceDeliveryRoutes.d.ts +86 -0
  188. package/dist/traceTimeline.d.ts +97 -0
  189. package/dist/turnLatency.d.ts +95 -0
  190. package/dist/turnQuality.d.ts +94 -0
  191. package/dist/types.d.ts +180 -4
  192. package/dist/vue/VoiceDeliveryRuntime.d.ts +30 -0
  193. package/dist/vue/VoiceOpsActionCenter.d.ts +13 -0
  194. package/dist/vue/VoiceOpsStatus.d.ts +30 -0
  195. package/dist/vue/VoicePlatformCoverage.d.ts +23 -0
  196. package/dist/vue/VoiceProofTrends.d.ts +21 -0
  197. package/dist/vue/VoiceProviderCapabilities.d.ts +51 -0
  198. package/dist/vue/VoiceProviderContracts.d.ts +21 -0
  199. package/dist/vue/VoiceProviderSimulationControls.d.ts +88 -0
  200. package/dist/vue/VoiceProviderStatus.d.ts +51 -0
  201. package/dist/vue/VoiceRoutingStatus.d.ts +51 -0
  202. package/dist/vue/VoiceTurnLatency.d.ts +69 -0
  203. package/dist/vue/VoiceTurnQuality.d.ts +51 -0
  204. package/dist/vue/index.d.ts +28 -0
  205. package/dist/vue/index.js +4597 -57
  206. package/dist/vue/useVoiceAgentSquadStatus.d.ts +9 -0
  207. package/dist/vue/useVoiceCampaignDialerProof.d.ts +11 -0
  208. package/dist/vue/useVoiceController.d.ts +2 -1
  209. package/dist/vue/useVoiceDeliveryRuntime.d.ts +13 -0
  210. package/dist/vue/useVoiceLiveOps.d.ts +9 -0
  211. package/dist/vue/useVoiceOpsActionCenter.d.ts +11 -0
  212. package/dist/vue/useVoiceOpsStatus.d.ts +9 -0
  213. package/dist/vue/useVoicePlatformCoverage.d.ts +9 -0
  214. package/dist/vue/useVoiceProofTrends.d.ts +9 -0
  215. package/dist/vue/useVoiceProviderCapabilities.d.ts +9 -0
  216. package/dist/vue/useVoiceProviderContracts.d.ts +9 -0
  217. package/dist/vue/useVoiceProviderSimulationControls.d.ts +24 -0
  218. package/dist/vue/useVoiceProviderStatus.d.ts +1 -1
  219. package/dist/vue/useVoiceRoutingStatus.d.ts +8 -0
  220. package/dist/vue/useVoiceStream.d.ts +4 -1
  221. package/dist/vue/useVoiceTraceTimeline.d.ts +9 -0
  222. package/dist/vue/useVoiceTurnLatency.d.ts +10 -0
  223. package/dist/vue/useVoiceTurnQuality.d.ts +9 -0
  224. package/dist/vue/useVoiceWorkflowStatus.d.ts +9 -0
  225. package/dist/workflowContract.d.ts +91 -0
  226. package/package.json +1 -1
@@ -2105,6 +2105,17 @@ var serverMessageToAction = (message) => {
2105
2105
  sessionId: message.sessionId,
2106
2106
  type: "complete"
2107
2107
  };
2108
+ case "connection":
2109
+ return {
2110
+ reconnect: message.reconnect,
2111
+ type: "connection"
2112
+ };
2113
+ case "call_lifecycle":
2114
+ return {
2115
+ event: message.event,
2116
+ sessionId: message.sessionId,
2117
+ type: "call_lifecycle"
2118
+ };
2108
2119
  case "error":
2109
2120
  return {
2110
2121
  message: normalizeErrorMessage(message.message),
@@ -2120,6 +2131,17 @@ var serverMessageToAction = (message) => {
2120
2131
  transcript: message.transcript,
2121
2132
  type: "partial"
2122
2133
  };
2134
+ case "replay":
2135
+ return {
2136
+ assistantTexts: message.assistantTexts,
2137
+ call: message.call,
2138
+ partial: message.partial,
2139
+ scenarioId: message.scenarioId,
2140
+ sessionId: message.sessionId,
2141
+ status: message.status,
2142
+ turns: message.turns,
2143
+ type: "replay"
2144
+ };
2123
2145
  case "session":
2124
2146
  return {
2125
2147
  sessionId: message.sessionId,
@@ -2148,7 +2170,7 @@ var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
2148
2170
  var noop = () => {};
2149
2171
  var noopUnsubscribe = () => noop;
2150
2172
  var NOOP_CONNECTION = {
2151
- start: () => {},
2173
+ callControl: noop,
2152
2174
  close: noop,
2153
2175
  endTurn: noop,
2154
2176
  getReadyState: () => WS_CLOSED,
@@ -2156,6 +2178,7 @@ var NOOP_CONNECTION = {
2156
2178
  getSessionId: () => "",
2157
2179
  send: noop,
2158
2180
  sendAudio: noop,
2181
+ start: () => {},
2159
2182
  subscribe: noopUnsubscribe
2160
2183
  };
2161
2184
  var createSessionId = () => crypto.randomUUID();
@@ -2177,11 +2200,14 @@ var isVoiceServerMessage = (value) => {
2177
2200
  switch (value.type) {
2178
2201
  case "audio":
2179
2202
  case "assistant":
2203
+ case "call_lifecycle":
2180
2204
  case "complete":
2205
+ case "connection":
2181
2206
  case "error":
2182
2207
  case "final":
2183
2208
  case "partial":
2184
2209
  case "pong":
2210
+ case "replay":
2185
2211
  case "session":
2186
2212
  case "turn":
2187
2213
  return true;
@@ -2218,6 +2244,9 @@ var createVoiceConnection = (path, options = {}) => {
2218
2244
  sessionId: options.sessionId ?? createSessionId(),
2219
2245
  ws: null
2220
2246
  };
2247
+ const emitConnection = (reconnect) => {
2248
+ listeners.forEach((listener) => listener(reconnect));
2249
+ };
2221
2250
  const clearTimers = () => {
2222
2251
  if (state.pingInterval) {
2223
2252
  clearInterval(state.pingInterval);
@@ -2240,9 +2269,28 @@ var createVoiceConnection = (path, options = {}) => {
2240
2269
  }
2241
2270
  };
2242
2271
  const scheduleReconnect = () => {
2272
+ const nextAttemptAt = Date.now() + RECONNECT_DELAY_MS;
2243
2273
  state.reconnectAttempts += 1;
2274
+ emitConnection({
2275
+ reconnect: {
2276
+ attempts: state.reconnectAttempts,
2277
+ lastDisconnectAt: Date.now(),
2278
+ maxAttempts: maxReconnectAttempts,
2279
+ nextAttemptAt,
2280
+ status: "reconnecting"
2281
+ },
2282
+ type: "connection"
2283
+ });
2244
2284
  state.reconnectTimeout = setTimeout(() => {
2245
2285
  if (state.reconnectAttempts > maxReconnectAttempts) {
2286
+ emitConnection({
2287
+ reconnect: {
2288
+ attempts: state.reconnectAttempts,
2289
+ maxAttempts: maxReconnectAttempts,
2290
+ status: "exhausted"
2291
+ },
2292
+ type: "connection"
2293
+ });
2246
2294
  return;
2247
2295
  }
2248
2296
  connect();
@@ -2252,9 +2300,21 @@ var createVoiceConnection = (path, options = {}) => {
2252
2300
  const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId));
2253
2301
  ws.binaryType = "arraybuffer";
2254
2302
  ws.onopen = () => {
2303
+ const wasReconnecting = state.reconnectAttempts > 0;
2255
2304
  state.isConnected = true;
2256
- state.reconnectAttempts = 0;
2257
2305
  flushPendingMessages();
2306
+ if (wasReconnecting) {
2307
+ emitConnection({
2308
+ reconnect: {
2309
+ attempts: state.reconnectAttempts,
2310
+ lastResumedAt: Date.now(),
2311
+ maxAttempts: maxReconnectAttempts,
2312
+ status: "resumed"
2313
+ },
2314
+ type: "connection"
2315
+ });
2316
+ state.reconnectAttempts = 0;
2317
+ }
2258
2318
  listeners.forEach((listener) => listener({
2259
2319
  scenarioId: state.scenarioId ?? undefined,
2260
2320
  sessionId: state.sessionId,
@@ -2284,6 +2344,16 @@ var createVoiceConnection = (path, options = {}) => {
2284
2344
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && state.reconnectAttempts < maxReconnectAttempts;
2285
2345
  if (reconnectable) {
2286
2346
  scheduleReconnect();
2347
+ } else if (shouldReconnect && event.code !== WS_NORMAL_CLOSURE) {
2348
+ emitConnection({
2349
+ reconnect: {
2350
+ attempts: state.reconnectAttempts,
2351
+ lastDisconnectAt: Date.now(),
2352
+ maxAttempts: maxReconnectAttempts,
2353
+ status: "exhausted"
2354
+ },
2355
+ type: "connection"
2356
+ });
2287
2357
  }
2288
2358
  };
2289
2359
  state.ws = ws;
@@ -2317,6 +2387,12 @@ var createVoiceConnection = (path, options = {}) => {
2317
2387
  const endTurn = () => {
2318
2388
  send({ type: "end_turn" });
2319
2389
  };
2390
+ const callControl = (message) => {
2391
+ send({
2392
+ ...message,
2393
+ type: "call_control"
2394
+ });
2395
+ };
2320
2396
  const close = () => {
2321
2397
  clearTimers();
2322
2398
  if (state.ws) {
@@ -2334,7 +2410,7 @@ var createVoiceConnection = (path, options = {}) => {
2334
2410
  };
2335
2411
  connect();
2336
2412
  return {
2337
- start,
2413
+ callControl,
2338
2414
  close,
2339
2415
  endTurn,
2340
2416
  getReadyState: () => state.ws?.readyState ?? WS_CLOSED,
@@ -2342,18 +2418,26 @@ var createVoiceConnection = (path, options = {}) => {
2342
2418
  getSessionId: () => state.sessionId,
2343
2419
  send,
2344
2420
  sendAudio,
2421
+ start,
2345
2422
  subscribe
2346
2423
  };
2347
2424
  };
2348
2425
 
2349
2426
  // src/client/store.ts
2427
+ var createInitialReconnectState = () => ({
2428
+ attempts: 0,
2429
+ maxAttempts: 0,
2430
+ status: "idle"
2431
+ });
2350
2432
  var createInitialState2 = () => ({
2351
2433
  assistantAudio: [],
2352
2434
  assistantTexts: [],
2435
+ call: null,
2353
2436
  error: null,
2354
2437
  isConnected: false,
2355
2438
  scenarioId: null,
2356
2439
  partial: "",
2440
+ reconnect: createInitialReconnectState(),
2357
2441
  sessionId: null,
2358
2442
  status: "idle",
2359
2443
  turns: []
@@ -2393,10 +2477,36 @@ var createVoiceStreamStore = () => {
2393
2477
  status: "completed"
2394
2478
  };
2395
2479
  break;
2480
+ case "call_lifecycle":
2481
+ state = {
2482
+ ...state,
2483
+ call: {
2484
+ ...state.call,
2485
+ disposition: action.event.type === "end" ? action.event.disposition : state.call?.disposition,
2486
+ endedAt: action.event.type === "end" ? action.event.at : state.call?.endedAt,
2487
+ events: [...state.call?.events ?? [], action.event],
2488
+ lastEventAt: action.event.at,
2489
+ startedAt: state.call?.startedAt ?? action.event.at
2490
+ },
2491
+ sessionId: action.sessionId
2492
+ };
2493
+ break;
2396
2494
  case "connected":
2397
2495
  state = {
2398
2496
  ...state,
2399
- isConnected: true
2497
+ isConnected: true,
2498
+ reconnect: state.reconnect.status === "reconnecting" ? {
2499
+ ...state.reconnect,
2500
+ lastResumedAt: Date.now(),
2501
+ nextAttemptAt: undefined,
2502
+ status: "resumed"
2503
+ } : state.reconnect
2504
+ };
2505
+ break;
2506
+ case "connection":
2507
+ state = {
2508
+ ...state,
2509
+ reconnect: action.reconnect
2400
2510
  };
2401
2511
  break;
2402
2512
  case "disconnected":
@@ -2424,6 +2534,26 @@ var createVoiceStreamStore = () => {
2424
2534
  partial: action.transcript.text
2425
2535
  };
2426
2536
  break;
2537
+ case "replay":
2538
+ state = {
2539
+ ...state,
2540
+ assistantTexts: [...action.assistantTexts],
2541
+ call: action.call ?? null,
2542
+ error: null,
2543
+ isConnected: action.status === "active",
2544
+ partial: action.partial,
2545
+ reconnect: state.reconnect.status === "reconnecting" ? {
2546
+ ...state.reconnect,
2547
+ lastResumedAt: Date.now(),
2548
+ nextAttemptAt: undefined,
2549
+ status: "resumed"
2550
+ } : state.reconnect,
2551
+ scenarioId: action.scenarioId ?? state.scenarioId,
2552
+ sessionId: action.sessionId,
2553
+ status: action.status,
2554
+ turns: [...action.turns]
2555
+ };
2556
+ break;
2427
2557
  case "session":
2428
2558
  state = {
2429
2559
  ...state,
@@ -2471,14 +2601,41 @@ var createVoiceStream = (path, options = {}) => {
2471
2601
  const notify = () => {
2472
2602
  subscribers.forEach((subscriber) => subscriber());
2473
2603
  };
2604
+ const reportReconnect = () => {
2605
+ if (!options.reconnectReportPath || typeof fetch === "undefined") {
2606
+ return;
2607
+ }
2608
+ const snapshot = store.getSnapshot();
2609
+ const body = JSON.stringify({
2610
+ at: Date.now(),
2611
+ reconnect: snapshot.reconnect,
2612
+ scenarioId: snapshot.scenarioId,
2613
+ sessionId: connection.getSessionId(),
2614
+ turnIds: snapshot.turns.map((turn) => turn.id)
2615
+ });
2616
+ fetch(options.reconnectReportPath, {
2617
+ body,
2618
+ headers: {
2619
+ "Content-Type": "application/json"
2620
+ },
2621
+ keepalive: true,
2622
+ method: "POST"
2623
+ }).catch(() => {});
2624
+ };
2474
2625
  const unsubscribeConnection = connection.subscribe((message) => {
2475
2626
  const action = serverMessageToAction(message);
2476
2627
  if (action) {
2477
2628
  store.dispatch(action);
2629
+ if (message.type === "connection") {
2630
+ reportReconnect();
2631
+ }
2478
2632
  notify();
2479
2633
  }
2480
2634
  });
2481
2635
  return {
2636
+ callControl(message) {
2637
+ connection.callControl(message);
2638
+ },
2482
2639
  close() {
2483
2640
  unsubscribeConnection();
2484
2641
  connection.close();
@@ -2507,6 +2664,9 @@ var createVoiceStream = (path, options = {}) => {
2507
2664
  get partial() {
2508
2665
  return store.getSnapshot().partial;
2509
2666
  },
2667
+ get reconnect() {
2668
+ return store.getSnapshot().reconnect;
2669
+ },
2510
2670
  get sessionId() {
2511
2671
  return connection.getSessionId();
2512
2672
  },
@@ -2522,6 +2682,9 @@ var createVoiceStream = (path, options = {}) => {
2522
2682
  get assistantAudio() {
2523
2683
  return store.getSnapshot().assistantAudio;
2524
2684
  },
2685
+ get call() {
2686
+ return store.getSnapshot().call;
2687
+ },
2525
2688
  sendAudio(audio) {
2526
2689
  connection.sendAudio(audio);
2527
2690
  },
@@ -2854,10 +3017,12 @@ var resolveVoiceRuntimePreset = (name = "default") => {
2854
3017
  var createInitialState3 = (stream) => ({
2855
3018
  assistantAudio: [...stream.assistantAudio],
2856
3019
  assistantTexts: [...stream.assistantTexts],
3020
+ call: stream.call,
2857
3021
  error: stream.error,
2858
3022
  isConnected: stream.isConnected,
2859
3023
  isRecording: false,
2860
3024
  partial: stream.partial,
3025
+ reconnect: stream.reconnect,
2861
3026
  recordingError: null,
2862
3027
  sessionId: stream.sessionId,
2863
3028
  scenarioId: stream.scenarioId,
@@ -2883,9 +3048,11 @@ var createVoiceController = (path, options = {}) => {
2883
3048
  ...state,
2884
3049
  assistantAudio: [...stream.assistantAudio],
2885
3050
  assistantTexts: [...stream.assistantTexts],
3051
+ call: stream.call,
2886
3052
  error: stream.error,
2887
3053
  isConnected: stream.isConnected,
2888
3054
  partial: stream.partial,
3055
+ reconnect: stream.reconnect,
2889
3056
  sessionId: stream.sessionId,
2890
3057
  scenarioId: stream.scenarioId,
2891
3058
  status: stream.status,
@@ -2910,7 +3077,13 @@ var createVoiceController = (path, options = {}) => {
2910
3077
  capture = createMicrophoneCapture({
2911
3078
  channelCount: options.capture?.channelCount ?? preset.capture.channelCount,
2912
3079
  onLevel: options.capture?.onLevel,
2913
- onAudio: (audio) => stream.sendAudio(audio),
3080
+ onAudio: (audio) => {
3081
+ if (options.capture?.onAudio) {
3082
+ options.capture.onAudio(audio, stream.sendAudio);
3083
+ return;
3084
+ }
3085
+ stream.sendAudio(audio);
3086
+ },
2914
3087
  sampleRateHz: options.capture?.sampleRateHz ?? preset.capture.sampleRateHz
2915
3088
  });
2916
3089
  return capture;
@@ -2960,6 +3133,7 @@ var createVoiceController = (path, options = {}) => {
2960
3133
  bindHTMX(bindingOptions) {
2961
3134
  return bindVoiceHTMX(stream, bindingOptions);
2962
3135
  },
3136
+ callControl: (message) => stream.callControl(message),
2963
3137
  close,
2964
3138
  endTurn: () => stream.endTurn(),
2965
3139
  get error() {
@@ -2979,6 +3153,9 @@ var createVoiceController = (path, options = {}) => {
2979
3153
  get recordingError() {
2980
3154
  return state.recordingError;
2981
3155
  },
3156
+ get reconnect() {
3157
+ return state.reconnect;
3158
+ },
2982
3159
  sendAudio: (audio) => stream.sendAudio(audio),
2983
3160
  get sessionId() {
2984
3161
  return state.sessionId;
@@ -3012,6 +3189,9 @@ var createVoiceController = (path, options = {}) => {
3012
3189
  },
3013
3190
  get assistantAudio() {
3014
3191
  return state.assistantAudio;
3192
+ },
3193
+ get call() {
3194
+ return state.call;
3015
3195
  }
3016
3196
  };
3017
3197
  };
@@ -3021,11 +3201,26 @@ var DEFAULT_INTERRUPT_THRESHOLD = 0.08;
3021
3201
  var shouldInterruptForLevel = (level, options = {}) => (options.enabled ?? true) && level >= (options.interruptThreshold ?? DEFAULT_INTERRUPT_THRESHOLD);
3022
3202
  var bindVoiceBargeIn = (controller, player, options = {}) => {
3023
3203
  let lastPartial = controller.partial;
3024
- const interruptIfPlaying = () => {
3204
+ const interruptIfPlaying = (reason) => {
3025
3205
  if (!player.isPlaying || options.enabled === false) {
3206
+ options.monitor?.recordSkipped({
3207
+ reason,
3208
+ sessionId: controller.sessionId
3209
+ });
3026
3210
  return;
3027
3211
  }
3028
- player.interrupt();
3212
+ options.monitor?.recordRequested({
3213
+ reason,
3214
+ sessionId: controller.sessionId
3215
+ });
3216
+ player.interrupt().then(() => {
3217
+ options.monitor?.recordStopped({
3218
+ latencyMs: player.lastInterruptLatencyMs,
3219
+ playbackStopLatencyMs: player.lastPlaybackStopLatencyMs,
3220
+ reason,
3221
+ sessionId: controller.sessionId
3222
+ });
3223
+ });
3029
3224
  };
3030
3225
  const unsubscribe = controller.subscribe(() => {
3031
3226
  if (options.interruptOnPartial === false) {
@@ -3033,7 +3228,7 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
3033
3228
  return;
3034
3229
  }
3035
3230
  if (!lastPartial && controller.partial) {
3036
- interruptIfPlaying();
3231
+ interruptIfPlaying("partial-transcript");
3037
3232
  }
3038
3233
  lastPartial = controller.partial;
3039
3234
  });
@@ -3043,11 +3238,11 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
3043
3238
  },
3044
3239
  handleLevel: (level) => {
3045
3240
  if (shouldInterruptForLevel(level, options)) {
3046
- interruptIfPlaying();
3241
+ interruptIfPlaying("input-level");
3047
3242
  }
3048
3243
  },
3049
3244
  sendAudio: (audio) => {
3050
- interruptIfPlaying();
3245
+ interruptIfPlaying("manual-audio");
3051
3246
  controller.sendAudio(audio);
3052
3247
  }
3053
3248
  };
@@ -3077,7 +3272,17 @@ var createVoiceDuplexController = (path, options = {}) => {
3077
3272
  audioPlayer,
3078
3273
  close,
3079
3274
  interruptAssistant: async () => {
3275
+ options.bargeIn?.monitor?.recordRequested({
3276
+ reason: "manual-interrupt",
3277
+ sessionId: controller.sessionId
3278
+ });
3080
3279
  await audioPlayer.interrupt();
3280
+ options.bargeIn?.monitor?.recordStopped({
3281
+ latencyMs: audioPlayer.lastInterruptLatencyMs,
3282
+ playbackStopLatencyMs: audioPlayer.lastPlaybackStopLatencyMs,
3283
+ reason: "manual-interrupt",
3284
+ sessionId: controller.sessionId
3285
+ });
3081
3286
  },
3082
3287
  sendAudio: (audio) => {
3083
3288
  bargeInBinding?.sendAudio(audio);
@@ -3468,7 +3673,165 @@ var loadVoiceTestFixtures = async (fixtureDirectory) => {
3468
3673
  }
3469
3674
  return fixtures;
3470
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
+ };
3471
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
+ };
3472
3835
  var OUTPUT_SCHEMA = {
3473
3836
  additionalProperties: false,
3474
3837
  properties: {
@@ -3559,6 +3922,17 @@ var parseJSONValue = (value) => {
3559
3922
  return value;
3560
3923
  }
3561
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
+ }
3562
3936
  var getMessageToolCalls = (message) => {
3563
3937
  const toolCalls = message.metadata?.toolCalls;
3564
3938
  return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
@@ -3625,7 +3999,7 @@ var createJSONVoiceAssistantModel = (options) => ({
3625
3999
  var createVoiceProviderRouter = (options) => {
3626
4000
  const providerIds = Object.keys(options.providers);
3627
4001
  const firstProvider = providerIds[0];
3628
- 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) : {
3629
4003
  strategy: options.policy
3630
4004
  } : options.policy;
3631
4005
  const strategy = policy?.strategy ?? "prefer-selected";
@@ -3636,6 +4010,10 @@ var createVoiceProviderRouter = (options) => {
3636
4010
  const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
3637
4011
  const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
3638
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
+ };
3639
4017
  const getHealth = (provider) => {
3640
4018
  const existing = healthState.get(provider);
3641
4019
  if (existing) {
@@ -3703,13 +4081,40 @@ var createVoiceProviderRouter = (options) => {
3703
4081
  const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
3704
4082
  return new Set(allowed ?? providerIds);
3705
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
+ };
3706
4105
  const sortProviders = (providers) => {
3707
- if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest") {
4106
+ if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
3708
4107
  return providers;
3709
4108
  }
3710
4109
  return [...providers].sort((left, right) => {
3711
4110
  const leftProfile = options.providerProfiles?.[left];
3712
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
+ }
3713
4118
  const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
3714
4119
  const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
3715
4120
  return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
@@ -3719,12 +4124,13 @@ var createVoiceProviderRouter = (options) => {
3719
4124
  const selectedProvider = await options.selectProvider?.(input);
3720
4125
  const allowedProviders = await resolveAllowedProviders(input);
3721
4126
  const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
3722
- const rankedProviders = sortProviders([
4127
+ const allowedRankedProviders = sortProviders([
3723
4128
  ...fallbackOrder ?? providerIds
3724
4129
  ]).filter((provider) => allowedProviders.has(provider));
4130
+ const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
3725
4131
  const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
3726
4132
  const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
3727
- 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;
3728
4134
  const seen = new Set;
3729
4135
  const order = [];
3730
4136
  const candidates = strategy === "ordered" ? candidateRankedProviders : [
@@ -3747,6 +4153,25 @@ var createVoiceProviderRouter = (options) => {
3747
4153
  const emit = async (event, input) => {
3748
4154
  await options.onProviderEvent?.(event, input);
3749
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
+ };
3750
4175
  return {
3751
4176
  generate: async (input) => {
3752
4177
  const { order, selectedProvider } = await resolveOrder(input);
@@ -3761,12 +4186,14 @@ var createVoiceProviderRouter = (options) => {
3761
4186
  }
3762
4187
  const startedAt = Date.now();
3763
4188
  try {
3764
- const output = await model.generate(input);
4189
+ const output = await runProvider(provider, model, input);
3765
4190
  const providerHealth = recordProviderSuccess(provider);
3766
4191
  await emit({
3767
4192
  at: Date.now(),
4193
+ attempt: index + 1,
3768
4194
  elapsedMs: Date.now() - startedAt,
3769
4195
  fallbackProvider: provider === selectedProvider ? undefined : provider,
4196
+ latencyBudgetMs: getProviderTimeoutMs(provider),
3770
4197
  provider,
3771
4198
  providerHealth,
3772
4199
  recovered: provider !== selectedProvider,
@@ -3778,22 +4205,26 @@ var createVoiceProviderRouter = (options) => {
3778
4205
  lastError = error;
3779
4206
  const hasNextProvider = index < order.length - 1;
3780
4207
  const isProviderError = options.isProviderError?.(error, provider) ?? true;
4208
+ const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
3781
4209
  const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
3782
4210
  const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
3783
4211
  const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
3784
4212
  const nextProvider = hasNextProvider ? order[index + 1] : undefined;
3785
4213
  await emit({
3786
4214
  at: Date.now(),
4215
+ attempt: index + 1,
3787
4216
  elapsedMs: Date.now() - startedAt,
3788
4217
  error: errorMessage(error),
3789
4218
  fallbackProvider: shouldFallback ? nextProvider : undefined,
4219
+ latencyBudgetMs: getProviderTimeoutMs(provider),
3790
4220
  provider,
3791
4221
  providerHealth,
3792
4222
  rateLimited,
3793
4223
  selectedProvider,
3794
4224
  suppressionRemainingMs: getSuppressionRemainingMs(provider),
3795
4225
  suppressedUntil: providerHealth?.suppressedUntil,
3796
- status: "error"
4226
+ status: "error",
4227
+ timedOut
3797
4228
  }, input);
3798
4229
  if (!hasNextProvider || !shouldFallback) {
3799
4230
  throw error;
@@ -4415,7 +4846,290 @@ var createVoiceMemoryStore = () => {
4415
4846
  };
4416
4847
 
4417
4848
  // src/session.ts
4418
- 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
+ });
4419
5133
 
4420
5134
  // src/logger.ts
4421
5135
  var noop2 = () => {};
@@ -4451,6 +5165,12 @@ var DEFAULT_FORMAT = {
4451
5165
  encoding: "pcm_s16le",
4452
5166
  sampleRateHz: 16000
4453
5167
  };
5168
+ var DEFAULT_REALTIME_FORMAT = {
5169
+ channels: 1,
5170
+ container: "raw",
5171
+ encoding: "pcm_s16le",
5172
+ sampleRateHz: 24000
5173
+ };
4454
5174
  var toError = (value) => value instanceof Error ? value : new Error(String(value));
4455
5175
  var createEmptyCurrentTurn = () => ({
4456
5176
  finalText: "",
@@ -4463,7 +5183,7 @@ var createEmptyCurrentTurn = () => ({
4463
5183
  transcripts: []
4464
5184
  });
4465
5185
  var cloneTranscript = (transcript) => ({ ...transcript });
4466
- var encodeBase64 = (chunk) => Buffer.from(chunk).toString("base64");
5186
+ var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
4467
5187
  var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
4468
5188
  var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
4469
5189
  var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
@@ -4609,6 +5329,7 @@ var pushCallLifecycleEvent = (session, input) => {
4609
5329
  }
4610
5330
  return lifecycle;
4611
5331
  };
5332
+ var getLatestCallLifecycleEvent = (session) => session.call?.events.at(-1);
4612
5333
  var createVoiceSession = (options) => {
4613
5334
  const logger = resolveLogger(options.logger);
4614
5335
  const reconnect = {
@@ -4633,7 +5354,7 @@ var createVoiceSession = (options) => {
4633
5354
  } : undefined;
4634
5355
  const appendTrace = async (input) => {
4635
5356
  await options.trace?.append({
4636
- at: Date.now(),
5357
+ at: input.at ?? Date.now(),
4637
5358
  metadata: input.metadata,
4638
5359
  payload: input.payload,
4639
5360
  scenarioId: input.session?.scenarioId ?? options.scenarioId,
@@ -4642,6 +5363,13 @@ var createVoiceSession = (options) => {
4642
5363
  type: input.type
4643
5364
  });
4644
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
+ });
4645
5373
  const phraseHints = options.phraseHints ?? [];
4646
5374
  const lexicon = options.lexicon ?? [];
4647
5375
  let socket = options.socket;
@@ -4709,35 +5437,105 @@ var createVoiceSession = (options) => {
4709
5437
  });
4710
5438
  }
4711
5439
  };
4712
- const readSession = async () => options.store.getOrCreate(options.id);
4713
- const writeSession = async (mutate) => {
4714
- const session = await options.store.getOrCreate(options.id);
4715
- mutate(session);
4716
- await options.store.set(options.id, session);
4717
- return session;
4718
- };
4719
- const runSerial = (phase, operation) => {
4720
- const result = operationQueue.then(async () => {
4721
- logger.debug("voice session operation", {
4722
- phase,
4723
- sessionId: options.id
4724
- });
4725
- return await operation();
4726
- });
4727
- operationQueue = result.then(() => {
4728
- return;
4729
- }, () => {
5440
+ const sendCallLifecycle = async (session) => {
5441
+ const event = getLatestCallLifecycleEvent(session);
5442
+ if (!event) {
4730
5443
  return;
5444
+ }
5445
+ await send({
5446
+ event,
5447
+ sessionId: options.id,
5448
+ type: "call_lifecycle"
4731
5449
  });
4732
- return result;
4733
5450
  };
4734
- const closeAdapter = async (reason) => {
4735
- if (!sttSession) {
4736
- return;
4737
- }
4738
- const activeSession = sttSession;
4739
- sttSession = null;
4740
- activeAdapterGeneration = 0;
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
+ };
5510
+ const readSession = async () => options.store.getOrCreate(options.id);
5511
+ const writeSession = async (mutate) => {
5512
+ const session = await options.store.getOrCreate(options.id);
5513
+ mutate(session);
5514
+ await options.store.set(options.id, session);
5515
+ return session;
5516
+ };
5517
+ const runSerial = (phase, operation) => {
5518
+ const result = operationQueue.then(async () => {
5519
+ logger.debug("voice session operation", {
5520
+ phase,
5521
+ sessionId: options.id
5522
+ });
5523
+ return await operation();
5524
+ });
5525
+ operationQueue = result.then(() => {
5526
+ return;
5527
+ }, () => {
5528
+ return;
5529
+ });
5530
+ return result;
5531
+ };
5532
+ const closeAdapter = async (reason) => {
5533
+ if (!sttSession) {
5534
+ return;
5535
+ }
5536
+ const activeSession = sttSession;
5537
+ sttSession = null;
5538
+ activeAdapterGeneration = 0;
4741
5539
  try {
4742
5540
  await activeSession.close(reason);
4743
5541
  } catch (error) {
@@ -4765,6 +5563,23 @@ var createVoiceSession = (options) => {
4765
5563
  });
4766
5564
  }
4767
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
+ };
4768
5583
  const scheduleTurnCommit = (delayMs, reason, reset = true) => {
4769
5584
  if (!reset && silenceTimer) {
4770
5585
  return;
@@ -4899,6 +5714,7 @@ var createVoiceSession = (options) => {
4899
5714
  await appendTrace({
4900
5715
  payload: {
4901
5716
  disposition,
5717
+ metadata: input.metadata,
4902
5718
  reason: input.reason,
4903
5719
  target: input.target,
4904
5720
  type: "end"
@@ -4906,6 +5722,7 @@ var createVoiceSession = (options) => {
4906
5722
  session,
4907
5723
  type: "call.lifecycle"
4908
5724
  });
5725
+ await sendCallLifecycle(session);
4909
5726
  await send({
4910
5727
  sessionId: options.id,
4911
5728
  type: "complete"
@@ -4985,6 +5802,15 @@ var createVoiceSession = (options) => {
4985
5802
  session,
4986
5803
  type: "call.lifecycle"
4987
5804
  });
5805
+ await sendCallLifecycle(session);
5806
+ await runHandoff({
5807
+ action: "transfer",
5808
+ metadata: input.metadata,
5809
+ reason: input.reason,
5810
+ result: input.result,
5811
+ session,
5812
+ target: input.target
5813
+ });
4988
5814
  await completeInternal(input.result, {
4989
5815
  disposition: "transferred",
4990
5816
  invokeOnComplete: false,
@@ -5010,6 +5836,14 @@ var createVoiceSession = (options) => {
5010
5836
  session,
5011
5837
  type: "call.lifecycle"
5012
5838
  });
5839
+ await sendCallLifecycle(session);
5840
+ await runHandoff({
5841
+ action: "escalate",
5842
+ metadata: input.metadata,
5843
+ reason: input.reason,
5844
+ result: input.result,
5845
+ session
5846
+ });
5013
5847
  await completeInternal(input.result, {
5014
5848
  disposition: "escalated",
5015
5849
  invokeOnComplete: false,
@@ -5032,6 +5866,13 @@ var createVoiceSession = (options) => {
5032
5866
  session,
5033
5867
  type: "call.lifecycle"
5034
5868
  });
5869
+ await sendCallLifecycle(session);
5870
+ await runHandoff({
5871
+ action: "no-answer",
5872
+ metadata: input?.metadata,
5873
+ result: input?.result,
5874
+ session
5875
+ });
5035
5876
  await completeInternal(input?.result, {
5036
5877
  disposition: "no-answer",
5037
5878
  invokeOnComplete: false,
@@ -5053,6 +5894,13 @@ var createVoiceSession = (options) => {
5053
5894
  session,
5054
5895
  type: "call.lifecycle"
5055
5896
  });
5897
+ await sendCallLifecycle(session);
5898
+ await runHandoff({
5899
+ action: "voicemail",
5900
+ metadata: input?.metadata,
5901
+ result: input?.result,
5902
+ session
5903
+ });
5056
5904
  await completeInternal(input?.result, {
5057
5905
  disposition: "voicemail",
5058
5906
  invokeOnComplete: false,
@@ -5433,8 +6281,12 @@ var createVoiceSession = (options) => {
5433
6281
  if (sttSession) {
5434
6282
  return sttSession;
5435
6283
  }
5436
- const openedSession = await options.stt.open({
5437
- 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,
5438
6290
  languageStrategy: options.languageStrategy,
5439
6291
  lexicon,
5440
6292
  phraseHints,
@@ -5469,6 +6321,16 @@ var createVoiceSession = (options) => {
5469
6321
  openedSession.on("close", (event) => {
5470
6322
  runAdapterEvent("adapter.close", () => handleClose(event));
5471
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
+ }
5472
6334
  return openedSession;
5473
6335
  };
5474
6336
  const ensureTTSSession = async () => {
@@ -5493,13 +6355,9 @@ var createVoiceSession = (options) => {
5493
6355
  if (ttsSession !== openedSession) {
5494
6356
  return;
5495
6357
  }
5496
- 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));
5497
- await send({
5498
- chunkBase64: encodeBase64(normalizedChunk),
6358
+ await sendAssistantAudio(chunk, {
5499
6359
  format,
5500
- receivedAt,
5501
- turnId: activeTTSTurnId,
5502
- type: "audio"
6360
+ receivedAt
5503
6361
  });
5504
6362
  });
5505
6363
  });
@@ -5543,9 +6401,32 @@ var createVoiceSession = (options) => {
5543
6401
  });
5544
6402
  };
5545
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();
5546
6423
  const committedOutput = await options.route.onTurn({
5547
6424
  api,
5548
6425
  context: options.context,
6426
+ liveOps: liveOpsControl ? {
6427
+ control: liveOpsControl,
6428
+ injectedInstruction
6429
+ } : undefined,
5549
6430
  session,
5550
6431
  turn
5551
6432
  });
@@ -5559,6 +6440,7 @@ var createVoiceSession = (options) => {
5559
6440
  voicemail: committedOutput?.voicemail
5560
6441
  };
5561
6442
  if (output?.assistantText) {
6443
+ const assistantTextStartedAt = Date.now();
5562
6444
  await writeSession((currentSession) => {
5563
6445
  setTurnResult(currentSession, turn.id, {
5564
6446
  assistantText: output.assistantText
@@ -5569,10 +6451,17 @@ var createVoiceSession = (options) => {
5569
6451
  turnId: turn.id,
5570
6452
  type: "assistant"
5571
6453
  });
6454
+ await appendTurnLatencyStage({
6455
+ at: assistantTextStartedAt,
6456
+ session,
6457
+ stage: "assistant_text_started",
6458
+ turnId: turn.id
6459
+ });
5572
6460
  await appendTrace({
5573
6461
  payload: {
5574
6462
  text: output.assistantText,
5575
- ttsConfigured: Boolean(options.tts)
6463
+ ttsConfigured: Boolean(options.tts),
6464
+ realtimeConfigured: Boolean(options.realtime)
5576
6465
  },
5577
6466
  session,
5578
6467
  turnId: turn.id,
@@ -5583,7 +6472,18 @@ var createVoiceSession = (options) => {
5583
6472
  if (activeTTSSession) {
5584
6473
  const ttsStartedAt = Date.now();
5585
6474
  activeTTSTurnId = turn.id;
6475
+ await appendTurnLatencyStage({
6476
+ at: ttsStartedAt,
6477
+ session,
6478
+ stage: "tts_send_started",
6479
+ turnId: turn.id
6480
+ });
5586
6481
  await activeTTSSession.send(output.assistantText);
6482
+ await appendTurnLatencyStage({
6483
+ session,
6484
+ stage: "tts_send_completed",
6485
+ turnId: turn.id
6486
+ });
5587
6487
  await appendTrace({
5588
6488
  payload: {
5589
6489
  elapsedMs: Date.now() - ttsStartedAt,
@@ -5593,9 +6493,35 @@ var createVoiceSession = (options) => {
5593
6493
  turnId: turn.id,
5594
6494
  type: "turn.assistant"
5595
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
+ });
5596
6522
  }
5597
6523
  } catch (error) {
5598
- logger.warn("voice tts send failed", {
6524
+ logger.warn("voice assistant audio send failed", {
5599
6525
  error: toError(error).message,
5600
6526
  sessionId: options.id,
5601
6527
  turnId: turn.id
@@ -5603,7 +6529,7 @@ var createVoiceSession = (options) => {
5603
6529
  await appendTrace({
5604
6530
  payload: {
5605
6531
  error: toError(error).message,
5606
- status: "tts-send-failed"
6532
+ status: options.realtime ? "realtime-send-failed" : "tts-send-failed"
5607
6533
  },
5608
6534
  session,
5609
6535
  turnId: turn.id,
@@ -5780,11 +6706,35 @@ var createVoiceSession = (options) => {
5780
6706
  turnId: turn.id,
5781
6707
  type: "turn.cost"
5782
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
+ });
5783
6733
  await send({
5784
6734
  turn,
5785
6735
  type: "turn"
5786
6736
  });
5787
- if (options.sttLifecycle === "turn-scoped") {
6737
+ if (options.stt && options.sttLifecycle === "turn-scoped") {
5788
6738
  await closeAdapter("turn-commit");
5789
6739
  }
5790
6740
  await completeTurn(updatedSession, turn);
@@ -5839,6 +6789,7 @@ var createVoiceSession = (options) => {
5839
6789
  session,
5840
6790
  type: "call.lifecycle"
5841
6791
  });
6792
+ await sendCallLifecycle(session);
5842
6793
  }
5843
6794
  await send({
5844
6795
  sessionId: options.id,
@@ -5846,6 +6797,7 @@ var createVoiceSession = (options) => {
5846
6797
  scenarioId: session.scenarioId,
5847
6798
  type: "session"
5848
6799
  });
6800
+ await sendReplay(session);
5849
6801
  if (shouldFireOnSession) {
5850
6802
  await options.route.onCallStart?.({
5851
6803
  api,
@@ -6429,7 +7381,7 @@ var createVoiceCallReviewFromLiveTelephonyReport = (report, options = {}) => {
6429
7381
  }
6430
7382
  };
6431
7383
  };
6432
- var toErrorMessage = (error) => {
7384
+ var toErrorMessage2 = (error) => {
6433
7385
  if (typeof error === "string" && error.trim().length > 0) {
6434
7386
  return error;
6435
7387
  }
@@ -6516,7 +7468,7 @@ var createVoiceCallReviewRecorder = (options = {}) => {
6516
7468
  };
6517
7469
  },
6518
7470
  recordError: (error) => {
6519
- const message = toErrorMessage(error);
7471
+ const message = toErrorMessage2(error);
6520
7472
  errors.push(message);
6521
7473
  push("turn", "error", {
6522
7474
  reason: message
@@ -7222,10 +8174,870 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
7222
8174
  });
7223
8175
  };
7224
8176
  // src/telephony/twilio.ts
7225
- 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
+
8223
+ class VoiceTelephonyWebhookVerificationError extends Error {
8224
+ result;
8225
+ constructor(result) {
8226
+ super(result.ok ? "telephony webhook verified" : result.reason);
8227
+ this.name = "VoiceTelephonyWebhookVerificationError";
8228
+ this.result = result;
8229
+ }
8230
+ }
8231
+ var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
8232
+ const decisions = new Map;
8233
+ return {
8234
+ get: (key) => decisions.get(key),
8235
+ set: (key, decision) => {
8236
+ decisions.set(key, decision);
8237
+ }
8238
+ };
8239
+ };
8240
+ var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
8241
+ var firstString = (source, keys) => {
8242
+ for (const key of keys) {
8243
+ const value = source[key];
8244
+ if (typeof value === "string" && value.trim()) {
8245
+ return value.trim();
8246
+ }
8247
+ if (typeof value === "number" && Number.isFinite(value)) {
8248
+ return String(value);
8249
+ }
8250
+ }
8251
+ };
8252
+ var firstNumber = (source, keys) => {
8253
+ for (const key of keys) {
8254
+ const value = source[key];
8255
+ if (typeof value === "number" && Number.isFinite(value)) {
8256
+ return value;
8257
+ }
8258
+ if (typeof value === "string" && value.trim()) {
8259
+ const parsed = Number(value);
8260
+ if (Number.isFinite(parsed)) {
8261
+ return parsed;
8262
+ }
8263
+ }
8264
+ }
8265
+ };
8266
+ var parseMaybeJSON = (value) => {
8267
+ try {
8268
+ return JSON.parse(value);
8269
+ } catch {
8270
+ return;
8271
+ }
8272
+ };
8273
+ var flattenPayload = (value) => {
8274
+ if (!isRecord(value)) {
8275
+ return {};
8276
+ }
8277
+ const data = isRecord(value.data) ? value.data : undefined;
8278
+ const payload = isRecord(value.payload) ? value.payload : undefined;
8279
+ const event = isRecord(value.event) ? value.event : undefined;
8280
+ return {
8281
+ ...value,
8282
+ ...payload,
8283
+ ...event,
8284
+ ...data,
8285
+ ...isRecord(data?.payload) ? data.payload : undefined
8286
+ };
8287
+ };
8288
+ var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
8289
+ var timingSafeEqual = (left, right) => {
8290
+ const encoder = new TextEncoder;
8291
+ const leftBytes = encoder.encode(left);
8292
+ const rightBytes = encoder.encode(right);
8293
+ if (leftBytes.length !== rightBytes.length) {
8294
+ return false;
8295
+ }
8296
+ let diff = 0;
8297
+ for (let index = 0;index < leftBytes.length; index += 1) {
8298
+ diff |= leftBytes[index] ^ rightBytes[index];
8299
+ }
8300
+ return diff === 0;
8301
+ };
8302
+ var signHmacSHA1Base64 = async (secret, payload) => {
8303
+ const encoder = new TextEncoder;
8304
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
8305
+ hash: "SHA-1",
8306
+ name: "HMAC"
8307
+ }, false, ["sign"]);
8308
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
8309
+ return toBase64(signature);
8310
+ };
8311
+ 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("");
8312
+ var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
8313
+ var metadataValue = (metadata, keys) => {
8314
+ for (const key of keys) {
8315
+ const value = metadata?.[key];
8316
+ if (typeof value === "string" && value.trim()) {
8317
+ return value.trim();
8318
+ }
8319
+ }
8320
+ };
8321
+ var resolveTransferTarget = (event, policy) => {
8322
+ if (typeof event.target === "string" && event.target.trim()) {
8323
+ return event.target.trim();
8324
+ }
8325
+ const metadataTarget = metadataValue(event.metadata, [
8326
+ "transferTarget",
8327
+ "target",
8328
+ "queue",
8329
+ "department"
8330
+ ]);
8331
+ if (metadataTarget) {
8332
+ return metadataTarget;
8333
+ }
8334
+ if (typeof policy.transferTarget === "function") {
8335
+ const target = policy.transferTarget(event);
8336
+ return typeof target === "string" && target.trim() ? target.trim() : undefined;
8337
+ }
8338
+ return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
8339
+ };
8340
+ var mergeMetadata = (event, policy) => ({
8341
+ ...policy.includeProviderPayload ? {
8342
+ answeredBy: event.answeredBy,
8343
+ durationMs: event.durationMs,
8344
+ provider: event.provider,
8345
+ reason: event.reason,
8346
+ sipCode: event.sipCode,
8347
+ status: event.status
8348
+ } : undefined,
8349
+ ...policy.metadata,
8350
+ ...event.metadata
8351
+ });
8352
+ var withDecisionDefaults = (decision, input) => {
8353
+ if (typeof decision === "string") {
8354
+ return buildDecision(decision, input);
8355
+ }
8356
+ return {
8357
+ ...buildDecision(decision.action, input),
8358
+ ...decision,
8359
+ confidence: decision.confidence ?? "high",
8360
+ metadata: {
8361
+ ...mergeMetadata(input.event, input.policy),
8362
+ ...decision.metadata
8363
+ },
8364
+ source: decision.source ?? input.source,
8365
+ target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
8366
+ };
8367
+ };
8368
+ var dispositionForAction = (action) => {
8369
+ switch (action) {
8370
+ case "complete":
8371
+ return "completed";
8372
+ case "escalate":
8373
+ return "escalated";
8374
+ case "no-answer":
8375
+ return "no-answer";
8376
+ case "transfer":
8377
+ return "transferred";
8378
+ case "voicemail":
8379
+ return "voicemail";
8380
+ default:
8381
+ return;
8382
+ }
8383
+ };
8384
+ var buildDecision = (action, input) => ({
8385
+ action,
8386
+ confidence: action === "ignore" ? "low" : "high",
8387
+ disposition: dispositionForAction(action),
8388
+ metadata: mergeMetadata(input.event, input.policy),
8389
+ reason: input.event.reason,
8390
+ source: input.source,
8391
+ target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
8392
+ });
8393
+ var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
8394
+ completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
8395
+ escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
8396
+ failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
8397
+ failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
8398
+ includeProviderPayload: policy.includeProviderPayload ?? true,
8399
+ machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
8400
+ metadata: policy.metadata,
8401
+ minAnsweredDurationMs: policy.minAnsweredDurationMs,
8402
+ noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
8403
+ noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
8404
+ noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
8405
+ statusMap: policy.statusMap,
8406
+ transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
8407
+ transferTarget: policy.transferTarget,
8408
+ voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
8409
+ });
8410
+ var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
8411
+ const policy = createVoiceTelephonyOutcomePolicy(policyInput);
8412
+ const status = normalizeToken(event.status);
8413
+ const provider = normalizeToken(event.provider);
8414
+ const answeredBy = normalizeToken(event.answeredBy);
8415
+ const target = resolveTransferTarget(event, policy);
8416
+ if (status) {
8417
+ const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
8418
+ if (mapped) {
8419
+ return withDecisionDefaults(mapped, {
8420
+ event,
8421
+ policy,
8422
+ source: "policy"
8423
+ });
8424
+ }
8425
+ }
8426
+ if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
8427
+ return buildDecision("voicemail", { event, policy, source: "answered-by" });
8428
+ }
8429
+ if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
8430
+ return buildDecision("no-answer", { event, policy, source: "sip" });
8431
+ }
8432
+ if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
8433
+ return buildDecision("transfer", { event, policy, source: "status" });
8434
+ }
8435
+ if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
8436
+ return buildDecision("voicemail", { event, policy, source: "status" });
8437
+ }
8438
+ if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
8439
+ return buildDecision("escalate", { event, policy, source: "status" });
8440
+ }
8441
+ if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
8442
+ return buildDecision("no-answer", { event, policy, source: "status" });
8443
+ }
8444
+ if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
8445
+ return buildDecision("no-answer", { event, policy, source: "duration" });
8446
+ }
8447
+ if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
8448
+ return {
8449
+ ...buildDecision("no-answer", { event, policy, source: "duration" }),
8450
+ confidence: "medium"
8451
+ };
8452
+ }
8453
+ if (status && normalizeList(policy.completedStatuses, []).has(status)) {
8454
+ return buildDecision("complete", { event, policy, source: "status" });
8455
+ }
8456
+ if (target) {
8457
+ return {
8458
+ ...buildDecision("transfer", { event, policy, source: "explicit-target" }),
8459
+ confidence: "medium"
8460
+ };
8461
+ }
8462
+ return buildDecision("ignore", { event, policy, source: "status" });
8463
+ };
8464
+ var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
8465
+ switch (decision.action) {
8466
+ case "complete":
8467
+ return { complete: true, result };
8468
+ case "escalate":
8469
+ return {
8470
+ escalate: {
8471
+ metadata: decision.metadata,
8472
+ reason: decision.reason ?? "telephony-escalation"
8473
+ },
8474
+ result
8475
+ };
8476
+ case "no-answer":
8477
+ return {
8478
+ noAnswer: {
8479
+ metadata: decision.metadata
8480
+ },
8481
+ result
8482
+ };
8483
+ case "transfer":
8484
+ if (!decision.target) {
8485
+ return { result };
8486
+ }
8487
+ return {
8488
+ result,
8489
+ transfer: {
8490
+ metadata: decision.metadata,
8491
+ reason: decision.reason,
8492
+ target: decision.target
8493
+ }
8494
+ };
8495
+ case "voicemail":
8496
+ return {
8497
+ result,
8498
+ voicemail: {
8499
+ metadata: decision.metadata
8500
+ }
8501
+ };
8502
+ default:
8503
+ return { result };
8504
+ }
8505
+ };
8506
+ var applyVoiceTelephonyOutcome = async (api, decision, result) => {
8507
+ switch (decision.action) {
8508
+ case "complete":
8509
+ await api.complete(result);
8510
+ break;
8511
+ case "escalate":
8512
+ await api.escalate({
8513
+ metadata: decision.metadata,
8514
+ reason: decision.reason ?? "telephony-escalation",
8515
+ result
8516
+ });
8517
+ break;
8518
+ case "no-answer":
8519
+ await api.markNoAnswer({
8520
+ metadata: decision.metadata,
8521
+ result
8522
+ });
8523
+ break;
8524
+ case "transfer":
8525
+ if (!decision.target) {
8526
+ return;
8527
+ }
8528
+ await api.transfer({
8529
+ metadata: decision.metadata,
8530
+ reason: decision.reason,
8531
+ result,
8532
+ target: decision.target
8533
+ });
8534
+ break;
8535
+ case "voicemail":
8536
+ await api.markVoicemail({
8537
+ metadata: decision.metadata,
8538
+ result
8539
+ });
8540
+ break;
8541
+ default:
8542
+ break;
8543
+ }
8544
+ };
8545
+ var parseRequestBodyText = (input) => {
8546
+ const { contentType, text } = input;
8547
+ if (!text) {
8548
+ return {};
8549
+ }
8550
+ if (contentType.includes("application/json")) {
8551
+ return parseMaybeJSON(text) ?? {};
8552
+ }
8553
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
8554
+ return Object.fromEntries(new URLSearchParams(text));
8555
+ }
8556
+ return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
8557
+ };
8558
+ var readRequestBody = async (request) => {
8559
+ const contentType = request.headers.get("content-type") ?? "";
8560
+ const text = await request.text();
8561
+ return {
8562
+ body: parseRequestBodyText({ contentType, text }),
8563
+ rawBody: text
8564
+ };
8565
+ };
8566
+ var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
8567
+ var verifyVoiceTwilioWebhookSignature = async (input) => {
8568
+ if (!input.authToken) {
8569
+ return { ok: false, reason: "missing-secret" };
8570
+ }
8571
+ const signature = input.headers.get("x-twilio-signature");
8572
+ if (!signature) {
8573
+ return { ok: false, reason: "missing-signature" };
8574
+ }
8575
+ const expected = await signVoiceTwilioWebhook({
8576
+ authToken: input.authToken,
8577
+ body: input.body,
8578
+ url: input.url
8579
+ });
8580
+ return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
8581
+ };
8582
+ var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
8583
+ var verifyVoiceTelephonyWebhook = async (input) => {
8584
+ if (input.options.verify) {
8585
+ return input.options.verify({
8586
+ body: input.body,
8587
+ headers: input.request.headers,
8588
+ provider: input.provider,
8589
+ query: input.query,
8590
+ rawBody: input.rawBody,
8591
+ request: input.request
8592
+ });
8593
+ }
8594
+ if (!input.options.signingSecret) {
8595
+ return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
8596
+ }
8597
+ if (input.provider !== "twilio") {
8598
+ return { ok: false, reason: "unsupported-provider" };
8599
+ }
8600
+ return verifyVoiceTwilioWebhookSignature({
8601
+ authToken: input.options.signingSecret,
8602
+ body: input.body,
8603
+ headers: input.request.headers,
8604
+ url: resolveVerificationUrl(input.options.verificationUrl, {
8605
+ query: input.query,
8606
+ request: input.request
8607
+ })
8608
+ });
8609
+ };
8610
+ var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
8611
+ var parseVoiceTelephonyWebhookEvent = (input) => {
8612
+ const payload = flattenPayload(input.body);
8613
+ const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
8614
+ const status = firstString(payload, [
8615
+ "CallStatus",
8616
+ "call_status",
8617
+ "callStatus",
8618
+ "DialCallStatus",
8619
+ "dial_call_status",
8620
+ "status",
8621
+ "event_type",
8622
+ "type"
8623
+ ]);
8624
+ const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
8625
+ "CallDuration",
8626
+ "call_duration",
8627
+ "callDuration",
8628
+ "DialCallDuration",
8629
+ "dial_call_duration",
8630
+ "duration"
8631
+ ]));
8632
+ const sipCode = firstNumber(payload, [
8633
+ "SipResponseCode",
8634
+ "sip_response_code",
8635
+ "sipCode",
8636
+ "sip_code",
8637
+ "hangupCauseCode"
8638
+ ]);
8639
+ const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
8640
+ const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
8641
+ const target = firstString(payload, [
8642
+ "transferTarget",
8643
+ "TransferTarget",
8644
+ "target",
8645
+ "queue",
8646
+ "department"
8647
+ ]);
8648
+ return {
8649
+ answeredBy: firstString(payload, [
8650
+ "AnsweredBy",
8651
+ "answered_by",
8652
+ "answeredBy",
8653
+ "machineDetection",
8654
+ "machine_detection"
8655
+ ]),
8656
+ durationMs,
8657
+ from,
8658
+ metadata: {
8659
+ ...input.query,
8660
+ ...payload
8661
+ },
8662
+ provider,
8663
+ reason: firstString(payload, [
8664
+ "Reason",
8665
+ "reason",
8666
+ "HangupCause",
8667
+ "hangup_cause",
8668
+ "hangupCause"
8669
+ ]),
8670
+ sipCode,
8671
+ status,
8672
+ target,
8673
+ to
8674
+ };
8675
+ };
8676
+ var defaultSessionId = (input) => {
8677
+ const payload = flattenPayload(input.body);
8678
+ const metadataSessionId = input.event.metadata?.sessionId;
8679
+ return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
8680
+ "sessionId",
8681
+ "session_id",
8682
+ "SessionId",
8683
+ "CallSid",
8684
+ "call_sid",
8685
+ "callSid",
8686
+ "CallUUID",
8687
+ "call_uuid",
8688
+ "callControlId",
8689
+ "call_control_id"
8690
+ ]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
8691
+ };
8692
+ var defaultIdempotencyKey = (input) => {
8693
+ const payload = flattenPayload(input.body);
8694
+ const eventId = firstString(payload, [
8695
+ "id",
8696
+ "event_id",
8697
+ "eventId",
8698
+ "EventSid",
8699
+ "event_sid",
8700
+ "MessageSid",
8701
+ "message_sid",
8702
+ "CallSid",
8703
+ "call_sid",
8704
+ "CallUUID",
8705
+ "call_uuid",
8706
+ "callControlId",
8707
+ "call_control_id"
8708
+ ]);
8709
+ const status = normalizeToken(input.event.status) ?? "unknown";
8710
+ if (eventId) {
8711
+ return `${input.provider}:${eventId}:${status}`;
8712
+ }
8713
+ if (input.sessionId) {
8714
+ return `${input.provider}:${input.sessionId}:${status}`;
8715
+ }
8716
+ };
8717
+ var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
8718
+ const provider = options.provider ?? "generic";
8719
+ const query = input.query ?? {};
8720
+ const { body, rawBody } = await readRequestBody(input.request);
8721
+ const verification = await verifyVoiceTelephonyWebhook({
8722
+ body,
8723
+ options,
8724
+ provider,
8725
+ query,
8726
+ rawBody,
8727
+ request: input.request
8728
+ });
8729
+ if (!verification.ok) {
8730
+ throw new VoiceTelephonyWebhookVerificationError(verification);
8731
+ }
8732
+ const event = options.parse ? await options.parse({
8733
+ body,
8734
+ headers: input.request.headers,
8735
+ provider,
8736
+ query,
8737
+ request: input.request
8738
+ }) : parseVoiceTelephonyWebhookEvent({
8739
+ body,
8740
+ headers: input.request.headers,
8741
+ provider,
8742
+ query,
8743
+ request: input.request
8744
+ });
8745
+ const sessionId = await (options.resolveSessionId?.({
8746
+ body,
8747
+ event,
8748
+ query,
8749
+ request: input.request
8750
+ }) ?? defaultSessionId({ body, event, query }));
8751
+ const idempotencyEnabled = options.idempotency?.enabled !== false;
8752
+ const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
8753
+ body,
8754
+ event,
8755
+ provider,
8756
+ query,
8757
+ request: input.request,
8758
+ sessionId
8759
+ }) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
8760
+ const idempotencyStore = options.idempotency?.store;
8761
+ if (idempotencyKey && idempotencyStore) {
8762
+ const existing = await idempotencyStore.get(idempotencyKey);
8763
+ if (existing) {
8764
+ const duplicateDecision = {
8765
+ ...existing,
8766
+ duplicate: true
8767
+ };
8768
+ await options.onDecision?.({
8769
+ ...duplicateDecision,
8770
+ context: options.context,
8771
+ request: input.request
8772
+ });
8773
+ return duplicateDecision;
8774
+ }
8775
+ }
8776
+ const decision = resolveVoiceTelephonyOutcome(event, options.policy);
8777
+ const resultResolver = options.result;
8778
+ const result = typeof resultResolver === "function" ? await resultResolver({
8779
+ decision,
8780
+ event,
8781
+ sessionId
8782
+ }) : resultResolver;
8783
+ const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
8784
+ const shouldApply = typeof options.apply === "function" ? options.apply({
8785
+ applied: false,
8786
+ decision,
8787
+ event,
8788
+ routeResult,
8789
+ sessionId
8790
+ }) : options.apply === true;
8791
+ let applied = false;
8792
+ if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
8793
+ const api = await options.getSessionHandle({
8794
+ context: options.context,
8795
+ decision,
8796
+ event,
8797
+ request: input.request,
8798
+ sessionId
8799
+ });
8800
+ if (api) {
8801
+ await applyVoiceTelephonyOutcome(api, decision, result);
8802
+ applied = true;
8803
+ }
8804
+ }
8805
+ const webhookDecision = {
8806
+ applied,
8807
+ decision,
8808
+ event,
8809
+ idempotencyKey,
8810
+ routeResult,
8811
+ sessionId
8812
+ };
8813
+ if (idempotencyKey && idempotencyStore) {
8814
+ const now = Date.now();
8815
+ await idempotencyStore.set(idempotencyKey, {
8816
+ ...webhookDecision,
8817
+ createdAt: now,
8818
+ updatedAt: now
8819
+ });
8820
+ }
8821
+ await options.onDecision?.({
8822
+ ...webhookDecision,
8823
+ context: options.context,
8824
+ request: input.request
8825
+ });
8826
+ return webhookDecision;
8827
+ };
8828
+ var createVoiceTelephonyWebhookRoutes = (options = {}) => {
8829
+ const path = options.path ?? "/api/voice/telephony/webhook";
8830
+ const handler = createVoiceTelephonyWebhookHandler(options);
8831
+ return new Elysia({
8832
+ name: options.name ?? "absolutejs-voice-telephony-webhooks"
8833
+ }).post(path, async ({ query, request }) => {
8834
+ try {
8835
+ return await handler({ query, request });
8836
+ } catch (error) {
8837
+ if (error instanceof VoiceTelephonyWebhookVerificationError) {
8838
+ return new Response(JSON.stringify({ verification: error.result }), {
8839
+ headers: {
8840
+ "content-type": "application/json"
8841
+ },
8842
+ status: 401
8843
+ });
8844
+ }
8845
+ throw error;
8846
+ }
8847
+ }, {
8848
+ parse: "none"
8849
+ });
8850
+ };
8851
+
8852
+ // src/telephony/twilio.ts
7226
8853
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
7227
8854
  var VOICE_PCM_SAMPLE_RATE = 16000;
7228
- var escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8855
+ var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8856
+ var resolveRequestOrigin = (request) => {
8857
+ const url = new URL(request.url);
8858
+ const forwardedHost = request.headers.get("x-forwarded-host");
8859
+ const forwardedProto = request.headers.get("x-forwarded-proto");
8860
+ const host = forwardedHost ?? request.headers.get("host") ?? url.host;
8861
+ const protocol = forwardedProto ?? url.protocol.replace(":", "");
8862
+ return `${protocol}://${host}`;
8863
+ };
8864
+ var resolveTwilioStreamUrl = async (options, input) => {
8865
+ if (typeof options.twiml?.streamUrl === "function") {
8866
+ return options.twiml.streamUrl(input);
8867
+ }
8868
+ if (typeof options.twiml?.streamUrl === "string") {
8869
+ return options.twiml.streamUrl;
8870
+ }
8871
+ const origin = resolveRequestOrigin(input.request);
8872
+ const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
8873
+ return `${wsOrigin}${input.streamPath}`;
8874
+ };
8875
+ var resolveTwilioStreamParameters = async (parameters, input) => {
8876
+ if (typeof parameters === "function") {
8877
+ return parameters(input);
8878
+ }
8879
+ return parameters;
8880
+ };
8881
+ var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
8882
+ var escapeHtml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8883
+ var getWebhookVerificationUrl = (webhook, input) => {
8884
+ if (!webhook?.verificationUrl) {
8885
+ return;
8886
+ }
8887
+ if (typeof webhook.verificationUrl === "function") {
8888
+ return webhook.verificationUrl(input);
8889
+ }
8890
+ return webhook.verificationUrl;
8891
+ };
8892
+ var buildTwilioVoiceSetupStatus = async (options, input) => {
8893
+ const origin = resolveRequestOrigin(input.request);
8894
+ const stream = await resolveTwilioStreamUrl(options, input);
8895
+ const twiml = joinUrlPath(origin, input.twimlPath);
8896
+ const webhook = joinUrlPath(origin, input.webhookPath);
8897
+ const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
8898
+ const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
8899
+ const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
8900
+ const warnings = [
8901
+ ...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
8902
+ ...signingConfigured ? [] : ["Webhook signature verification is not configured."],
8903
+ ...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
8904
+ ];
8905
+ return {
8906
+ generatedAt: Date.now(),
8907
+ missing,
8908
+ provider: "twilio",
8909
+ ready: missing.length === 0 && signingConfigured && warnings.length === 0,
8910
+ signing: {
8911
+ configured: signingConfigured,
8912
+ mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
8913
+ verificationUrl
8914
+ },
8915
+ urls: {
8916
+ stream,
8917
+ twiml,
8918
+ webhook
8919
+ },
8920
+ warnings
8921
+ };
8922
+ };
8923
+ var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
8924
+ <p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
8925
+ <h1>${escapeHtml2(title)}</h1>
8926
+ <p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
8927
+ <section>
8928
+ <h2>URLs</h2>
8929
+ <ul>
8930
+ <li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
8931
+ <li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
8932
+ <li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
8933
+ </ul>
8934
+ </section>
8935
+ <section>
8936
+ <h2>Signing</h2>
8937
+ <p>Mode: <code>${status.signing.mode}</code></p>
8938
+ ${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
8939
+ </section>
8940
+ ${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
8941
+ ${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
8942
+ </main>`;
8943
+ var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&amp;", "&");
8944
+ var createSmokeCheck = (name, status, message, details) => ({
8945
+ details,
8946
+ message,
8947
+ name,
8948
+ status
8949
+ });
8950
+ var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
8951
+ <p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
8952
+ <h1>${escapeHtml2(title)}</h1>
8953
+ <p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
8954
+ <section>
8955
+ <h2>Checks</h2>
8956
+ <ul>
8957
+ ${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
8958
+ </ul>
8959
+ </section>
8960
+ <section>
8961
+ <h2>Observed URLs</h2>
8962
+ <ul>
8963
+ <li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
8964
+ <li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
8965
+ <li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
8966
+ </ul>
8967
+ </section>
8968
+ </main>`;
8969
+ var runTwilioVoiceSmokeTest = async (input) => {
8970
+ const setup = await buildTwilioVoiceSetupStatus(input.options, input);
8971
+ const checks = [];
8972
+ const twimlUrl = new URL(setup.urls.twiml);
8973
+ twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
8974
+ twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
8975
+ const twimlResponse = await input.app.handle(new Request(twimlUrl, {
8976
+ headers: input.request.headers
8977
+ }));
8978
+ const twiml = await twimlResponse.text();
8979
+ const streamUrl = extractTwilioStreamUrl(twiml);
8980
+ checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
8981
+ status: twimlResponse.status,
8982
+ streamUrl
8983
+ }));
8984
+ 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.", {
8985
+ streamUrl
8986
+ }));
8987
+ const webhookBody = {
8988
+ CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
8989
+ CallStatus: input.options.smoke?.status ?? "busy",
8990
+ SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
8991
+ };
8992
+ const webhookHeaders = new Headers({
8993
+ "content-type": "application/x-www-form-urlencoded"
8994
+ });
8995
+ const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
8996
+ if (input.options.webhook?.signingSecret) {
8997
+ webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
8998
+ authToken: input.options.webhook.signingSecret,
8999
+ body: webhookBody,
9000
+ url: verificationUrl
9001
+ }));
9002
+ }
9003
+ const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
9004
+ body: new URLSearchParams(webhookBody),
9005
+ headers: webhookHeaders,
9006
+ method: "POST"
9007
+ }));
9008
+ const webhookText = await webhookResponse.text();
9009
+ const webhookPayload = (() => {
9010
+ try {
9011
+ return JSON.parse(webhookText);
9012
+ } catch {
9013
+ return webhookText;
9014
+ }
9015
+ })();
9016
+ checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
9017
+ status: webhookResponse.status
9018
+ }));
9019
+ for (const warning of setup.warnings) {
9020
+ checks.push(createSmokeCheck("setup-warning", "warn", warning));
9021
+ }
9022
+ for (const name of setup.missing) {
9023
+ checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
9024
+ }
9025
+ return {
9026
+ checks,
9027
+ generatedAt: Date.now(),
9028
+ pass: checks.every((check) => check.status !== "fail"),
9029
+ provider: "twilio",
9030
+ setup,
9031
+ twiml: {
9032
+ status: twimlResponse.status,
9033
+ streamUrl
9034
+ },
9035
+ webhook: {
9036
+ body: webhookPayload,
9037
+ status: webhookResponse.status
9038
+ }
9039
+ };
9040
+ };
7229
9041
  var normalizeOnTurn = (handler) => {
7230
9042
  if (handler.length > 1) {
7231
9043
  const directHandler = handler;
@@ -7327,7 +9139,7 @@ var bytesToInt16Array = (bytes) => {
7327
9139
  return output;
7328
9140
  };
7329
9141
  var decodeTwilioMulawBase64 = (payload) => {
7330
- const bytes = Uint8Array.from(Buffer2.from(payload, "base64"));
9142
+ const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
7331
9143
  const samples = new Int16Array(bytes.length);
7332
9144
  for (let index = 0;index < bytes.length; index += 1) {
7333
9145
  samples[index] = decodeMulawSample(bytes[index] ?? 0);
@@ -7339,7 +9151,7 @@ var encodeTwilioMulawBase64 = (samples) => {
7339
9151
  for (let index = 0;index < samples.length; index += 1) {
7340
9152
  bytes[index] = encodeMulawSample(samples[index] ?? 0);
7341
9153
  }
7342
- return Buffer2.from(bytes).toString("base64");
9154
+ return Buffer3.from(bytes).toString("base64");
7343
9155
  };
7344
9156
  var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
7345
9157
  const narrowband = decodeTwilioMulawBase64(payload);
@@ -7348,7 +9160,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
7348
9160
  };
7349
9161
  var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
7350
9162
  if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
7351
- return Buffer2.from(chunk).toString("base64");
9163
+ return Buffer3.from(chunk).toString("base64");
7352
9164
  }
7353
9165
  if (format.encoding !== "pcm_s16le") {
7354
9166
  throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
@@ -7389,7 +9201,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
7389
9201
  return;
7390
9202
  }
7391
9203
  if (message.type === "audio") {
7392
- const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer2.from(message.chunkBase64, "base64")), message.format);
9204
+ const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
7393
9205
  state.hasOutboundAudioSinceLastInbound = true;
7394
9206
  state.reviewRecorder?.recordTwilioOutbound({
7395
9207
  bytes: payload.length,
@@ -7421,8 +9233,8 @@ var createTwilioSocketAdapter = (socket, getState) => ({
7421
9233
  }
7422
9234
  });
7423
9235
  var createTwilioVoiceResponse = (options) => {
7424
- const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml(name)}" value="${escapeXml(String(value))}" />`).join("");
7425
- 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>`;
9236
+ const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml2(name)}" value="${escapeXml2(String(value))}" />`).join("");
9237
+ 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>`;
7426
9238
  };
7427
9239
  var createTwilioMediaStreamBridge = (socket, options) => {
7428
9240
  const runtimePreset = resolveVoiceRuntimePreset(options.preset);
@@ -7602,6 +9414,148 @@ var createTwilioMediaStreamBridge = (socket, options) => {
7602
9414
  }
7603
9415
  };
7604
9416
  };
9417
+ var createTwilioVoiceRoutes = (options) => {
9418
+ const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
9419
+ const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
9420
+ const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
9421
+ const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
9422
+ const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
9423
+ const bridges = new WeakMap;
9424
+ const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
9425
+ const app = new Elysia2({
9426
+ name: options.name ?? "absolutejs-voice-twilio"
9427
+ }).get(twimlPath, async ({ query, request }) => {
9428
+ const streamUrl = await resolveTwilioStreamUrl(options, {
9429
+ query,
9430
+ request,
9431
+ streamPath
9432
+ });
9433
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
9434
+ query,
9435
+ request
9436
+ });
9437
+ return new Response(createTwilioVoiceResponse({
9438
+ parameters,
9439
+ streamName: options.twiml?.streamName,
9440
+ streamUrl,
9441
+ track: options.twiml?.track
9442
+ }), {
9443
+ headers: {
9444
+ "content-type": "text/xml; charset=utf-8"
9445
+ }
9446
+ });
9447
+ }).post(twimlPath, async ({ query, request }) => {
9448
+ const streamUrl = await resolveTwilioStreamUrl(options, {
9449
+ query,
9450
+ request,
9451
+ streamPath
9452
+ });
9453
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
9454
+ query,
9455
+ request
9456
+ });
9457
+ return new Response(createTwilioVoiceResponse({
9458
+ parameters,
9459
+ streamName: options.twiml?.streamName,
9460
+ streamUrl,
9461
+ track: options.twiml?.track
9462
+ }), {
9463
+ headers: {
9464
+ "content-type": "text/xml; charset=utf-8"
9465
+ }
9466
+ });
9467
+ }).ws(streamPath, {
9468
+ close: async (ws, _code, reason) => {
9469
+ const bridge = bridges.get(ws);
9470
+ bridges.delete(ws);
9471
+ await bridge?.close(reason);
9472
+ },
9473
+ message: async (ws, raw) => {
9474
+ let bridge = bridges.get(ws);
9475
+ if (!bridge) {
9476
+ bridge = createTwilioMediaStreamBridge({
9477
+ close: (code, reason) => {
9478
+ ws.close(code, reason);
9479
+ },
9480
+ send: (data) => {
9481
+ ws.send(data);
9482
+ }
9483
+ }, options);
9484
+ bridges.set(ws, bridge);
9485
+ }
9486
+ await bridge.handleMessage(raw);
9487
+ }
9488
+ }).use(createVoiceTelephonyWebhookRoutes({
9489
+ ...options.webhook ?? {},
9490
+ context: options.context,
9491
+ path: webhookPath,
9492
+ policy: webhookPolicy,
9493
+ provider: "twilio"
9494
+ }));
9495
+ if (!setupPath) {
9496
+ if (!smokePath) {
9497
+ return app;
9498
+ }
9499
+ return app.get(smokePath, async ({ query, request }) => {
9500
+ const report = await runTwilioVoiceSmokeTest({
9501
+ app,
9502
+ options,
9503
+ query,
9504
+ request,
9505
+ streamPath,
9506
+ twimlPath,
9507
+ webhookPath
9508
+ });
9509
+ if (query.format === "html") {
9510
+ return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
9511
+ headers: {
9512
+ "content-type": "text/html; charset=utf-8"
9513
+ }
9514
+ });
9515
+ }
9516
+ return report;
9517
+ });
9518
+ }
9519
+ const withSetup = app.get(setupPath, async ({ query, request }) => {
9520
+ const status = await buildTwilioVoiceSetupStatus(options, {
9521
+ query,
9522
+ request,
9523
+ streamPath,
9524
+ twimlPath,
9525
+ webhookPath
9526
+ });
9527
+ if (query.format === "html") {
9528
+ return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
9529
+ headers: {
9530
+ "content-type": "text/html; charset=utf-8"
9531
+ }
9532
+ });
9533
+ }
9534
+ return status;
9535
+ });
9536
+ if (!smokePath) {
9537
+ return withSetup;
9538
+ }
9539
+ return withSetup.get(smokePath, async ({ query, request }) => {
9540
+ const report = await runTwilioVoiceSmokeTest({
9541
+ app,
9542
+ options,
9543
+ query,
9544
+ request,
9545
+ streamPath,
9546
+ twimlPath,
9547
+ webhookPath
9548
+ });
9549
+ if (query.format === "html") {
9550
+ return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
9551
+ headers: {
9552
+ "content-type": "text/html; charset=utf-8"
9553
+ }
9554
+ });
9555
+ }
9556
+ return report;
9557
+ });
9558
+ };
7605
9559
 
7606
9560
  // src/testing/telephony.ts
7607
9561
  var DEFAULT_PCM16_FORMAT = {
@@ -7867,7 +9821,7 @@ var runVoiceTelephonyBenchmark = async (scenarios = getDefaultVoiceTelephonyBenc
7867
9821
  };
7868
9822
  };
7869
9823
  // src/testing/tts.ts
7870
- var DEFAULT_REALTIME_FORMAT = {
9824
+ var DEFAULT_REALTIME_FORMAT2 = {
7871
9825
  channels: 1,
7872
9826
  container: "raw",
7873
9827
  encoding: "pcm_s16le",
@@ -7926,7 +9880,7 @@ var runTTSAdapterFixture = async (adapter, fixture, options = {}) => {
7926
9880
  let audioDurationMs = 0;
7927
9881
  let audioChunkCount = 0;
7928
9882
  const session = adapter.kind === "realtime" ? await adapter.open({
7929
- format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT,
9883
+ format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT2,
7930
9884
  sessionId: `tts-benchmark:${fixture.id}`,
7931
9885
  ...openOptions ?? {}
7932
9886
  }) : await adapter.open({
@@ -8093,6 +10047,7 @@ export {
8093
10047
  getDefaultTTSBenchmarkFixtures,
8094
10048
  evaluateSTTBenchmarkAcceptance,
8095
10049
  createVoiceProviderFailureSimulator,
10050
+ createVoiceIOProviderFailureSimulator,
8096
10051
  createVoiceCallReviewRecorder,
8097
10052
  createVoiceCallReviewFromLiveTelephonyReport,
8098
10053
  createTelephonyVoiceTestFixtures,