@absolutejs/voice 0.0.22-beta.3 → 0.0.22-beta.31

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 (41) hide show
  1. package/dist/angular/index.d.ts +1 -0
  2. package/dist/angular/index.js +172 -2
  3. package/dist/angular/voice-provider-status.service.d.ts +12 -0
  4. package/dist/angular/voice-stream.service.d.ts +2 -0
  5. package/dist/assistant.d.ts +20 -0
  6. package/dist/assistantHealth.d.ts +81 -0
  7. package/dist/assistantMemory.d.ts +63 -0
  8. package/dist/client/actions.d.ts +22 -0
  9. package/dist/client/connection.d.ts +3 -0
  10. package/dist/client/htmxBootstrap.js +44 -2
  11. package/dist/client/index.d.ts +2 -0
  12. package/dist/client/index.js +125 -2
  13. package/dist/client/providerStatus.d.ts +19 -0
  14. package/dist/fileStore.d.ts +5 -2
  15. package/dist/handoff.d.ts +54 -0
  16. package/dist/handoffHealth.d.ts +94 -0
  17. package/dist/index.d.ts +20 -4
  18. package/dist/index.js +2509 -21
  19. package/dist/modelAdapters.d.ts +93 -0
  20. package/dist/opsWebhook.d.ts +126 -0
  21. package/dist/providerHealth.d.ts +78 -0
  22. package/dist/queue.d.ts +52 -0
  23. package/dist/react/index.d.ts +1 -0
  24. package/dist/react/index.js +148 -2
  25. package/dist/react/useVoiceController.d.ts +2 -0
  26. package/dist/react/useVoiceProviderStatus.d.ts +8 -0
  27. package/dist/react/useVoiceStream.d.ts +2 -0
  28. package/dist/sessionReplay.d.ts +175 -0
  29. package/dist/svelte/createVoiceProviderStatus.d.ts +8 -0
  30. package/dist/svelte/index.d.ts +1 -0
  31. package/dist/svelte/index.js +127 -2
  32. package/dist/testing/index.d.ts +1 -0
  33. package/dist/testing/index.js +1310 -7
  34. package/dist/testing/providerSimulator.d.ts +44 -0
  35. package/dist/trace.d.ts +1 -1
  36. package/dist/types.d.ts +84 -2
  37. package/dist/vue/index.d.ts +1 -0
  38. package/dist/vue/index.js +161 -2
  39. package/dist/vue/useVoiceProviderStatus.d.ts +9 -0
  40. package/dist/vue/useVoiceStream.d.ts +2 -0
  41. package/package.json +1 -1
@@ -2105,6 +2105,12 @@ var serverMessageToAction = (message) => {
2105
2105
  sessionId: message.sessionId,
2106
2106
  type: "complete"
2107
2107
  };
2108
+ case "call_lifecycle":
2109
+ return {
2110
+ event: message.event,
2111
+ sessionId: message.sessionId,
2112
+ type: "call_lifecycle"
2113
+ };
2108
2114
  case "error":
2109
2115
  return {
2110
2116
  message: normalizeErrorMessage(message.message),
@@ -2148,7 +2154,7 @@ var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
2148
2154
  var noop = () => {};
2149
2155
  var noopUnsubscribe = () => noop;
2150
2156
  var NOOP_CONNECTION = {
2151
- start: () => {},
2157
+ callControl: noop,
2152
2158
  close: noop,
2153
2159
  endTurn: noop,
2154
2160
  getReadyState: () => WS_CLOSED,
@@ -2156,6 +2162,7 @@ var NOOP_CONNECTION = {
2156
2162
  getSessionId: () => "",
2157
2163
  send: noop,
2158
2164
  sendAudio: noop,
2165
+ start: () => {},
2159
2166
  subscribe: noopUnsubscribe
2160
2167
  };
2161
2168
  var createSessionId = () => crypto.randomUUID();
@@ -2177,6 +2184,7 @@ var isVoiceServerMessage = (value) => {
2177
2184
  switch (value.type) {
2178
2185
  case "audio":
2179
2186
  case "assistant":
2187
+ case "call_lifecycle":
2180
2188
  case "complete":
2181
2189
  case "error":
2182
2190
  case "final":
@@ -2317,6 +2325,12 @@ var createVoiceConnection = (path, options = {}) => {
2317
2325
  const endTurn = () => {
2318
2326
  send({ type: "end_turn" });
2319
2327
  };
2328
+ const callControl = (message) => {
2329
+ send({
2330
+ ...message,
2331
+ type: "call_control"
2332
+ });
2333
+ };
2320
2334
  const close = () => {
2321
2335
  clearTimers();
2322
2336
  if (state.ws) {
@@ -2334,7 +2348,7 @@ var createVoiceConnection = (path, options = {}) => {
2334
2348
  };
2335
2349
  connect();
2336
2350
  return {
2337
- start,
2351
+ callControl,
2338
2352
  close,
2339
2353
  endTurn,
2340
2354
  getReadyState: () => state.ws?.readyState ?? WS_CLOSED,
@@ -2342,6 +2356,7 @@ var createVoiceConnection = (path, options = {}) => {
2342
2356
  getSessionId: () => state.sessionId,
2343
2357
  send,
2344
2358
  sendAudio,
2359
+ start,
2345
2360
  subscribe
2346
2361
  };
2347
2362
  };
@@ -2350,6 +2365,7 @@ var createVoiceConnection = (path, options = {}) => {
2350
2365
  var createInitialState2 = () => ({
2351
2366
  assistantAudio: [],
2352
2367
  assistantTexts: [],
2368
+ call: null,
2353
2369
  error: null,
2354
2370
  isConnected: false,
2355
2371
  scenarioId: null,
@@ -2393,6 +2409,20 @@ var createVoiceStreamStore = () => {
2393
2409
  status: "completed"
2394
2410
  };
2395
2411
  break;
2412
+ case "call_lifecycle":
2413
+ state = {
2414
+ ...state,
2415
+ call: {
2416
+ ...state.call,
2417
+ disposition: action.event.type === "end" ? action.event.disposition : state.call?.disposition,
2418
+ endedAt: action.event.type === "end" ? action.event.at : state.call?.endedAt,
2419
+ events: [...state.call?.events ?? [], action.event],
2420
+ lastEventAt: action.event.at,
2421
+ startedAt: state.call?.startedAt ?? action.event.at
2422
+ },
2423
+ sessionId: action.sessionId
2424
+ };
2425
+ break;
2396
2426
  case "connected":
2397
2427
  state = {
2398
2428
  ...state,
@@ -2479,6 +2509,9 @@ var createVoiceStream = (path, options = {}) => {
2479
2509
  }
2480
2510
  });
2481
2511
  return {
2512
+ callControl(message) {
2513
+ connection.callControl(message);
2514
+ },
2482
2515
  close() {
2483
2516
  unsubscribeConnection();
2484
2517
  connection.close();
@@ -2522,6 +2555,9 @@ var createVoiceStream = (path, options = {}) => {
2522
2555
  get assistantAudio() {
2523
2556
  return store.getSnapshot().assistantAudio;
2524
2557
  },
2558
+ get call() {
2559
+ return store.getSnapshot().call;
2560
+ },
2525
2561
  sendAudio(audio) {
2526
2562
  connection.sendAudio(audio);
2527
2563
  },
@@ -2854,6 +2890,7 @@ var resolveVoiceRuntimePreset = (name = "default") => {
2854
2890
  var createInitialState3 = (stream) => ({
2855
2891
  assistantAudio: [...stream.assistantAudio],
2856
2892
  assistantTexts: [...stream.assistantTexts],
2893
+ call: stream.call,
2857
2894
  error: stream.error,
2858
2895
  isConnected: stream.isConnected,
2859
2896
  isRecording: false,
@@ -2883,6 +2920,7 @@ var createVoiceController = (path, options = {}) => {
2883
2920
  ...state,
2884
2921
  assistantAudio: [...stream.assistantAudio],
2885
2922
  assistantTexts: [...stream.assistantTexts],
2923
+ call: stream.call,
2886
2924
  error: stream.error,
2887
2925
  isConnected: stream.isConnected,
2888
2926
  partial: stream.partial,
@@ -2960,6 +2998,7 @@ var createVoiceController = (path, options = {}) => {
2960
2998
  bindHTMX(bindingOptions) {
2961
2999
  return bindVoiceHTMX(stream, bindingOptions);
2962
3000
  },
3001
+ callControl: (message) => stream.callControl(message),
2963
3002
  close,
2964
3003
  endTurn: () => stream.endTurn(),
2965
3004
  get error() {
@@ -3012,6 +3051,9 @@ var createVoiceController = (path, options = {}) => {
3012
3051
  },
3013
3052
  get assistantAudio() {
3014
3053
  return state.assistantAudio;
3054
+ },
3055
+ get call() {
3056
+ return state.call;
3015
3057
  }
3016
3058
  };
3017
3059
  };
@@ -3468,6 +3510,778 @@ var loadVoiceTestFixtures = async (fixtureDirectory) => {
3468
3510
  }
3469
3511
  return fixtures;
3470
3512
  };
3513
+ // src/modelAdapters.ts
3514
+ var OUTPUT_SCHEMA = {
3515
+ additionalProperties: false,
3516
+ properties: {
3517
+ assistantText: {
3518
+ type: "string"
3519
+ },
3520
+ complete: {
3521
+ type: "boolean"
3522
+ },
3523
+ escalate: {
3524
+ additionalProperties: false,
3525
+ properties: {
3526
+ metadata: {
3527
+ additionalProperties: true,
3528
+ type: "object"
3529
+ },
3530
+ reason: {
3531
+ type: "string"
3532
+ }
3533
+ },
3534
+ required: ["reason"],
3535
+ type: "object"
3536
+ },
3537
+ noAnswer: {
3538
+ additionalProperties: false,
3539
+ properties: {
3540
+ metadata: {
3541
+ additionalProperties: true,
3542
+ type: "object"
3543
+ }
3544
+ },
3545
+ type: "object"
3546
+ },
3547
+ result: {
3548
+ additionalProperties: true,
3549
+ type: "object"
3550
+ },
3551
+ transfer: {
3552
+ additionalProperties: false,
3553
+ properties: {
3554
+ metadata: {
3555
+ additionalProperties: true,
3556
+ type: "object"
3557
+ },
3558
+ reason: {
3559
+ type: "string"
3560
+ },
3561
+ target: {
3562
+ type: "string"
3563
+ }
3564
+ },
3565
+ required: ["target"],
3566
+ type: "object"
3567
+ },
3568
+ voicemail: {
3569
+ additionalProperties: false,
3570
+ properties: {
3571
+ metadata: {
3572
+ additionalProperties: true,
3573
+ type: "object"
3574
+ }
3575
+ },
3576
+ type: "object"
3577
+ }
3578
+ },
3579
+ type: "object"
3580
+ };
3581
+ var ROUTE_RESULT_INSTRUCTION = "Return only a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools. Only set transfer, escalate, voicemail, or noAnswer when the user explicitly asks for that lifecycle outcome or a tool result says that exact outcome. Do not infer voicemail from generic words like voice, voice app, or voice integration.";
3582
+ var stripJSONCodeFence = (value) => {
3583
+ const trimmed = value.trim();
3584
+ const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
3585
+ return match?.[1]?.trim() ?? value;
3586
+ };
3587
+ var parseJSON = (value) => {
3588
+ try {
3589
+ const parsed = JSON.parse(stripJSONCodeFence(value));
3590
+ return parsed && typeof parsed === "object" ? parsed : {};
3591
+ } catch {
3592
+ return {
3593
+ assistantText: value
3594
+ };
3595
+ }
3596
+ };
3597
+ var parseJSONValue = (value) => {
3598
+ try {
3599
+ return JSON.parse(value);
3600
+ } catch {
3601
+ return value;
3602
+ }
3603
+ };
3604
+ var getMessageToolCalls = (message) => {
3605
+ const toolCalls = message.metadata?.toolCalls;
3606
+ return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
3607
+ };
3608
+ var createHTTPError = (provider, response) => new Error(`${provider} voice assistant model failed: HTTP ${response.status}`);
3609
+ var sleep = (ms) => new Promise((resolve2) => {
3610
+ setTimeout(resolve2, ms);
3611
+ });
3612
+ var errorMessage = (error) => error instanceof Error ? error.message : String(error);
3613
+ var defaultIsRateLimitError = (error) => /(\b429\b|rate limit|quota|too many requests)/i.test(errorMessage(error));
3614
+ var normalizeRouteOutput = (output) => {
3615
+ const result = {};
3616
+ if (typeof output.assistantText === "string") {
3617
+ result.assistantText = output.assistantText;
3618
+ }
3619
+ if (typeof output.complete === "boolean") {
3620
+ result.complete = output.complete;
3621
+ }
3622
+ if (output.result !== undefined) {
3623
+ result.result = output.result;
3624
+ }
3625
+ if (output.transfer && typeof output.transfer === "object") {
3626
+ const transfer = output.transfer;
3627
+ if (typeof transfer.target === "string") {
3628
+ result.transfer = {
3629
+ metadata: transfer.metadata && typeof transfer.metadata === "object" ? transfer.metadata : undefined,
3630
+ reason: typeof transfer.reason === "string" ? transfer.reason : undefined,
3631
+ target: transfer.target
3632
+ };
3633
+ }
3634
+ }
3635
+ if (output.escalate && typeof output.escalate === "object") {
3636
+ const escalate = output.escalate;
3637
+ if (typeof escalate.reason === "string") {
3638
+ result.escalate = {
3639
+ metadata: escalate.metadata && typeof escalate.metadata === "object" ? escalate.metadata : undefined,
3640
+ reason: escalate.reason
3641
+ };
3642
+ }
3643
+ }
3644
+ if (output.voicemail && typeof output.voicemail === "object") {
3645
+ const voicemail = output.voicemail;
3646
+ result.voicemail = {
3647
+ metadata: voicemail.metadata && typeof voicemail.metadata === "object" ? voicemail.metadata : undefined
3648
+ };
3649
+ }
3650
+ if (output.noAnswer && typeof output.noAnswer === "object") {
3651
+ const noAnswer = output.noAnswer;
3652
+ result.noAnswer = {
3653
+ metadata: noAnswer.metadata && typeof noAnswer.metadata === "object" ? noAnswer.metadata : undefined
3654
+ };
3655
+ }
3656
+ return result;
3657
+ };
3658
+ var createJSONVoiceAssistantModel = (options) => ({
3659
+ generate: async (input) => {
3660
+ const output = await options.generate(input);
3661
+ if ("assistantText" in output || "toolCalls" in output || "complete" in output || "transfer" in output || "escalate" in output) {
3662
+ return output;
3663
+ }
3664
+ return options.mapOutput?.(output) ?? normalizeRouteOutput(output);
3665
+ }
3666
+ });
3667
+ var createVoiceProviderRouter = (options) => {
3668
+ const providerIds = Object.keys(options.providers);
3669
+ const firstProvider = providerIds[0];
3670
+ const policy = typeof options.policy === "string" ? {
3671
+ strategy: options.policy
3672
+ } : options.policy;
3673
+ const strategy = policy?.strategy ?? "prefer-selected";
3674
+ const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? "provider-error";
3675
+ const healthOptions = typeof options.providerHealth === "object" ? options.providerHealth : options.providerHealth ? {} : undefined;
3676
+ const healthState = new Map;
3677
+ const now = () => healthOptions?.now?.() ?? Date.now();
3678
+ const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
3679
+ const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
3680
+ const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
3681
+ const getHealth = (provider) => {
3682
+ const existing = healthState.get(provider);
3683
+ if (existing) {
3684
+ return existing;
3685
+ }
3686
+ const next = {
3687
+ consecutiveFailures: 0,
3688
+ provider,
3689
+ status: "healthy"
3690
+ };
3691
+ healthState.set(provider, next);
3692
+ return next;
3693
+ };
3694
+ const cloneHealth = (provider) => {
3695
+ if (!healthOptions) {
3696
+ return;
3697
+ }
3698
+ return {
3699
+ ...getHealth(provider)
3700
+ };
3701
+ };
3702
+ const getSuppressionRemainingMs = (provider) => {
3703
+ if (!healthOptions) {
3704
+ return;
3705
+ }
3706
+ const suppressedUntil = getHealth(provider).suppressedUntil;
3707
+ return typeof suppressedUntil === "number" ? Math.max(0, suppressedUntil - now()) : undefined;
3708
+ };
3709
+ const isSuppressed = (provider) => {
3710
+ if (!healthOptions) {
3711
+ return false;
3712
+ }
3713
+ const health = getHealth(provider);
3714
+ return typeof health.suppressedUntil === "number" && health.suppressedUntil > now();
3715
+ };
3716
+ const recordProviderSuccess = (provider) => {
3717
+ if (!healthOptions) {
3718
+ return;
3719
+ }
3720
+ const health = getHealth(provider);
3721
+ health.consecutiveFailures = 0;
3722
+ health.status = "healthy";
3723
+ health.suppressedUntil = undefined;
3724
+ return cloneHealth(provider);
3725
+ };
3726
+ const recordProviderError = (provider, isProviderError, rateLimited) => {
3727
+ if (!healthOptions || !isProviderError) {
3728
+ return cloneHealth(provider);
3729
+ }
3730
+ const currentTime = now();
3731
+ const health = getHealth(provider);
3732
+ health.consecutiveFailures += 1;
3733
+ health.lastFailureAt = currentTime;
3734
+ if (rateLimited) {
3735
+ health.lastRateLimitedAt = currentTime;
3736
+ }
3737
+ if (rateLimited || health.consecutiveFailures >= failureThreshold) {
3738
+ health.status = "suppressed";
3739
+ health.suppressedUntil = currentTime + (rateLimited ? rateLimitCooldownMs : cooldownMs);
3740
+ }
3741
+ return cloneHealth(provider);
3742
+ };
3743
+ const resolveAllowedProviders = async (input) => {
3744
+ const allowProviders = policy?.allowProviders ?? options.allowProviders;
3745
+ const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
3746
+ return new Set(allowed ?? providerIds);
3747
+ };
3748
+ const sortProviders = (providers) => {
3749
+ if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest") {
3750
+ return providers;
3751
+ }
3752
+ return [...providers].sort((left, right) => {
3753
+ const leftProfile = options.providerProfiles?.[left];
3754
+ const rightProfile = options.providerProfiles?.[right];
3755
+ const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
3756
+ const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
3757
+ return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
3758
+ });
3759
+ };
3760
+ const resolveOrder = async (input) => {
3761
+ const selectedProvider = await options.selectProvider?.(input);
3762
+ const allowedProviders = await resolveAllowedProviders(input);
3763
+ const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
3764
+ const rankedProviders = sortProviders([
3765
+ ...fallbackOrder ?? providerIds
3766
+ ]).filter((provider) => allowedProviders.has(provider));
3767
+ const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
3768
+ const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
3769
+ const preferred = selectedProvider && allowedProviders.has(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
3770
+ const seen = new Set;
3771
+ const order = [];
3772
+ const candidates = strategy === "ordered" ? candidateRankedProviders : [
3773
+ preferred,
3774
+ ...candidateRankedProviders,
3775
+ ...providerIds.filter((provider) => !healthOptions || !isSuppressed(provider))
3776
+ ];
3777
+ for (const provider of candidates) {
3778
+ if (!provider || seen.has(provider) || !allowedProviders.has(provider) || !options.providers[provider]) {
3779
+ continue;
3780
+ }
3781
+ seen.add(provider);
3782
+ order.push(provider);
3783
+ }
3784
+ return {
3785
+ order,
3786
+ selectedProvider: preferred
3787
+ };
3788
+ };
3789
+ const emit = async (event, input) => {
3790
+ await options.onProviderEvent?.(event, input);
3791
+ };
3792
+ return {
3793
+ generate: async (input) => {
3794
+ const { order, selectedProvider } = await resolveOrder(input);
3795
+ if (!selectedProvider || order.length === 0) {
3796
+ throw new Error("Voice provider router has no available providers.");
3797
+ }
3798
+ let lastError;
3799
+ for (const [index, provider] of order.entries()) {
3800
+ const model = options.providers[provider];
3801
+ if (!model) {
3802
+ continue;
3803
+ }
3804
+ const startedAt = Date.now();
3805
+ try {
3806
+ const output = await model.generate(input);
3807
+ const providerHealth = recordProviderSuccess(provider);
3808
+ await emit({
3809
+ at: Date.now(),
3810
+ elapsedMs: Date.now() - startedAt,
3811
+ fallbackProvider: provider === selectedProvider ? undefined : provider,
3812
+ provider,
3813
+ providerHealth,
3814
+ recovered: provider !== selectedProvider,
3815
+ selectedProvider,
3816
+ status: provider === selectedProvider ? "success" : "fallback"
3817
+ }, input);
3818
+ return output;
3819
+ } catch (error) {
3820
+ lastError = error;
3821
+ const hasNextProvider = index < order.length - 1;
3822
+ const isProviderError = options.isProviderError?.(error, provider) ?? true;
3823
+ const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
3824
+ const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
3825
+ const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
3826
+ const nextProvider = hasNextProvider ? order[index + 1] : undefined;
3827
+ await emit({
3828
+ at: Date.now(),
3829
+ elapsedMs: Date.now() - startedAt,
3830
+ error: errorMessage(error),
3831
+ fallbackProvider: shouldFallback ? nextProvider : undefined,
3832
+ provider,
3833
+ providerHealth,
3834
+ rateLimited,
3835
+ selectedProvider,
3836
+ suppressionRemainingMs: getSuppressionRemainingMs(provider),
3837
+ suppressedUntil: providerHealth?.suppressedUntil,
3838
+ status: "error"
3839
+ }, input);
3840
+ if (!hasNextProvider || !shouldFallback) {
3841
+ throw error;
3842
+ }
3843
+ }
3844
+ }
3845
+ throw lastError ?? new Error("Voice provider router did not run a provider.");
3846
+ }
3847
+ };
3848
+ };
3849
+ var messageToOpenAIInput = (message) => {
3850
+ if (message.role === "tool") {
3851
+ return [
3852
+ {
3853
+ call_id: message.toolCallId ?? message.name ?? crypto.randomUUID(),
3854
+ output: message.content,
3855
+ type: "function_call_output"
3856
+ }
3857
+ ];
3858
+ }
3859
+ const toolCalls = getMessageToolCalls(message);
3860
+ if (message.role === "assistant" && toolCalls.length) {
3861
+ return toolCalls.map((toolCall) => ({
3862
+ arguments: JSON.stringify(toolCall.args),
3863
+ call_id: toolCall.id ?? crypto.randomUUID(),
3864
+ name: toolCall.name,
3865
+ type: "function_call"
3866
+ }));
3867
+ }
3868
+ return [
3869
+ {
3870
+ content: message.content,
3871
+ role: message.role === "system" ? "developer" : message.role
3872
+ }
3873
+ ];
3874
+ };
3875
+ var messagesToOpenAIInput = (messages) => messages.flatMap(messageToOpenAIInput);
3876
+ var messageToAnthropicMessage = (message) => {
3877
+ if (message.role === "system") {
3878
+ return;
3879
+ }
3880
+ if (message.role === "tool") {
3881
+ if (!message.toolCallId) {
3882
+ return {
3883
+ content: `Tool result from ${message.name ?? "tool"}: ${message.content}`,
3884
+ role: "user"
3885
+ };
3886
+ }
3887
+ return {
3888
+ content: [
3889
+ {
3890
+ content: message.content,
3891
+ tool_use_id: message.toolCallId,
3892
+ type: "tool_result"
3893
+ }
3894
+ ],
3895
+ role: "user"
3896
+ };
3897
+ }
3898
+ const toolCalls = getMessageToolCalls(message);
3899
+ if (message.role === "assistant" && toolCalls.length) {
3900
+ return {
3901
+ content: [
3902
+ ...message.content ? [
3903
+ {
3904
+ text: message.content,
3905
+ type: "text"
3906
+ }
3907
+ ] : [],
3908
+ ...toolCalls.map((toolCall) => ({
3909
+ id: toolCall.id ?? crypto.randomUUID(),
3910
+ input: toolCall.args,
3911
+ name: toolCall.name,
3912
+ type: "tool_use"
3913
+ }))
3914
+ ],
3915
+ role: "assistant"
3916
+ };
3917
+ }
3918
+ return {
3919
+ content: message.content,
3920
+ role: message.role
3921
+ };
3922
+ };
3923
+ var toGeminiSchema = (schema) => {
3924
+ const next = {};
3925
+ for (const [key, value] of Object.entries(schema)) {
3926
+ if (key === "additionalProperties") {
3927
+ continue;
3928
+ }
3929
+ if (key === "type" && typeof value === "string") {
3930
+ next[key] = value.toUpperCase();
3931
+ continue;
3932
+ }
3933
+ if (Array.isArray(value)) {
3934
+ next[key] = value.map((item) => item && typeof item === "object" ? toGeminiSchema(item) : item);
3935
+ continue;
3936
+ }
3937
+ if (value && typeof value === "object") {
3938
+ next[key] = toGeminiSchema(value);
3939
+ continue;
3940
+ }
3941
+ next[key] = value;
3942
+ }
3943
+ return next;
3944
+ };
3945
+ var messageToGeminiContent = (message) => {
3946
+ if (message.role === "system") {
3947
+ return;
3948
+ }
3949
+ if (message.role === "tool") {
3950
+ return {
3951
+ parts: [
3952
+ {
3953
+ functionResponse: {
3954
+ id: message.toolCallId,
3955
+ name: message.name ?? "tool",
3956
+ response: {
3957
+ result: parseJSONValue(message.content)
3958
+ }
3959
+ }
3960
+ }
3961
+ ],
3962
+ role: "user"
3963
+ };
3964
+ }
3965
+ const toolCalls = getMessageToolCalls(message);
3966
+ if (message.role === "assistant" && toolCalls.length) {
3967
+ return {
3968
+ parts: [
3969
+ ...message.content ? [
3970
+ {
3971
+ text: message.content
3972
+ }
3973
+ ] : [],
3974
+ ...toolCalls.map((toolCall) => ({
3975
+ functionCall: {
3976
+ args: toolCall.args,
3977
+ id: toolCall.id,
3978
+ name: toolCall.name
3979
+ }
3980
+ }))
3981
+ ],
3982
+ role: "model"
3983
+ };
3984
+ }
3985
+ return {
3986
+ parts: [
3987
+ {
3988
+ text: message.content
3989
+ }
3990
+ ],
3991
+ role: message.role === "assistant" ? "model" : "user"
3992
+ };
3993
+ };
3994
+ var extractText = (response) => {
3995
+ if (typeof response.output_text === "string") {
3996
+ return response.output_text;
3997
+ }
3998
+ const output = Array.isArray(response.output) ? response.output : [];
3999
+ for (const item of output) {
4000
+ if (!item || typeof item !== "object") {
4001
+ continue;
4002
+ }
4003
+ const record = item;
4004
+ const content = Array.isArray(record.content) ? record.content : [];
4005
+ for (const contentItem of content) {
4006
+ if (!contentItem || typeof contentItem !== "object") {
4007
+ continue;
4008
+ }
4009
+ const contentRecord = contentItem;
4010
+ if (typeof contentRecord.text === "string") {
4011
+ return contentRecord.text;
4012
+ }
4013
+ }
4014
+ }
4015
+ return "";
4016
+ };
4017
+ var extractToolCalls = (response) => {
4018
+ const output = Array.isArray(response.output) ? response.output : [];
4019
+ const toolCalls = [];
4020
+ for (const item of output) {
4021
+ if (!item || typeof item !== "object") {
4022
+ continue;
4023
+ }
4024
+ const record = item;
4025
+ if (record.type !== "function_call" || typeof record.name !== "string") {
4026
+ continue;
4027
+ }
4028
+ const args = typeof record.arguments === "string" ? parseJSON(record.arguments) : {};
4029
+ toolCalls.push({
4030
+ args,
4031
+ id: typeof record.call_id === "string" ? record.call_id : typeof record.id === "string" ? record.id : undefined,
4032
+ name: record.name
4033
+ });
4034
+ }
4035
+ return toolCalls;
4036
+ };
4037
+ var createOpenAIVoiceAssistantModel = (options) => {
4038
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4039
+ const baseUrl = options.baseUrl ?? "https://api.openai.com/v1";
4040
+ const model = options.model ?? "gpt-4.1-mini";
4041
+ return {
4042
+ generate: async (input) => {
4043
+ const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, {
4044
+ body: JSON.stringify({
4045
+ input: messagesToOpenAIInput(input.messages),
4046
+ instructions: [
4047
+ input.system,
4048
+ "Return a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools."
4049
+ ].filter(Boolean).join(`
4050
+
4051
+ `),
4052
+ max_output_tokens: options.maxOutputTokens,
4053
+ model,
4054
+ temperature: options.temperature,
4055
+ text: {
4056
+ format: {
4057
+ name: "voice_route_result",
4058
+ schema: OUTPUT_SCHEMA,
4059
+ strict: false,
4060
+ type: "json_schema"
4061
+ }
4062
+ },
4063
+ tool_choice: input.tools.length ? "auto" : "none",
4064
+ tools: input.tools.map((tool) => ({
4065
+ description: tool.description,
4066
+ name: tool.name,
4067
+ parameters: tool.parameters ?? {
4068
+ additionalProperties: true,
4069
+ type: "object"
4070
+ },
4071
+ strict: false,
4072
+ type: "function"
4073
+ }))
4074
+ }),
4075
+ headers: {
4076
+ authorization: `Bearer ${options.apiKey}`,
4077
+ "content-type": "application/json"
4078
+ },
4079
+ method: "POST"
4080
+ });
4081
+ if (!response.ok) {
4082
+ throw createHTTPError("OpenAI", response);
4083
+ }
4084
+ const body = await response.json();
4085
+ if (body.usage && typeof body.usage === "object") {
4086
+ await options.onUsage?.(body.usage);
4087
+ }
4088
+ const toolCalls = extractToolCalls(body);
4089
+ if (toolCalls.length) {
4090
+ return {
4091
+ toolCalls
4092
+ };
4093
+ }
4094
+ return normalizeRouteOutput(parseJSON(extractText(body)));
4095
+ }
4096
+ };
4097
+ };
4098
+ var extractAnthropicText = (response) => {
4099
+ const content = Array.isArray(response.content) ? response.content : [];
4100
+ return content.map((item) => item && typeof item === "object" && item.type === "text" && typeof item.text === "string" ? item.text : "").filter(Boolean).join(`
4101
+ `);
4102
+ };
4103
+ var extractAnthropicToolCalls = (response) => {
4104
+ const content = Array.isArray(response.content) ? response.content : [];
4105
+ const toolCalls = [];
4106
+ for (const item of content) {
4107
+ if (!item || typeof item !== "object") {
4108
+ continue;
4109
+ }
4110
+ const record = item;
4111
+ if (record.type !== "tool_use" || typeof record.name !== "string") {
4112
+ continue;
4113
+ }
4114
+ toolCalls.push({
4115
+ args: record.input && typeof record.input === "object" ? record.input : {},
4116
+ id: typeof record.id === "string" ? record.id : undefined,
4117
+ name: record.name
4118
+ });
4119
+ }
4120
+ return toolCalls;
4121
+ };
4122
+ var createAnthropicVoiceAssistantModel = (options) => {
4123
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4124
+ const baseUrl = options.baseUrl ?? "https://api.anthropic.com/v1";
4125
+ const model = options.model ?? "claude-sonnet-4-5";
4126
+ return {
4127
+ generate: async (input) => {
4128
+ const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/messages`, {
4129
+ body: JSON.stringify({
4130
+ max_tokens: options.maxOutputTokens ?? 1024,
4131
+ messages: input.messages.map(messageToAnthropicMessage).filter(Boolean),
4132
+ model,
4133
+ system: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
4134
+
4135
+ `),
4136
+ temperature: options.temperature,
4137
+ tool_choice: input.tools.length ? { type: "auto" } : { type: "none" },
4138
+ tools: input.tools.map((tool) => ({
4139
+ description: tool.description,
4140
+ input_schema: tool.parameters ?? {
4141
+ additionalProperties: true,
4142
+ type: "object"
4143
+ },
4144
+ name: tool.name
4145
+ }))
4146
+ }),
4147
+ headers: {
4148
+ "anthropic-version": options.version ?? "2023-06-01",
4149
+ "content-type": "application/json",
4150
+ "x-api-key": options.apiKey
4151
+ },
4152
+ method: "POST"
4153
+ });
4154
+ if (!response.ok) {
4155
+ throw createHTTPError("Anthropic", response);
4156
+ }
4157
+ const body = await response.json();
4158
+ if (body.usage && typeof body.usage === "object") {
4159
+ await options.onUsage?.(body.usage);
4160
+ }
4161
+ const toolCalls = extractAnthropicToolCalls(body);
4162
+ if (toolCalls.length) {
4163
+ return {
4164
+ assistantText: extractAnthropicText(body) || undefined,
4165
+ toolCalls
4166
+ };
4167
+ }
4168
+ return normalizeRouteOutput(parseJSON(extractAnthropicText(body)));
4169
+ }
4170
+ };
4171
+ };
4172
+ var extractGeminiCandidateParts = (response) => {
4173
+ const candidates = Array.isArray(response.candidates) ? response.candidates : [];
4174
+ const first = candidates[0];
4175
+ if (!first || typeof first !== "object") {
4176
+ return [];
4177
+ }
4178
+ const content = first.content;
4179
+ if (!content || typeof content !== "object") {
4180
+ return [];
4181
+ }
4182
+ const parts = content.parts;
4183
+ return Array.isArray(parts) ? parts : [];
4184
+ };
4185
+ var extractGeminiText = (response) => extractGeminiCandidateParts(response).map((part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : "").filter(Boolean).join(`
4186
+ `);
4187
+ var extractGeminiToolCalls = (response) => {
4188
+ const toolCalls = [];
4189
+ for (const part of extractGeminiCandidateParts(response)) {
4190
+ if (!part || typeof part !== "object") {
4191
+ continue;
4192
+ }
4193
+ const functionCall = part.functionCall;
4194
+ if (!functionCall || typeof functionCall !== "object") {
4195
+ continue;
4196
+ }
4197
+ const record = functionCall;
4198
+ if (typeof record.name !== "string") {
4199
+ continue;
4200
+ }
4201
+ toolCalls.push({
4202
+ args: record.args && typeof record.args === "object" ? record.args : {},
4203
+ id: typeof record.id === "string" ? record.id : undefined,
4204
+ name: record.name
4205
+ });
4206
+ }
4207
+ return toolCalls;
4208
+ };
4209
+ var createGeminiVoiceAssistantModel = (options) => {
4210
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4211
+ const baseUrl = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
4212
+ const model = options.model ?? "gemini-2.5-flash";
4213
+ const maxRetries = Math.max(0, options.maxRetries ?? 2);
4214
+ return {
4215
+ generate: async (input) => {
4216
+ const endpoint = `${baseUrl.replace(/\/$/, "")}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(options.apiKey)}`;
4217
+ let response;
4218
+ for (let attempt = 0;attempt <= maxRetries; attempt += 1) {
4219
+ response = await fetchImpl(endpoint, {
4220
+ body: JSON.stringify({
4221
+ contents: input.messages.map(messageToGeminiContent).filter(Boolean),
4222
+ generationConfig: {
4223
+ maxOutputTokens: options.maxOutputTokens,
4224
+ ...input.tools.length ? {} : {
4225
+ responseMimeType: "application/json",
4226
+ responseSchema: toGeminiSchema(OUTPUT_SCHEMA)
4227
+ },
4228
+ temperature: options.temperature
4229
+ },
4230
+ systemInstruction: {
4231
+ parts: [
4232
+ {
4233
+ text: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
4234
+
4235
+ `)
4236
+ }
4237
+ ]
4238
+ },
4239
+ tools: input.tools.length ? [
4240
+ {
4241
+ functionDeclarations: input.tools.map((tool) => ({
4242
+ description: tool.description,
4243
+ name: tool.name,
4244
+ parameters: toGeminiSchema(tool.parameters ?? {
4245
+ additionalProperties: true,
4246
+ type: "object"
4247
+ })
4248
+ }))
4249
+ }
4250
+ ] : undefined
4251
+ }),
4252
+ headers: {
4253
+ "content-type": "application/json"
4254
+ },
4255
+ method: "POST"
4256
+ });
4257
+ if (response.ok || response.status !== 429 && response.status < 500 || attempt === maxRetries) {
4258
+ break;
4259
+ }
4260
+ const retryAfter = Number(response.headers.get("retry-after"));
4261
+ await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt);
4262
+ }
4263
+ if (!response) {
4264
+ throw new Error("Gemini voice assistant model failed: no response");
4265
+ }
4266
+ if (!response.ok) {
4267
+ throw createHTTPError("Gemini", response);
4268
+ }
4269
+ const body = await response.json();
4270
+ if (body.usageMetadata && typeof body.usageMetadata === "object") {
4271
+ await options.onUsage?.(body.usageMetadata);
4272
+ }
4273
+ const toolCalls = extractGeminiToolCalls(body);
4274
+ if (toolCalls.length) {
4275
+ return {
4276
+ assistantText: extractGeminiText(body) || undefined,
4277
+ toolCalls
4278
+ };
4279
+ }
4280
+ return normalizeRouteOutput(parseJSON(extractGeminiText(body)));
4281
+ }
4282
+ };
4283
+ };
4284
+
3471
4285
  // src/store.ts
3472
4286
  var createId = () => crypto.randomUUID();
3473
4287
  var createVoiceSessionRecord = (id, scenarioId) => ({
@@ -3508,6 +4322,118 @@ var toVoiceSessionSummary = (session) => ({
3508
4322
  turnCount: session.turns.length
3509
4323
  });
3510
4324
 
4325
+ // src/testing/providerSimulator.ts
4326
+ var getContextQuery = (context) => context.query;
4327
+ var titleCaseProvider = (provider) => provider.split(/[-_\s]+/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ");
4328
+ var resolveRequestedProvider = (context, providers) => {
4329
+ const provider = getContextQuery(context).provider;
4330
+ return providers.includes(provider) ? provider : providers[0];
4331
+ };
4332
+ var createVoiceProviderFailureSimulator = (options) => {
4333
+ if (options.providers.length === 0) {
4334
+ throw new Error("At least one provider is required.");
4335
+ }
4336
+ const providerModels = Object.fromEntries(options.providers.map((provider) => [
4337
+ provider,
4338
+ {
4339
+ generate: async (input) => {
4340
+ const query = getContextQuery(input.context);
4341
+ if (provider === query.simulateFailureProvider) {
4342
+ const label = options.providerLabel?.(provider) ?? titleCaseProvider(provider);
4343
+ throw new Error(`${label} voice assistant model failed: HTTP 429`);
4344
+ }
4345
+ if (options.response) {
4346
+ return options.response({
4347
+ ...input,
4348
+ mode: query.recoverProvider === provider ? "recovery" : "failure",
4349
+ provider
4350
+ });
4351
+ }
4352
+ return {
4353
+ assistantText: `Simulated ${provider} provider recovered.`
4354
+ };
4355
+ }
4356
+ }
4357
+ ]));
4358
+ const router = createVoiceProviderRouter({
4359
+ allowProviders: async (input) => {
4360
+ const recoverProvider = getContextQuery(input.context).recoverProvider;
4361
+ if (recoverProvider) {
4362
+ return [recoverProvider];
4363
+ }
4364
+ if (typeof options.allowProviders === "function") {
4365
+ return options.allowProviders(input);
4366
+ }
4367
+ return options.allowProviders ?? options.providers;
4368
+ },
4369
+ fallback: async (input) => {
4370
+ const selectedProvider = resolveRequestedProvider(input.context, options.providers);
4371
+ if (typeof options.fallback === "function") {
4372
+ return options.fallback(selectedProvider, input);
4373
+ }
4374
+ return options.fallback ?? options.providers.filter((provider) => provider !== selectedProvider);
4375
+ },
4376
+ fallbackMode: "provider-error",
4377
+ isProviderError: options.isProviderError,
4378
+ isRateLimitError: options.isRateLimitError,
4379
+ onProviderEvent: options.onProviderEvent,
4380
+ policy: "prefer-selected",
4381
+ providerHealth: options.providerHealth ?? {
4382
+ cooldownMs: 30000,
4383
+ failureThreshold: 1,
4384
+ rateLimitCooldownMs: 120000
4385
+ },
4386
+ providers: providerModels,
4387
+ selectProvider: ({ context }) => resolveRequestedProvider(context, options.providers)
4388
+ });
4389
+ const run = async (provider, mode) => {
4390
+ const now = Date.now();
4391
+ const session = createVoiceSessionRecord(`provider-sim-${now}`, "provider-simulation");
4392
+ const turn = {
4393
+ committedAt: now,
4394
+ id: `provider-sim-turn-${now}`,
4395
+ text: mode === "failure" ? `Simulate ${provider} provider failure.` : `Simulate ${provider} provider recovery.`,
4396
+ transcripts: []
4397
+ };
4398
+ const context = {
4399
+ query: {
4400
+ provider,
4401
+ ...mode === "recovery" ? { recoverProvider: provider } : {},
4402
+ ...mode === "failure" ? { simulateFailureProvider: provider } : {}
4403
+ }
4404
+ };
4405
+ const result = await router.generate({
4406
+ agentId: "provider-simulator",
4407
+ context,
4408
+ messages: [
4409
+ {
4410
+ content: turn.text,
4411
+ role: "user"
4412
+ }
4413
+ ],
4414
+ session,
4415
+ system: "Simulate provider routing without calling external APIs.",
4416
+ tools: [],
4417
+ turn
4418
+ });
4419
+ return {
4420
+ mode,
4421
+ provider,
4422
+ replayHref: options.replayHref === false ? undefined : typeof options.replayHref === "function" ? options.replayHref({
4423
+ provider,
4424
+ sessionId: session.id,
4425
+ turnId: turn.id
4426
+ }) : `${options.replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(session.id)}/replay/htmx`,
4427
+ result,
4428
+ sessionId: session.id,
4429
+ status: "simulated",
4430
+ turnId: turn.id
4431
+ };
4432
+ };
4433
+ return {
4434
+ run
4435
+ };
4436
+ };
3511
4437
  // src/memoryStore.ts
3512
4438
  var createVoiceMemoryStore = () => {
3513
4439
  const sessions = new Map;
@@ -3533,6 +4459,289 @@ var createVoiceMemoryStore = () => {
3533
4459
  // src/session.ts
3534
4460
  import { Buffer } from "buffer";
3535
4461
 
4462
+ // src/handoff.ts
4463
+ var toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
4464
+ var signHandoffBody = async (input) => {
4465
+ const encoder = new TextEncoder;
4466
+ const key = await crypto.subtle.importKey("raw", encoder.encode(input.secret), {
4467
+ hash: "SHA-256",
4468
+ name: "HMAC"
4469
+ }, false, ["sign"]);
4470
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${input.timestamp}.${input.body}`));
4471
+ return `sha256=${toHex(new Uint8Array(signature))}`;
4472
+ };
4473
+ var toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
4474
+ var createSkippedDelivery = (adapter) => ({
4475
+ adapterId: adapter.id,
4476
+ adapterKind: adapter.kind,
4477
+ status: "skipped"
4478
+ });
4479
+ var aggregateHandoffStatus = (deliveries) => {
4480
+ const statuses = Object.values(deliveries).map((delivery) => delivery.status);
4481
+ if (statuses.some((status) => status === "failed")) {
4482
+ return "failed";
4483
+ }
4484
+ if (statuses.some((status) => status === "delivered")) {
4485
+ return "delivered";
4486
+ }
4487
+ return "skipped";
4488
+ };
4489
+ var createHandoffDeliveryId = (input) => [
4490
+ "voice-handoff",
4491
+ input.sessionId,
4492
+ input.action,
4493
+ Date.now(),
4494
+ crypto.randomUUID()
4495
+ ].join(":");
4496
+ var resolveHandoffDeliveryError = (deliveries) => Object.values(deliveries).map((delivery) => delivery.error).find(Boolean);
4497
+ var defaultWebhookBody = (input) => ({
4498
+ action: input.action,
4499
+ metadata: input.metadata,
4500
+ reason: input.reason,
4501
+ result: input.result,
4502
+ session: {
4503
+ id: input.session.id,
4504
+ scenarioId: input.session.scenarioId,
4505
+ status: input.session.status
4506
+ },
4507
+ source: "absolutejs-voice",
4508
+ target: input.target
4509
+ });
4510
+ var deliverVoiceHandoff = async (input) => {
4511
+ if (!input.config || input.config.adapters.length === 0) {
4512
+ return;
4513
+ }
4514
+ const deliveries = {};
4515
+ for (const adapter of input.config.adapters) {
4516
+ if (adapter.actions && !adapter.actions.includes(input.handoff.action)) {
4517
+ deliveries[adapter.id] = createSkippedDelivery(adapter);
4518
+ continue;
4519
+ }
4520
+ try {
4521
+ const result = await adapter.handoff(input.handoff);
4522
+ deliveries[adapter.id] = {
4523
+ ...result,
4524
+ adapterId: adapter.id,
4525
+ adapterKind: adapter.kind
4526
+ };
4527
+ } catch (error) {
4528
+ deliveries[adapter.id] = {
4529
+ adapterId: adapter.id,
4530
+ adapterKind: adapter.kind,
4531
+ error: toErrorMessage(error),
4532
+ status: "failed"
4533
+ };
4534
+ if (input.config.failMode === "throw") {
4535
+ throw error;
4536
+ }
4537
+ }
4538
+ }
4539
+ return {
4540
+ action: input.handoff.action,
4541
+ deliveries,
4542
+ status: aggregateHandoffStatus(deliveries)
4543
+ };
4544
+ };
4545
+ var createVoiceHandoffDeliveryRecord = (input) => {
4546
+ const now = Date.now();
4547
+ return {
4548
+ action: input.action,
4549
+ context: input.context,
4550
+ createdAt: now,
4551
+ deliveryAttempts: 0,
4552
+ deliveryStatus: "pending",
4553
+ id: input.id ?? createHandoffDeliveryId({
4554
+ action: input.action,
4555
+ sessionId: input.session.id
4556
+ }),
4557
+ metadata: input.metadata,
4558
+ reason: input.reason,
4559
+ result: input.result,
4560
+ session: input.session,
4561
+ sessionId: input.session.id,
4562
+ target: input.target,
4563
+ updatedAt: now
4564
+ };
4565
+ };
4566
+ var applyVoiceHandoffDeliveryResult = (delivery, result) => ({
4567
+ ...delivery,
4568
+ deliveredAt: result.status === "delivered" || result.status === "skipped" ? Date.now() : delivery.deliveredAt,
4569
+ deliveries: result.deliveries,
4570
+ deliveryAttempts: (delivery.deliveryAttempts ?? 0) + 1,
4571
+ deliveryError: result.status === "failed" ? resolveHandoffDeliveryError(result.deliveries) : undefined,
4572
+ deliveryStatus: result.status,
4573
+ updatedAt: Date.now()
4574
+ });
4575
+ var deliverVoiceHandoffDelivery = async (options) => {
4576
+ const result = await deliverVoiceHandoff({
4577
+ config: {
4578
+ adapters: options.adapters,
4579
+ failMode: options.failMode
4580
+ },
4581
+ handoff: {
4582
+ action: options.delivery.action,
4583
+ api: options.api,
4584
+ context: options.delivery.context,
4585
+ metadata: options.delivery.metadata,
4586
+ reason: options.delivery.reason,
4587
+ result: options.delivery.result,
4588
+ session: options.delivery.session,
4589
+ target: options.delivery.target
4590
+ }
4591
+ });
4592
+ return result ? applyVoiceHandoffDeliveryResult(options.delivery, result) : {
4593
+ ...options.delivery,
4594
+ deliveryAttempts: (options.delivery.deliveryAttempts ?? 0) + 1,
4595
+ deliveryStatus: "skipped",
4596
+ updatedAt: Date.now()
4597
+ };
4598
+ };
4599
+ var createVoiceMemoryHandoffDeliveryStore = () => {
4600
+ const deliveries = new Map;
4601
+ return {
4602
+ get: async (id) => deliveries.get(id),
4603
+ list: async () => [...deliveries.values()].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
4604
+ remove: async (id) => {
4605
+ deliveries.delete(id);
4606
+ },
4607
+ set: async (id, delivery) => {
4608
+ deliveries.set(id, delivery);
4609
+ }
4610
+ };
4611
+ };
4612
+ var createVoiceWebhookHandoffAdapter = (options) => ({
4613
+ actions: options.actions,
4614
+ handoff: async (input) => {
4615
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4616
+ if (typeof fetchImpl !== "function") {
4617
+ return {
4618
+ deliveredTo: options.url,
4619
+ error: "Handoff delivery failed: fetch is not available in this runtime.",
4620
+ status: "failed"
4621
+ };
4622
+ }
4623
+ const body = JSON.stringify(await options.body?.(input) ?? defaultWebhookBody(input));
4624
+ const headers = {
4625
+ "content-type": "application/json",
4626
+ ...options.headers
4627
+ };
4628
+ if (options.signingSecret) {
4629
+ const timestamp = String(Date.now());
4630
+ headers["x-absolutejs-timestamp"] = timestamp;
4631
+ headers["x-absolutejs-signature"] = await signHandoffBody({
4632
+ body,
4633
+ secret: options.signingSecret,
4634
+ timestamp
4635
+ });
4636
+ }
4637
+ const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
4638
+ const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
4639
+ try {
4640
+ const response = await fetchImpl(options.url, {
4641
+ body,
4642
+ headers,
4643
+ method: options.method ?? "POST",
4644
+ signal: controller?.signal
4645
+ });
4646
+ if (!response.ok) {
4647
+ return {
4648
+ deliveredTo: options.url,
4649
+ error: `Handoff delivery failed with response ${response.status}.`,
4650
+ status: "failed"
4651
+ };
4652
+ }
4653
+ return {
4654
+ deliveredAt: Date.now(),
4655
+ deliveredTo: options.url,
4656
+ status: "delivered"
4657
+ };
4658
+ } finally {
4659
+ if (timeout) {
4660
+ clearTimeout(timeout);
4661
+ }
4662
+ }
4663
+ },
4664
+ id: options.id,
4665
+ kind: options.kind ?? "webhook"
4666
+ });
4667
+ var escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
4668
+ var defaultTwilioTransferTwiML = (input) => {
4669
+ if (!input.target) {
4670
+ return "<Response><Hangup /></Response>";
4671
+ }
4672
+ return `<Response><Dial>${escapeXml(input.target)}</Dial></Response>`;
4673
+ };
4674
+ var resolveTwilioCallSid = async (resolver, input) => {
4675
+ if (typeof resolver === "function") {
4676
+ return resolver(input);
4677
+ }
4678
+ if (typeof resolver === "string" && resolver.length > 0) {
4679
+ return resolver;
4680
+ }
4681
+ const metadataSid = typeof input.metadata?.callSid === "string" ? input.metadata.callSid : undefined;
4682
+ const sessionMetadata = input.session.metadata && typeof input.session.metadata === "object" ? input.session.metadata : undefined;
4683
+ const sessionSid = typeof sessionMetadata?.callSid === "string" ? sessionMetadata.callSid : undefined;
4684
+ return metadataSid ?? sessionSid;
4685
+ };
4686
+ var createVoiceTwilioRedirectHandoffAdapter = (options) => ({
4687
+ actions: options.actions ?? ["transfer"],
4688
+ handoff: async (input) => {
4689
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4690
+ const callSid = await resolveTwilioCallSid(options.callSid, input);
4691
+ if (!callSid) {
4692
+ return {
4693
+ error: "Twilio handoff requires a callSid.",
4694
+ status: "failed"
4695
+ };
4696
+ }
4697
+ if (typeof fetchImpl !== "function") {
4698
+ return {
4699
+ error: "Twilio handoff failed: fetch is not available in this runtime.",
4700
+ status: "failed"
4701
+ };
4702
+ }
4703
+ const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(options.accountSid)}/Calls/${encodeURIComponent(callSid)}.json`;
4704
+ const body = new URLSearchParams({
4705
+ Twiml: await (options.buildTwiML?.(input) ?? defaultTwilioTransferTwiML(input))
4706
+ });
4707
+ const auth = btoa(`${options.accountSid}:${options.authToken}`);
4708
+ const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
4709
+ const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
4710
+ try {
4711
+ const response = await fetchImpl(url, {
4712
+ body,
4713
+ headers: {
4714
+ authorization: `Basic ${auth}`,
4715
+ "content-type": "application/x-www-form-urlencoded"
4716
+ },
4717
+ method: "POST",
4718
+ signal: controller?.signal
4719
+ });
4720
+ if (!response.ok) {
4721
+ return {
4722
+ deliveredTo: url,
4723
+ error: `Twilio handoff failed with response ${response.status}.`,
4724
+ status: "failed"
4725
+ };
4726
+ }
4727
+ return {
4728
+ deliveredAt: Date.now(),
4729
+ deliveredTo: url,
4730
+ metadata: {
4731
+ callSid
4732
+ },
4733
+ status: "delivered"
4734
+ };
4735
+ } finally {
4736
+ if (timeout) {
4737
+ clearTimeout(timeout);
4738
+ }
4739
+ }
4740
+ },
4741
+ id: options.id ?? "twilio-redirect",
4742
+ kind: "twilio-redirect"
4743
+ });
4744
+
3536
4745
  // src/logger.ts
3537
4746
  var noop2 = () => {};
3538
4747
  var createNoopLogger = () => ({
@@ -3725,6 +4934,7 @@ var pushCallLifecycleEvent = (session, input) => {
3725
4934
  }
3726
4935
  return lifecycle;
3727
4936
  };
4937
+ var getLatestCallLifecycleEvent = (session) => session.call?.events.at(-1);
3728
4938
  var createVoiceSession = (options) => {
3729
4939
  const logger = resolveLogger(options.logger);
3730
4940
  const reconnect = {
@@ -3825,6 +5035,64 @@ var createVoiceSession = (options) => {
3825
5035
  });
3826
5036
  }
3827
5037
  };
5038
+ const sendCallLifecycle = async (session) => {
5039
+ const event = getLatestCallLifecycleEvent(session);
5040
+ if (!event) {
5041
+ return;
5042
+ }
5043
+ await send({
5044
+ event,
5045
+ sessionId: options.id,
5046
+ type: "call_lifecycle"
5047
+ });
5048
+ };
5049
+ const runHandoff = async (input) => {
5050
+ const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
5051
+ action: input.action,
5052
+ context: options.context,
5053
+ metadata: input.metadata,
5054
+ reason: input.reason,
5055
+ result: input.result,
5056
+ session: input.session,
5057
+ target: input.target
5058
+ }) : undefined;
5059
+ if (queuedDelivery) {
5060
+ await options.handoff?.deliveryQueue?.set(queuedDelivery.id, queuedDelivery);
5061
+ }
5062
+ if (options.handoff?.enqueueOnly) {
5063
+ return;
5064
+ }
5065
+ const result = await deliverVoiceHandoff({
5066
+ config: options.handoff,
5067
+ handoff: {
5068
+ action: input.action,
5069
+ api,
5070
+ context: options.context,
5071
+ metadata: input.metadata,
5072
+ reason: input.reason,
5073
+ result: input.result,
5074
+ session: input.session,
5075
+ target: input.target
5076
+ }
5077
+ });
5078
+ if (!result) {
5079
+ return;
5080
+ }
5081
+ if (queuedDelivery) {
5082
+ const updatedDelivery = applyVoiceHandoffDeliveryResult(queuedDelivery, result);
5083
+ await options.handoff?.deliveryQueue?.set(updatedDelivery.id, updatedDelivery);
5084
+ }
5085
+ await appendTrace({
5086
+ metadata: input.metadata,
5087
+ payload: {
5088
+ ...result,
5089
+ reason: input.reason,
5090
+ target: input.target
5091
+ },
5092
+ session: input.session,
5093
+ type: "call.handoff"
5094
+ });
5095
+ };
3828
5096
  const readSession = async () => options.store.getOrCreate(options.id);
3829
5097
  const writeSession = async (mutate) => {
3830
5098
  const session = await options.store.getOrCreate(options.id);
@@ -4015,6 +5283,7 @@ var createVoiceSession = (options) => {
4015
5283
  await appendTrace({
4016
5284
  payload: {
4017
5285
  disposition,
5286
+ metadata: input.metadata,
4018
5287
  reason: input.reason,
4019
5288
  target: input.target,
4020
5289
  type: "end"
@@ -4022,6 +5291,7 @@ var createVoiceSession = (options) => {
4022
5291
  session,
4023
5292
  type: "call.lifecycle"
4024
5293
  });
5294
+ await sendCallLifecycle(session);
4025
5295
  await send({
4026
5296
  sessionId: options.id,
4027
5297
  type: "complete"
@@ -4101,6 +5371,15 @@ var createVoiceSession = (options) => {
4101
5371
  session,
4102
5372
  type: "call.lifecycle"
4103
5373
  });
5374
+ await sendCallLifecycle(session);
5375
+ await runHandoff({
5376
+ action: "transfer",
5377
+ metadata: input.metadata,
5378
+ reason: input.reason,
5379
+ result: input.result,
5380
+ session,
5381
+ target: input.target
5382
+ });
4104
5383
  await completeInternal(input.result, {
4105
5384
  disposition: "transferred",
4106
5385
  invokeOnComplete: false,
@@ -4126,6 +5405,14 @@ var createVoiceSession = (options) => {
4126
5405
  session,
4127
5406
  type: "call.lifecycle"
4128
5407
  });
5408
+ await sendCallLifecycle(session);
5409
+ await runHandoff({
5410
+ action: "escalate",
5411
+ metadata: input.metadata,
5412
+ reason: input.reason,
5413
+ result: input.result,
5414
+ session
5415
+ });
4129
5416
  await completeInternal(input.result, {
4130
5417
  disposition: "escalated",
4131
5418
  invokeOnComplete: false,
@@ -4148,6 +5435,13 @@ var createVoiceSession = (options) => {
4148
5435
  session,
4149
5436
  type: "call.lifecycle"
4150
5437
  });
5438
+ await sendCallLifecycle(session);
5439
+ await runHandoff({
5440
+ action: "no-answer",
5441
+ metadata: input?.metadata,
5442
+ result: input?.result,
5443
+ session
5444
+ });
4151
5445
  await completeInternal(input?.result, {
4152
5446
  disposition: "no-answer",
4153
5447
  invokeOnComplete: false,
@@ -4169,6 +5463,13 @@ var createVoiceSession = (options) => {
4169
5463
  session,
4170
5464
  type: "call.lifecycle"
4171
5465
  });
5466
+ await sendCallLifecycle(session);
5467
+ await runHandoff({
5468
+ action: "voicemail",
5469
+ metadata: input?.metadata,
5470
+ result: input?.result,
5471
+ session
5472
+ });
4172
5473
  await completeInternal(input?.result, {
4173
5474
  disposition: "voicemail",
4174
5475
  invokeOnComplete: false,
@@ -4955,6 +6256,7 @@ var createVoiceSession = (options) => {
4955
6256
  session,
4956
6257
  type: "call.lifecycle"
4957
6258
  });
6259
+ await sendCallLifecycle(session);
4958
6260
  }
4959
6261
  await send({
4960
6262
  sessionId: options.id,
@@ -5545,7 +6847,7 @@ var createVoiceCallReviewFromLiveTelephonyReport = (report, options = {}) => {
5545
6847
  }
5546
6848
  };
5547
6849
  };
5548
- var toErrorMessage = (error) => {
6850
+ var toErrorMessage2 = (error) => {
5549
6851
  if (typeof error === "string" && error.trim().length > 0) {
5550
6852
  return error;
5551
6853
  }
@@ -5632,7 +6934,7 @@ var createVoiceCallReviewRecorder = (options = {}) => {
5632
6934
  };
5633
6935
  },
5634
6936
  recordError: (error) => {
5635
- const message = toErrorMessage(error);
6937
+ const message = toErrorMessage2(error);
5636
6938
  errors.push(message);
5637
6939
  push("turn", "error", {
5638
6940
  reason: message
@@ -6341,7 +7643,7 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
6341
7643
  import { Buffer as Buffer2 } from "buffer";
6342
7644
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
6343
7645
  var VOICE_PCM_SAMPLE_RATE = 16000;
6344
- var escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
7646
+ var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
6345
7647
  var normalizeOnTurn = (handler) => {
6346
7648
  if (handler.length > 1) {
6347
7649
  const directHandler = handler;
@@ -6537,8 +7839,8 @@ var createTwilioSocketAdapter = (socket, getState) => ({
6537
7839
  }
6538
7840
  });
6539
7841
  var createTwilioVoiceResponse = (options) => {
6540
- const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml(name)}" value="${escapeXml(String(value))}" />`).join("");
6541
- return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml(options.streamUrl)}"${options.track ? ` track="${escapeXml(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
7842
+ const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml2(name)}" value="${escapeXml2(String(value))}" />`).join("");
7843
+ return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml2(options.streamUrl)}"${options.track ? ` track="${escapeXml2(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml2(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
6542
7844
  };
6543
7845
  var createTwilioMediaStreamBridge = (socket, options) => {
6544
7846
  const runtimePreset = resolveVoiceRuntimePreset(options.preset);
@@ -7208,6 +8510,7 @@ export {
7208
8510
  getDefaultVoiceDuplexBenchmarkScenarios,
7209
8511
  getDefaultTTSBenchmarkFixtures,
7210
8512
  evaluateSTTBenchmarkAcceptance,
8513
+ createVoiceProviderFailureSimulator,
7211
8514
  createVoiceCallReviewRecorder,
7212
8515
  createVoiceCallReviewFromLiveTelephonyReport,
7213
8516
  createTelephonyVoiceTestFixtures,