@vellumai/assistant 0.4.31 → 0.4.32

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 (121) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -7
  4. package/src/__tests__/anthropic-provider.test.ts +86 -1
  5. package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
  6. package/src/__tests__/checker.test.ts +37 -98
  7. package/src/__tests__/commit-message-enrichment-service.test.ts +15 -0
  8. package/src/__tests__/config-schema.test.ts +6 -5
  9. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  10. package/src/__tests__/daemon-server-session-init.test.ts +1 -19
  11. package/src/__tests__/followup-tools.test.ts +0 -30
  12. package/src/__tests__/gemini-provider.test.ts +79 -1
  13. package/src/__tests__/ipc-snapshot.test.ts +0 -4
  14. package/src/__tests__/managed-proxy-context.test.ts +163 -0
  15. package/src/__tests__/memory-lifecycle-e2e.test.ts +2 -2
  16. package/src/__tests__/memory-regressions.test.ts +6 -6
  17. package/src/__tests__/openai-provider.test.ts +82 -0
  18. package/src/__tests__/provider-fail-open-selection.test.ts +134 -1
  19. package/src/__tests__/provider-managed-proxy-integration.test.ts +269 -0
  20. package/src/__tests__/recurrence-types.test.ts +0 -15
  21. package/src/__tests__/schedule-tools.test.ts +28 -44
  22. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  23. package/src/__tests__/task-management-tools.test.ts +111 -0
  24. package/src/__tests__/twilio-config.test.ts +0 -3
  25. package/src/amazon/session.ts +30 -91
  26. package/src/calls/call-controller.ts +423 -571
  27. package/src/calls/finalize-call.ts +20 -0
  28. package/src/calls/relay-access-wait.ts +340 -0
  29. package/src/calls/relay-server.ts +267 -902
  30. package/src/calls/relay-setup-router.ts +307 -0
  31. package/src/calls/relay-verification.ts +280 -0
  32. package/src/calls/twilio-config.ts +1 -8
  33. package/src/calls/voice-control-protocol.ts +184 -0
  34. package/src/calls/voice-session-bridge.ts +1 -8
  35. package/src/config/agent-schema.ts +1 -1
  36. package/src/config/bundled-skills/followups/TOOLS.json +0 -4
  37. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  38. package/src/config/bundled-skills/schedule/TOOLS.json +2 -10
  39. package/src/config/core-schema.ts +1 -1
  40. package/src/config/env.ts +0 -10
  41. package/src/config/feature-flag-registry.json +1 -1
  42. package/src/config/loader.ts +19 -0
  43. package/src/config/schema.ts +2 -2
  44. package/src/daemon/handlers/session-history.ts +398 -0
  45. package/src/daemon/handlers/session-user-message.ts +982 -0
  46. package/src/daemon/handlers/sessions.ts +9 -1338
  47. package/src/daemon/ipc-contract/sessions.ts +0 -6
  48. package/src/daemon/ipc-contract-inventory.json +0 -1
  49. package/src/daemon/lifecycle.ts +0 -29
  50. package/src/home-base/app-link-store.ts +0 -7
  51. package/src/memory/conversation-attention-store.ts +1 -1
  52. package/src/memory/conversation-store.ts +0 -51
  53. package/src/memory/db-init.ts +5 -1
  54. package/src/memory/job-handlers/conflict.ts +24 -0
  55. package/src/memory/migrations/105-contacts-and-triage.ts +4 -7
  56. package/src/memory/migrations/134-contacts-notes-column.ts +50 -33
  57. package/src/memory/migrations/registry.ts +6 -0
  58. package/src/memory/recall-cache.ts +0 -5
  59. package/src/memory/schema/calls.ts +274 -0
  60. package/src/memory/schema/contacts.ts +125 -0
  61. package/src/memory/schema/conversations.ts +129 -0
  62. package/src/memory/schema/guardian.ts +172 -0
  63. package/src/memory/schema/index.ts +8 -0
  64. package/src/memory/schema/infrastructure.ts +205 -0
  65. package/src/memory/schema/memory-core.ts +196 -0
  66. package/src/memory/schema/notifications.ts +191 -0
  67. package/src/memory/schema/tasks.ts +78 -0
  68. package/src/memory/schema.ts +1 -1385
  69. package/src/memory/slack-thread-store.ts +0 -69
  70. package/src/notifications/decisions-store.ts +2 -105
  71. package/src/notifications/deliveries-store.ts +0 -11
  72. package/src/notifications/preferences-store.ts +1 -58
  73. package/src/permissions/checker.ts +6 -17
  74. package/src/providers/anthropic/client.ts +6 -2
  75. package/src/providers/gemini/client.ts +13 -2
  76. package/src/providers/managed-proxy/constants.ts +55 -0
  77. package/src/providers/managed-proxy/context.ts +77 -0
  78. package/src/providers/registry.ts +112 -0
  79. package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -23
  80. package/src/runtime/http-server.ts +83 -722
  81. package/src/runtime/http-types.ts +0 -16
  82. package/src/runtime/middleware/auth.ts +0 -12
  83. package/src/runtime/routes/app-routes.ts +33 -0
  84. package/src/runtime/routes/approval-routes.ts +32 -0
  85. package/src/runtime/routes/attachment-routes.ts +32 -0
  86. package/src/runtime/routes/brain-graph-routes.ts +27 -0
  87. package/src/runtime/routes/call-routes.ts +41 -0
  88. package/src/runtime/routes/channel-readiness-routes.ts +20 -0
  89. package/src/runtime/routes/channel-routes.ts +70 -0
  90. package/src/runtime/routes/contact-routes.ts +63 -0
  91. package/src/runtime/routes/conversation-attention-routes.ts +15 -0
  92. package/src/runtime/routes/conversation-routes.ts +190 -193
  93. package/src/runtime/routes/debug-routes.ts +15 -0
  94. package/src/runtime/routes/events-routes.ts +16 -0
  95. package/src/runtime/routes/global-search-routes.ts +15 -0
  96. package/src/runtime/routes/guardian-action-routes.ts +22 -0
  97. package/src/runtime/routes/guardian-bootstrap-routes.ts +20 -0
  98. package/src/runtime/routes/guardian-refresh-routes.ts +20 -0
  99. package/src/runtime/routes/identity-routes.ts +20 -0
  100. package/src/runtime/routes/inbound-message-handler.ts +8 -0
  101. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +6 -6
  102. package/src/runtime/routes/integration-routes.ts +83 -0
  103. package/src/runtime/routes/invite-routes.ts +31 -0
  104. package/src/runtime/routes/migration-routes.ts +30 -0
  105. package/src/runtime/routes/pairing-routes.ts +18 -0
  106. package/src/runtime/routes/secret-routes.ts +20 -0
  107. package/src/runtime/routes/surface-action-routes.ts +26 -0
  108. package/src/runtime/routes/trust-rules-routes.ts +31 -0
  109. package/src/runtime/routes/twilio-routes.ts +79 -0
  110. package/src/schedule/recurrence-types.ts +1 -11
  111. package/src/tools/followups/followup_create.ts +9 -3
  112. package/src/tools/mcp/mcp-tool-factory.ts +0 -17
  113. package/src/tools/memory/definitions.ts +0 -6
  114. package/src/tools/network/script-proxy/session-manager.ts +38 -3
  115. package/src/tools/schedule/create.ts +1 -3
  116. package/src/tools/schedule/update.ts +9 -6
  117. package/src/twitter/session.ts +29 -77
  118. package/src/util/cookie-session.ts +114 -0
  119. package/src/__tests__/conversation-routes.test.ts +0 -99
  120. package/src/__tests__/task-tools.test.ts +0 -685
  121. package/src/contacts/startup-migration.ts +0 -21
@@ -10,10 +10,8 @@ import { randomInt } from "node:crypto";
10
10
 
11
11
  import type { ServerWebSocket } from "bun";
12
12
 
13
- import { getConfig } from "../config/loader.js";
14
13
  import { resolveUserReference } from "../config/user-reference.js";
15
14
  import {
16
- findContactChannel,
17
15
  findGuardianForChannel,
18
16
  listGuardianChannels,
19
17
  } from "../contacts/contact-store.js";
@@ -26,50 +24,46 @@ import {
26
24
  import { getAssistantName } from "../daemon/identity-helpers.js";
27
25
  import { getCanonicalGuardianRequest } from "../memory/canonical-guardian-store.js";
28
26
  import * as conversationStore from "../memory/conversation-store.js";
29
- import { findActiveVoiceInvites } from "../memory/invite-store.js";
30
27
  import { revokeScopedApprovalGrantsForContext } from "../memory/scoped-approval-grants.js";
31
- import { emitNotificationSignal } from "../notifications/emit-signal.js";
32
28
  import { notifyGuardianOfAccessRequest } from "../runtime/access-request-helper.js";
33
29
  import {
34
30
  resolveActorTrust,
35
31
  toTrustContext,
36
32
  } from "../runtime/actor-trust-resolver.js";
37
33
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
38
- import {
39
- getGuardianBinding,
40
- getPendingChallenge,
41
- validateAndConsumeChallenge,
42
- } from "../runtime/channel-guardian-service.js";
34
+ import { getGuardianBinding } from "../runtime/channel-guardian-service.js";
43
35
  import {
44
36
  composeVerificationVoice,
45
37
  GUARDIAN_VERIFY_TEMPLATE_KEYS,
46
38
  } from "../runtime/guardian-verification-templates.js";
47
- import { redeemVoiceInviteCode } from "../runtime/invite-service.js";
48
39
  import { parseJsonSafe } from "../util/json.js";
49
40
  import { getLogger } from "../util/logger.js";
50
41
  import {
51
42
  getAccessRequestPollIntervalMs,
52
- getGuardianWaitUpdateInitialIntervalMs,
53
- getGuardianWaitUpdateInitialWindowMs,
54
- getGuardianWaitUpdateSteadyMaxIntervalMs,
55
- getGuardianWaitUpdateSteadyMinIntervalMs,
56
43
  getTtsPlaybackDelayMs,
57
44
  getUserConsultationTimeoutMs,
58
45
  } from "./call-constants.js";
59
46
  import { CallController } from "./call-controller.js";
60
- import { persistCallCompletionMessage } from "./call-conversation-messages.js";
61
47
  import { addPointerMessage, formatDuration } from "./call-pointer-messages.js";
62
- import {
63
- fireCallCompletionNotifier,
64
- fireCallTranscriptNotifier,
65
- } from "./call-state.js";
48
+ import { fireCallTranscriptNotifier } from "./call-state.js";
66
49
  import { isTerminalState } from "./call-state-machine.js";
67
50
  import {
68
- expirePendingQuestions,
69
51
  getCallSession,
70
52
  recordCallEvent,
71
53
  updateCallSession,
72
54
  } from "./call-store.js";
55
+ import { finalizeCall } from "./finalize-call.js";
56
+ import {
57
+ classifyWaitUtterance,
58
+ emitAccessRequestCallbackHandoff,
59
+ scheduleNextHeartbeat,
60
+ } from "./relay-access-wait.js";
61
+ import { routeSetup } from "./relay-setup-router.js";
62
+ import {
63
+ attemptGuardianCodeVerification,
64
+ attemptInviteCodeRedemption,
65
+ parseDigitsFromSpeech,
66
+ } from "./relay-verification.js";
73
67
  import {
74
68
  extractPromptSpeakerMetadata,
75
69
  type PromptSpeakerContext,
@@ -427,7 +421,7 @@ export class RelayConnection {
427
421
  // If the call was still in guardian-wait with callback opt-in, emit the
428
422
  // handoff notification before cleaning up wait state.
429
423
  if (this.accessRequestWaitActive && this.callbackOptIn) {
430
- this.emitAccessRequestCallbackHandoff("transport_closed");
424
+ this.emitAccessRequestCallbackHandoffForReason("transport_closed");
431
425
  }
432
426
 
433
427
  // Clean up access request wait state on disconnect to stop polling
@@ -502,8 +496,6 @@ export class RelayConnection {
502
496
  }
503
497
  }
504
498
 
505
- expirePendingQuestions(this.callSessionId);
506
-
507
499
  // Revoke any scoped approval grants bound to this call session.
508
500
  // Revoke by both callSessionId and conversationId because the
509
501
  // guardian-approval-interception minting path sets callSessionId: null
@@ -522,20 +514,7 @@ export class RelayConnection {
522
514
  );
523
515
  }
524
516
 
525
- persistCallCompletionMessage(
526
- session.conversationId,
527
- this.callSessionId,
528
- ).catch((err) => {
529
- log.error(
530
- {
531
- err,
532
- conversationId: session.conversationId,
533
- callSessionId: this.callSessionId,
534
- },
535
- "Failed to persist call completion message",
536
- );
537
- });
538
- fireCallCompletionNotifier(session.conversationId, this.callSessionId);
517
+ finalizeCall(this.callSessionId, session.conversationId);
539
518
  }
540
519
 
541
520
  // ── Private handlers ─────────────────────────────────────────────
@@ -551,349 +530,165 @@ export class RelayConnection {
551
530
  "ConversationRelay setup received",
552
531
  );
553
532
 
554
- // Store the callSid association on the call session
555
533
  const session = getCallSession(this.callSessionId);
556
- if (session) {
557
- const updates: Parameters<typeof updateCallSession>[1] = {
558
- providerCallSid: msg.callSid,
559
- };
560
- if (
561
- !isTerminalState(session.status) &&
562
- session.status !== "in_progress" &&
563
- session.status !== "waiting_on_user"
564
- ) {
565
- updates.status = "in_progress";
566
- if (!session.startedAt) {
567
- updates.startedAt = Date.now();
568
- }
569
- }
570
- updateCallSession(this.callSessionId, updates);
571
- }
534
+ this.recordSetupBookkeeping(session, msg);
572
535
 
573
- // Omit potentially sensitive keys from customParameters before persisting
574
- // to the call_events table. Only allow known-safe keys through.
575
- const safeCustomParameters = msg.customParameters
576
- ? Object.fromEntries(
577
- Object.entries(msg.customParameters).filter(
578
- ([key]) => !key.toLowerCase().includes("secret"),
579
- ),
580
- )
581
- : undefined;
582
-
583
- recordCallEvent(this.callSessionId, "call_connected", {
584
- callSid: msg.callSid,
536
+ const { outcome, resolved } = routeSetup({
537
+ callSessionId: this.callSessionId,
538
+ session,
585
539
  from: msg.from,
586
540
  to: msg.to,
587
- customParameters: safeCustomParameters,
541
+ customParameters: msg.customParameters,
588
542
  });
589
543
 
590
- // Inbound calls skip callee verification — verification is an
591
- // outbound-call concern where we need to confirm the callee's identity.
592
- // We use initiatedFromConversationId rather than task == null because
593
- // outbound calls always have an initiating conversation, while inbound
594
- // calls (created via createInboundVoiceSession) never do. Relying on
595
- // task == null is unreliable: task-less outbound sessions would
596
- // incorrectly bypass outbound verification.
597
- const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
598
- const isInbound = session?.initiatedFromConversationId == null;
599
-
600
- // Create and attach the session-backed voice controller. Seed guardian
601
- // actor context from the other party's identity + active binding so
602
- // first-turn behavior matches channel ingress semantics. For inbound
603
- // calls msg.from is the caller; for outbound calls msg.to is the
604
- // recipient (msg.from is the assistant's Twilio number).
605
- const otherPartyNumber = isInbound ? msg.from : msg.to;
606
- const initialActorTrust = resolveActorTrust({
607
- assistantId,
608
- sourceChannel: "voice",
609
- conversationExternalId: otherPartyNumber,
610
- actorExternalId: otherPartyNumber || undefined,
611
- });
612
544
  const initialTrustContext = toTrustContext(
613
- initialActorTrust,
614
- otherPartyNumber,
545
+ resolved.actorTrust,
546
+ resolved.otherPartyNumber,
615
547
  );
616
-
617
548
  const controller = new CallController(
618
549
  this.callSessionId,
619
550
  this,
620
551
  session?.task ?? null,
621
552
  {
622
553
  broadcast: globalBroadcast,
623
- assistantId,
554
+ assistantId: resolved.assistantId,
624
555
  trustContext: initialTrustContext,
625
556
  },
626
557
  );
627
558
  this.setController(controller);
628
559
 
629
- // Detect outbound guardian verification call from persisted call session
630
- // mode first (deterministic source of truth), with setup custom parameter
631
- // as secondary signal for backward compatibility and observability.
632
- const persistedMode = session?.callMode;
633
- const persistedGvSessionId = session?.guardianVerificationSessionId;
634
- const customParamGvSessionId =
635
- msg.customParameters?.guardianVerificationSessionId;
636
- const guardianVerificationSessionId =
637
- persistedGvSessionId ?? customParamGvSessionId;
638
-
639
- if (
640
- persistedMode === "guardian_verification" &&
641
- guardianVerificationSessionId
642
- ) {
643
- this.startOutboundGuardianVerification(
644
- assistantId,
645
- guardianVerificationSessionId,
646
- msg.to,
647
- );
648
- return;
649
- }
650
-
651
- // Secondary signal: custom parameter without persisted mode (pre-migration sessions)
652
- if (!persistedMode && customParamGvSessionId) {
653
- log.warn(
654
- {
655
- callSessionId: this.callSessionId,
656
- guardianVerificationSessionId: customParamGvSessionId,
657
- },
658
- "Guardian verification detected via setup custom parameter (no persisted call_mode) — entering verification path",
659
- );
660
- this.startOutboundGuardianVerification(
661
- assistantId,
662
- customParamGvSessionId,
663
- msg.to,
664
- );
665
- return;
666
- }
667
-
668
- const config = getConfig();
669
- const verificationConfig = config.calls.verification;
670
- if (!isInbound && verificationConfig.enabled) {
671
- await this.startVerification(session, verificationConfig);
672
- } else if (isInbound) {
673
- // ── Trusted-contact ACL enforcement for inbound voice ──
674
- // Resolve the caller's trust classification before allowing the call
675
- // to proceed. Guardian and trusted-contact callers pass through;
676
- // unknown callers are denied with deterministic voice copy and an
677
- // access request is created for the guardian — unless there is a
678
- // pending voice guardian challenge, in which case the caller is
679
- // expected to be unknown (no binding yet) and should enter the
680
- // verification flow.
681
- const actorTrust = resolveActorTrust({
682
- assistantId,
683
- sourceChannel: "voice",
684
- conversationExternalId: msg.from,
685
- actorExternalId: msg.from || undefined,
686
- });
687
-
688
- // Check for a pending voice guardian challenge before the ACL deny
689
- // gate. An unknown caller with a pending challenge is expected —
690
- // they need to complete verification to establish a binding.
691
- const pendingChallenge = getPendingChallenge(assistantId, "voice");
692
-
693
- if (actorTrust.trustClass === "unknown" && !pendingChallenge) {
694
- // Before entering the name capture flow, check if there is an
695
- // active voice invite bound to the caller's phone number. If so,
696
- // enter the invite redemption subflow instead.
697
- let voiceInvites: ReturnType<typeof findActiveVoiceInvites> = [];
698
- try {
699
- voiceInvites = findActiveVoiceInvites({
700
- assistantId,
701
- expectedExternalUserId: msg.from,
702
- });
703
- } catch (err) {
704
- log.warn(
705
- { err, callSessionId: this.callSessionId },
706
- "Failed to check voice invites for unknown caller",
707
- );
708
- }
709
-
710
- // Exclude invites that are past their expiresAt even if the DB
711
- // status hasn't been lazily flipped to 'expired' yet.
712
- const now = Date.now();
713
- const nonExpiredInvites = voiceInvites.filter(
714
- (i) => !i.expiresAt || i.expiresAt > now,
560
+ switch (outcome.action) {
561
+ case "outbound_guardian_verification":
562
+ this.startOutboundGuardianVerification(
563
+ outcome.assistantId,
564
+ outcome.sessionId,
565
+ outcome.toNumber,
715
566
  );
716
-
717
- // Blocked members get immediate denial — the guardian already made
718
- // an explicit decision to block them. This must be checked before
719
- // invite redemption so a blocked caller cannot bypass the block by
720
- // redeeming an active invite.
721
- if (actorTrust.memberRecord?.channel.status === "blocked") {
722
- log.info(
723
- {
724
- callSessionId: this.callSessionId,
725
- from: msg.from,
726
- trustClass: actorTrust.trustClass,
727
- },
728
- "Inbound voice ACL: blocked caller denied",
729
- );
730
-
731
- recordCallEvent(this.callSessionId, "inbound_acl_denied", {
732
- from: msg.from,
733
- trustClass: actorTrust.trustClass,
734
- denialReason: actorTrust.denialReason,
735
- });
736
-
737
- this.sendTextToken(
738
- "This number is not authorized to use this assistant.",
739
- true,
740
- );
741
-
742
- this.connectionState = "disconnecting";
743
-
744
- updateCallSession(this.callSessionId, {
745
- status: "failed",
746
- endedAt: Date.now(),
747
- lastError: "Inbound voice ACL: caller blocked",
748
- });
749
-
750
- setTimeout(() => {
751
- this.endSession("Inbound voice ACL denied — blocked");
752
- }, getTtsPlaybackDelayMs());
753
- return;
754
- }
755
-
756
- if (nonExpiredInvites.length > 0) {
757
- // Use the first matching invite's metadata for personalized prompts
758
- const matchedInvite = nonExpiredInvites[0];
759
- log.info(
760
- { callSessionId: this.callSessionId, from: msg.from },
761
- "Inbound voice ACL: unknown caller has active voice invite — entering redemption flow",
762
- );
763
- this.startInviteRedemption(
764
- assistantId,
765
- msg.from,
766
- matchedInvite.friendName,
767
- matchedInvite.guardianName,
768
- );
769
- return;
770
- }
771
-
772
- // Unknown/revoked/pending callers enter the name capture + guardian
773
- // approval wait flow instead of being hard-rejected.
774
- log.info(
775
- {
776
- callSessionId: this.callSessionId,
777
- from: msg.from,
778
- trustClass: actorTrust.trustClass,
779
- },
780
- "Inbound voice ACL: unknown caller — entering name capture flow",
567
+ return;
568
+ case "callee_verification":
569
+ await this.startVerification(session, outcome.verificationConfig);
570
+ return;
571
+ case "deny":
572
+ this.denyInboundCall(msg.from, resolved, outcome);
573
+ return;
574
+ case "invite_redemption":
575
+ this.startInviteRedemption(
576
+ outcome.assistantId,
577
+ outcome.fromNumber,
578
+ outcome.friendName,
579
+ outcome.guardianName,
781
580
  );
782
-
581
+ return;
582
+ case "name_capture":
783
583
  recordCallEvent(
784
584
  this.callSessionId,
785
585
  "inbound_acl_name_capture_started",
786
586
  {
787
587
  from: msg.from,
788
- trustClass: actorTrust.trustClass,
588
+ trustClass: resolved.actorTrust.trustClass,
789
589
  },
790
590
  );
791
-
792
- this.startNameCapture(assistantId, msg.from);
591
+ this.startNameCapture(outcome.assistantId, outcome.fromNumber);
793
592
  return;
794
- }
795
-
796
- // Members with policy: 'deny' have status: 'active' so resolveActorTrust
797
- // classifies them as trusted_contact, but the guardian has explicitly
798
- // denied their access. Block them the same way the text-channel path does.
799
- if (actorTrust.memberRecord?.channel.policy === "deny") {
800
- log.info(
801
- {
802
- callSessionId: this.callSessionId,
803
- from: msg.from,
804
- channelId: actorTrust.memberRecord.channel.id,
805
- trustClass: actorTrust.trustClass,
806
- },
807
- "Inbound voice ACL: member policy deny",
808
- );
809
-
810
- recordCallEvent(this.callSessionId, "inbound_acl_denied", {
811
- from: msg.from,
812
- trustClass: actorTrust.trustClass,
813
- channelId: actorTrust.memberRecord.channel.id,
814
- memberPolicy: actorTrust.memberRecord.channel.policy,
815
- });
816
-
817
- this.sendTextToken(
818
- "This number is not authorized to use this assistant.",
819
- true,
593
+ case "guardian_verification":
594
+ if (
595
+ resolved.actorTrust.memberRecord &&
596
+ (resolved.actorTrust.trustClass === "guardian" ||
597
+ resolved.actorTrust.trustClass === "trusted_contact")
598
+ ) {
599
+ touchContactInteraction(resolved.actorTrust.memberRecord.contact.id);
600
+ }
601
+ if (this.controller && resolved.actorTrust.trustClass !== "unknown") {
602
+ this.controller.setTrustContext(
603
+ toTrustContext(resolved.actorTrust, msg.from),
604
+ );
605
+ }
606
+ this.startInboundGuardianVerification(
607
+ outcome.assistantId,
608
+ outcome.fromNumber,
820
609
  );
821
-
822
- this.connectionState = "disconnecting";
823
-
824
- updateCallSession(this.callSessionId, {
825
- status: "failed",
826
- endedAt: Date.now(),
827
- lastError: "Inbound voice ACL: member policy deny",
828
- });
829
-
830
- setTimeout(() => {
831
- this.endSession("Inbound voice ACL: member policy deny");
832
- }, getTtsPlaybackDelayMs());
833
610
  return;
834
- }
835
-
836
- // Members with policy: 'escalate' require guardian approval, but a live
837
- // voice call cannot be paused for async approval. Fail-closed by denying
838
- // the call with an appropriate message — mirrors the deny block above.
839
- if (actorTrust.memberRecord?.channel.policy === "escalate") {
840
- log.info(
841
- {
842
- callSessionId: this.callSessionId,
843
- from: msg.from,
844
- channelId: actorTrust.memberRecord.channel.id,
845
- trustClass: actorTrust.trustClass,
846
- },
847
- "Inbound voice ACL: member policy escalate — cannot hold live call for guardian approval",
848
- );
849
-
850
- recordCallEvent(this.callSessionId, "inbound_acl_denied", {
851
- from: msg.from,
852
- trustClass: actorTrust.trustClass,
853
- channelId: actorTrust.memberRecord.channel.id,
854
- memberPolicy: actorTrust.memberRecord.channel.policy,
855
- });
856
-
857
- this.sendTextToken(
858
- "This number requires guardian approval for calls. Please have the account guardian update your permissions.",
859
- true,
860
- );
861
-
862
- this.connectionState = "disconnecting";
863
-
864
- updateCallSession(this.callSessionId, {
865
- status: "failed",
866
- endedAt: Date.now(),
867
- lastError:
868
- "Inbound voice ACL: member policy escalate — voice calls cannot await guardian approval",
869
- });
870
-
871
- setTimeout(() => {
872
- this.endSession("Inbound voice ACL: member policy escalate");
873
- }, getTtsPlaybackDelayMs());
611
+ case "normal_call":
612
+ if (outcome.isInbound) {
613
+ if (
614
+ resolved.actorTrust.memberRecord &&
615
+ (resolved.actorTrust.trustClass === "guardian" ||
616
+ resolved.actorTrust.trustClass === "trusted_contact")
617
+ ) {
618
+ touchContactInteraction(
619
+ resolved.actorTrust.memberRecord.contact.id,
620
+ );
621
+ }
622
+ if (this.controller && resolved.actorTrust.trustClass !== "unknown") {
623
+ this.controller.setTrustContext(
624
+ toTrustContext(resolved.actorTrust, msg.from),
625
+ );
626
+ }
627
+ }
628
+ this.startNormalCallFlow(controller, outcome.isInbound);
874
629
  return;
875
- }
630
+ }
631
+ }
876
632
 
877
- // Guardian and trusted-contact callers proceed normally.
878
- if (actorTrust.memberRecord) {
879
- touchContactInteraction(actorTrust.memberRecord.contact.id);
633
+ /** Bookkeeping side-effects that run on every setup regardless of routing outcome. */
634
+ private recordSetupBookkeeping(
635
+ session: ReturnType<typeof getCallSession>,
636
+ msg: RelaySetupMessage,
637
+ ): void {
638
+ if (session) {
639
+ const updates: Parameters<typeof updateCallSession>[1] = {
640
+ providerCallSid: msg.callSid,
641
+ };
642
+ if (
643
+ !isTerminalState(session.status) &&
644
+ session.status !== "in_progress" &&
645
+ session.status !== "waiting_on_user"
646
+ ) {
647
+ updates.status = "in_progress";
648
+ if (!session.startedAt) updates.startedAt = Date.now();
880
649
  }
650
+ updateCallSession(this.callSessionId, updates);
651
+ }
881
652
 
882
- // Update the controller's guardian context with the trust-resolved
883
- // context so downstream policy gates have accurate actor metadata.
884
- if (this.controller && actorTrust.trustClass !== "unknown") {
885
- const resolvedTrustContext = toTrustContext(actorTrust, msg.from);
886
- this.controller.setTrustContext(resolvedTrustContext);
887
- }
653
+ const safeCustomParameters = msg.customParameters
654
+ ? Object.fromEntries(
655
+ Object.entries(msg.customParameters).filter(
656
+ ([key]) => !key.toLowerCase().includes("secret"),
657
+ ),
658
+ )
659
+ : undefined;
888
660
 
889
- if (pendingChallenge) {
890
- this.startInboundGuardianVerification(assistantId, msg.from);
891
- } else {
892
- this.startNormalCallFlow(controller, true);
893
- }
894
- } else {
895
- this.startNormalCallFlow(controller, false);
896
- }
661
+ recordCallEvent(this.callSessionId, "call_connected", {
662
+ callSid: msg.callSid,
663
+ from: msg.from,
664
+ to: msg.to,
665
+ customParameters: safeCustomParameters,
666
+ });
667
+ }
668
+
669
+ /** Deny an inbound call with a TTS message and schedule disconnect. */
670
+ private denyInboundCall(
671
+ from: string,
672
+ resolved: import("./relay-setup-router.js").SetupResolved,
673
+ outcome: { message: string; logReason: string },
674
+ ): void {
675
+ recordCallEvent(this.callSessionId, "inbound_acl_denied", {
676
+ from,
677
+ trustClass: resolved.actorTrust.trustClass,
678
+ denialReason: resolved.actorTrust.denialReason,
679
+ channelId: resolved.actorTrust.memberRecord?.channel.id,
680
+ memberPolicy: resolved.actorTrust.memberRecord?.channel.policy,
681
+ });
682
+ this.sendTextToken(outcome.message, true);
683
+ this.connectionState = "disconnecting";
684
+ updateCallSession(this.callSessionId, {
685
+ status: "failed",
686
+ endedAt: Date.now(),
687
+ lastError: outcome.logReason,
688
+ });
689
+ setTimeout(() => {
690
+ this.endSession(outcome.logReason);
691
+ }, getTtsPlaybackDelayMs());
897
692
  }
898
693
 
899
694
  /**
@@ -1127,59 +922,11 @@ export class RelayConnection {
1127
922
  }
1128
923
 
1129
924
  /**
1130
- * Extract digit characters from a speech transcript. Recognizes both
1131
- * raw digit characters ("1 2 3") and spoken number words ("one two three").
1132
- */
1133
- private static parseDigitsFromSpeech(transcript: string): string {
1134
- const wordToDigit: Record<string, string> = {
1135
- zero: "0",
1136
- oh: "0",
1137
- o: "0",
1138
- one: "1",
1139
- won: "1",
1140
- two: "2",
1141
- too: "2",
1142
- to: "2",
1143
- three: "3",
1144
- four: "4",
1145
- for: "4",
1146
- fore: "4",
1147
- five: "5",
1148
- six: "6",
1149
- seven: "7",
1150
- eight: "8",
1151
- ate: "8",
1152
- nine: "9",
1153
- };
1154
-
1155
- const digits: string[] = [];
1156
- const lower = transcript.toLowerCase();
1157
-
1158
- // Split on whitespace and non-alphanumeric boundaries
1159
- const tokens = lower.split(/[\s,.\-;:!?]+/);
1160
- for (const token of tokens) {
1161
- if (/^\d$/.test(token)) {
1162
- digits.push(token);
1163
- } else if (wordToDigit[token]) {
1164
- digits.push(wordToDigit[token]);
1165
- } else if (/^\d+$/.test(token)) {
1166
- // Multi-digit number like "123456" — split into individual digits
1167
- digits.push(...token.split(""));
1168
- }
1169
- }
1170
-
1171
- return digits.join("");
1172
- }
1173
-
1174
- /**
1175
- * Attempt to validate an entered code against the pending voice guardian
1176
- * challenge via validateAndConsumeChallenge. On success, binds the
1177
- * guardian and transitions appropriately:
1178
- * - Inbound: transitions to normal call flow
1179
- * - Outbound: plays success template and ends the call
1180
- * On failure, enforces max attempts and terminates the call if exhausted.
925
+ * Validate an entered code against the pending voice guardian challenge.
926
+ * Delegates to the extracted attemptGuardianCodeVerification() and
927
+ * interprets the structured result to drive side-effects.
1181
928
  */
1182
- private attemptGuardianCodeVerification(enteredCode: string): void {
929
+ private handleGuardianCodeVerificationResult(enteredCode: string): void {
1183
930
  if (
1184
931
  !this.guardianChallengeAssistantId ||
1185
932
  !this.guardianVerificationFromNumber
@@ -1188,27 +935,26 @@ export class RelayConnection {
1188
935
  }
1189
936
 
1190
937
  const isOutbound = this.outboundGuardianVerificationSessionId != null;
1191
- const codeDigits = this.verificationCodeLength;
938
+ const assistantId = this.guardianChallengeAssistantId;
939
+ const fromNumber = this.guardianVerificationFromNumber;
1192
940
 
1193
- const result = validateAndConsumeChallenge(
1194
- this.guardianChallengeAssistantId,
1195
- "voice",
941
+ const result = attemptGuardianCodeVerification({
942
+ guardianChallengeAssistantId: assistantId,
943
+ guardianVerificationFromNumber: fromNumber,
1196
944
  enteredCode,
1197
- this.guardianVerificationFromNumber,
1198
- this.guardianVerificationFromNumber,
1199
- );
945
+ isOutbound,
946
+ codeDigits: this.verificationCodeLength,
947
+ verificationAttempts: this.verificationAttempts,
948
+ verificationMaxAttempts: this.verificationMaxAttempts,
949
+ });
1200
950
 
1201
- if (result.success) {
951
+ if (result.outcome === "success") {
1202
952
  this.connectionState = "connected";
1203
953
  this.guardianVerificationActive = false;
1204
954
  this.verificationAttempts = 0;
1205
955
  this.dtmfBuffer = "";
1206
956
 
1207
- const eventName = isOutbound
1208
- ? "outbound_guardian_voice_verification_succeeded"
1209
- : "guardian_voice_verification_succeeded";
1210
-
1211
- recordCallEvent(this.callSessionId, eventName, {
957
+ recordCallEvent(this.callSessionId, result.eventName, {
1212
958
  verificationType: result.verificationType,
1213
959
  });
1214
960
  log.info(
@@ -1218,65 +964,36 @@ export class RelayConnection {
1218
964
 
1219
965
  // Create the guardian binding now that verification succeeded.
1220
966
  if (result.verificationType === "guardian") {
1221
- const existingBinding = getGuardianBinding(
1222
- this.guardianChallengeAssistantId,
1223
- "voice",
1224
- );
1225
- if (
1226
- existingBinding &&
1227
- existingBinding.guardianExternalUserId !==
1228
- this.guardianVerificationFromNumber
1229
- ) {
967
+ if (result.bindingConflict) {
1230
968
  log.warn(
1231
969
  {
1232
970
  callSessionId: this.callSessionId,
1233
- existingGuardian: existingBinding.guardianExternalUserId,
971
+ existingGuardian: result.bindingConflict.existingGuardian,
1234
972
  },
1235
973
  "Guardian binding conflict: another user already holds the voice binding",
1236
974
  );
1237
975
  } else {
1238
- revokeGuardianBinding(this.guardianChallengeAssistantId, "voice");
1239
-
1240
- // Unify all channel bindings onto the canonical (vellum) principal
1241
- const vellumBinding = getGuardianBinding(
1242
- this.guardianChallengeAssistantId,
1243
- "vellum",
1244
- );
1245
- const canonicalPrincipal =
1246
- vellumBinding?.guardianPrincipalId ??
1247
- this.guardianVerificationFromNumber;
1248
-
976
+ revokeGuardianBinding(assistantId, "voice");
1249
977
  createGuardianBinding({
1250
- assistantId: this.guardianChallengeAssistantId,
978
+ assistantId,
1251
979
  channel: "voice",
1252
- guardianExternalUserId: this.guardianVerificationFromNumber,
1253
- guardianDeliveryChatId: this.guardianVerificationFromNumber,
1254
- guardianPrincipalId: canonicalPrincipal,
980
+ guardianExternalUserId: fromNumber,
981
+ guardianDeliveryChatId: fromNumber,
982
+ guardianPrincipalId: result.canonicalPrincipal!,
1255
983
  verifiedVia: "challenge",
1256
984
  });
1257
985
  }
1258
986
  }
1259
987
 
1260
988
  if (isOutbound) {
1261
- // Outbound guardian verification: play success and hang up.
1262
- // There is no normal conversation to transition to.
1263
- // Set disconnecting to ignore any further DTMF/speech input
1264
- // during the brief delay before the session ends.
1265
989
  this.connectionState = "disconnecting";
1266
-
1267
- const successText = composeVerificationVoice(
1268
- GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_SUCCESS,
1269
- { codeDigits },
1270
- );
1271
- this.sendTextToken(successText, true);
990
+ this.sendTextToken(result.ttsMessage!, true);
1272
991
 
1273
992
  updateCallSession(this.callSessionId, {
1274
993
  status: "completed",
1275
994
  endedAt: Date.now(),
1276
995
  });
1277
996
 
1278
- // Emit a pointer message to the origin conversation so the
1279
- // requesting chat sees a deterministic completion notice.
1280
997
  const successSession = getCallSession(this.callSessionId);
1281
998
  if (successSession?.initiatedFromConversationId) {
1282
999
  addPointerMessage(
@@ -1299,174 +1016,92 @@ export class RelayConnection {
1299
1016
  this.endSession("Verified — guardian challenge passed");
1300
1017
  }, getTtsPlaybackDelayMs());
1301
1018
  } else if (result.verificationType === "trusted_contact") {
1302
- // Inbound trusted-contact verification: activate and continue
1303
- // the live call with the shared handoff primitive.
1304
1019
  this.continueCallAfterTrustedContactActivation({
1305
- assistantId: this.guardianChallengeAssistantId,
1306
- fromNumber: this.guardianVerificationFromNumber,
1020
+ assistantId,
1021
+ fromNumber,
1307
1022
  });
1308
1023
  } else {
1309
- // Inbound guardian verification: create/update binding, then proceed
1310
- // to normal call flow. Mirrors the binding creation logic in
1311
- // verification-intercept.ts for the inbound channel path.
1312
- const guardianAssistantId = this.guardianChallengeAssistantId;
1313
- const callerNumber = this.guardianVerificationFromNumber;
1314
-
1315
- const existingBinding = getGuardianBinding(
1316
- guardianAssistantId,
1317
- "voice",
1318
- );
1319
- if (
1320
- existingBinding &&
1321
- existingBinding.guardianExternalUserId !== callerNumber
1322
- ) {
1323
- log.warn(
1324
- {
1325
- sourceChannel: "voice",
1326
- existingGuardian: existingBinding.guardianExternalUserId,
1327
- },
1328
- "Guardian binding conflict: another user already holds the voice channel binding",
1329
- );
1330
- } else {
1331
- revokeGuardianBinding(guardianAssistantId, "voice");
1332
-
1333
- // Resolve canonical principal from the vellum channel binding
1334
- // so all channel bindings share a single principal identity.
1335
- const vellumBinding = getGuardianBinding(
1336
- guardianAssistantId,
1337
- "vellum",
1338
- );
1339
- const canonicalPrincipal =
1340
- vellumBinding?.guardianPrincipalId ?? callerNumber;
1341
-
1342
- createGuardianBinding({
1343
- assistantId: guardianAssistantId,
1344
- channel: "voice",
1345
- guardianExternalUserId: callerNumber,
1346
- guardianDeliveryChatId: callerNumber,
1347
- guardianPrincipalId: canonicalPrincipal,
1348
- verifiedVia: "challenge",
1349
- });
1350
- }
1351
-
1024
+ // Inbound guardian verification: binding already handled above,
1025
+ // proceed to normal call flow.
1352
1026
  if (this.controller) {
1353
1027
  const verifiedActorTrust = resolveActorTrust({
1354
- assistantId: guardianAssistantId,
1028
+ assistantId,
1355
1029
  sourceChannel: "voice",
1356
- conversationExternalId: callerNumber,
1357
- actorExternalId: callerNumber,
1030
+ conversationExternalId: fromNumber,
1031
+ actorExternalId: fromNumber,
1358
1032
  });
1359
1033
  this.controller.setTrustContext(
1360
- toTrustContext(verifiedActorTrust, callerNumber),
1034
+ toTrustContext(verifiedActorTrust, fromNumber),
1361
1035
  );
1362
1036
  this.startNormalCallFlow(this.controller, true);
1363
1037
  }
1364
1038
  }
1365
- } else {
1366
- this.verificationAttempts++;
1039
+ } else if (result.outcome === "failure") {
1040
+ this.guardianVerificationActive = false;
1041
+ this.verificationAttempts = result.attempts;
1367
1042
 
1368
- if (this.verificationAttempts >= this.verificationMaxAttempts) {
1369
- // Immediately deactivate verification so DTMF/speech input during
1370
- // the goodbye window doesn't trigger more verification attempts.
1371
- this.guardianVerificationActive = false;
1043
+ recordCallEvent(this.callSessionId, result.eventName, {
1044
+ attempts: result.attempts,
1045
+ });
1046
+ log.warn(
1047
+ {
1048
+ callSessionId: this.callSessionId,
1049
+ attempts: result.attempts,
1050
+ isOutbound,
1051
+ },
1052
+ "Guardian voice verification failed — max attempts reached",
1053
+ );
1372
1054
 
1373
- const failEventName = isOutbound
1374
- ? "outbound_guardian_voice_verification_failed"
1375
- : "guardian_voice_verification_failed";
1055
+ this.sendTextToken(result.ttsMessage, true);
1376
1056
 
1377
- recordCallEvent(this.callSessionId, failEventName, {
1378
- attempts: this.verificationAttempts,
1379
- });
1380
- log.warn(
1381
- {
1382
- callSessionId: this.callSessionId,
1383
- attempts: this.verificationAttempts,
1384
- isOutbound,
1385
- },
1386
- "Guardian voice verification failed — max attempts reached",
1387
- );
1388
-
1389
- const failureText = isOutbound
1390
- ? composeVerificationVoice(
1391
- GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_FAILURE,
1392
- { codeDigits },
1393
- )
1394
- : "Verification failed. Goodbye.";
1395
- this.sendTextToken(failureText, true);
1057
+ updateCallSession(this.callSessionId, {
1058
+ status: "failed",
1059
+ endedAt: Date.now(),
1060
+ lastError: "Guardian voice verification failed — max attempts exceeded",
1061
+ });
1396
1062
 
1397
- updateCallSession(this.callSessionId, {
1398
- status: "failed",
1399
- endedAt: Date.now(),
1400
- lastError:
1401
- "Guardian voice verification failed — max attempts exceeded",
1402
- });
1063
+ const failSession = getCallSession(this.callSessionId);
1064
+ if (failSession) {
1065
+ finalizeCall(this.callSessionId, failSession.conversationId);
1403
1066
 
1404
- const failSession = getCallSession(this.callSessionId);
1405
- if (failSession) {
1406
- expirePendingQuestions(this.callSessionId);
1407
- persistCallCompletionMessage(
1408
- failSession.conversationId,
1409
- this.callSessionId,
1067
+ if (isOutbound && failSession.initiatedFromConversationId) {
1068
+ addPointerMessage(
1069
+ failSession.initiatedFromConversationId,
1070
+ "guardian_verification_failed",
1071
+ failSession.toNumber,
1072
+ {
1073
+ channel: "voice",
1074
+ reason: "Max verification attempts exceeded",
1075
+ },
1410
1076
  ).catch((err) => {
1411
- log.error(
1077
+ log.warn(
1412
1078
  {
1079
+ conversationId: failSession.initiatedFromConversationId,
1413
1080
  err,
1414
- conversationId: failSession.conversationId,
1415
- callSessionId: this.callSessionId,
1416
1081
  },
1417
- "Failed to persist call completion message",
1082
+ "Skipping pointer write origin conversation may no longer exist",
1418
1083
  );
1419
1084
  });
1420
- fireCallCompletionNotifier(
1421
- failSession.conversationId,
1422
- this.callSessionId,
1423
- );
1424
-
1425
- // Emit a pointer message to the origin conversation so the
1426
- // requesting chat sees a deterministic failure notice.
1427
- if (isOutbound && failSession.initiatedFromConversationId) {
1428
- addPointerMessage(
1429
- failSession.initiatedFromConversationId,
1430
- "guardian_verification_failed",
1431
- failSession.toNumber,
1432
- {
1433
- channel: "voice",
1434
- reason: "Max verification attempts exceeded",
1435
- },
1436
- ).catch((err) => {
1437
- log.warn(
1438
- {
1439
- conversationId: failSession.initiatedFromConversationId,
1440
- err,
1441
- },
1442
- "Skipping pointer write — origin conversation may no longer exist",
1443
- );
1444
- });
1445
- }
1446
1085
  }
1086
+ }
1447
1087
 
1448
- setTimeout(() => {
1449
- this.endSession("Verification failed — challenge rejected");
1450
- }, getTtsPlaybackDelayMs());
1451
- } else {
1452
- const retryText = isOutbound
1453
- ? composeVerificationVoice(
1454
- GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_RETRY,
1455
- { codeDigits },
1456
- )
1457
- : "That code was incorrect. Please try again.";
1088
+ setTimeout(() => {
1089
+ this.endSession("Verification failed — challenge rejected");
1090
+ }, getTtsPlaybackDelayMs());
1091
+ } else {
1092
+ // retry
1093
+ this.verificationAttempts = result.attempt;
1458
1094
 
1459
- log.info(
1460
- {
1461
- callSessionId: this.callSessionId,
1462
- attempt: this.verificationAttempts,
1463
- maxAttempts: this.verificationMaxAttempts,
1464
- isOutbound,
1465
- },
1466
- "Guardian voice verification attempt failed — retrying",
1467
- );
1468
- this.sendTextToken(retryText, true);
1469
- }
1095
+ log.info(
1096
+ {
1097
+ callSessionId: this.callSessionId,
1098
+ attempt: result.attempt,
1099
+ maxAttempts: result.maxAttempts,
1100
+ isOutbound,
1101
+ },
1102
+ "Guardian voice verification attempt failed — retrying",
1103
+ );
1104
+ this.sendTextToken(result.ttsMessage, true);
1470
1105
  }
1471
1106
  }
1472
1107
 
@@ -1794,7 +1429,7 @@ export class RelayConnection {
1794
1429
  */
1795
1430
  private handleAccessRequestTimeout(): void {
1796
1431
  // Emit callback handoff notification before clearing wait state
1797
- this.emitAccessRequestCallbackHandoff("timeout");
1432
+ this.emitAccessRequestCallbackHandoffForReason("timeout");
1798
1433
 
1799
1434
  this.clearAccessRequestWait();
1800
1435
 
@@ -1832,118 +1467,20 @@ export class RelayConnection {
1832
1467
  }, getTtsPlaybackDelayMs());
1833
1468
  }
1834
1469
 
1835
- /**
1836
- * Emit a callback handoff notification to the guardian when the caller
1837
- * opted into a callback during guardian wait but the wait ended without
1838
- * resolution (timeout or transport close).
1839
- *
1840
- * Idempotent: uses callbackHandoffNotified guard + deterministic dedupeKey
1841
- * to ensure at most one notification per call/request.
1842
- */
1843
- private emitAccessRequestCallbackHandoff(
1470
+ private emitAccessRequestCallbackHandoffForReason(
1844
1471
  reason: "timeout" | "transport_closed",
1845
1472
  ): void {
1846
- if (!this.callbackOptIn) return;
1847
- if (!this.accessRequestId) return;
1848
- if (this.callbackHandoffNotified) return;
1849
-
1850
- this.callbackHandoffNotified = true;
1851
-
1852
- const assistantId =
1853
- this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
1854
- const fromNumber = this.accessRequestFromNumber ?? null;
1855
-
1856
- // Resolve canonical request for requestCode and conversationId
1857
- const canonicalRequest = this.accessRequestId
1858
- ? getCanonicalGuardianRequest(this.accessRequestId)
1859
- : null;
1860
-
1861
- // Resolve trusted-contact member reference when possible
1862
- let requesterMemberId: string | null = null;
1863
- if (fromNumber) {
1864
- try {
1865
- const contactResult = findContactChannel({
1866
- channelType: "voice",
1867
- externalUserId: fromNumber,
1868
- externalChatId: fromNumber,
1869
- });
1870
- if (
1871
- contactResult &&
1872
- contactResult.channel.status === "active" &&
1873
- contactResult.channel.policy === "allow"
1874
- ) {
1875
- requesterMemberId = contactResult.channel.id;
1876
- }
1877
- } catch (err) {
1878
- log.warn(
1879
- { err, callSessionId: this.callSessionId },
1880
- "Failed to resolve member for callback handoff",
1881
- );
1882
- }
1883
- }
1884
-
1885
- const dedupeKey = `access-request-callback-handoff:${this.accessRequestId}`;
1886
- const sourceSessionId =
1887
- canonicalRequest?.conversationId ??
1888
- `access-req-callback-${this.accessRequestId}`;
1889
-
1890
- void emitNotificationSignal({
1891
- sourceEventName: "ingress.access_request.callback_handoff",
1892
- sourceChannel: "voice",
1893
- sourceSessionId,
1894
- assistantId,
1895
- attentionHints: {
1896
- requiresAction: false,
1897
- urgency: "medium",
1898
- isAsyncBackground: true,
1899
- visibleInSourceNow: false,
1900
- },
1901
- contextPayload: {
1902
- requestId: this.accessRequestId,
1903
- requestCode: canonicalRequest?.requestCode ?? null,
1904
- callSessionId: this.callSessionId,
1905
- sourceChannel: "voice",
1906
- reason,
1907
- callbackOptIn: true,
1908
- callerPhoneNumber: fromNumber,
1909
- callerName: this.accessRequestCallerName ?? null,
1910
- requesterExternalUserId: fromNumber,
1911
- requesterChatId: fromNumber,
1912
- requesterMemberId,
1913
- requesterMemberSourceChannel: requesterMemberId ? "voice" : null,
1914
- },
1915
- dedupeKey,
1916
- })
1917
- .then(() => {
1918
- recordCallEvent(this.callSessionId, "callback_handoff_notified", {
1919
- requestId: this.accessRequestId,
1920
- reason,
1921
- requesterMemberId,
1922
- });
1923
- log.info(
1924
- {
1925
- callSessionId: this.callSessionId,
1926
- requestId: this.accessRequestId,
1927
- reason,
1928
- },
1929
- "Callback handoff notification emitted",
1930
- );
1931
- })
1932
- .catch((err) => {
1933
- recordCallEvent(this.callSessionId, "callback_handoff_failed", {
1934
- requestId: this.accessRequestId,
1935
- reason,
1936
- error: err instanceof Error ? err.message : String(err),
1937
- });
1938
- log.error(
1939
- {
1940
- err,
1941
- callSessionId: this.callSessionId,
1942
- requestId: this.accessRequestId,
1943
- },
1944
- "Failed to emit callback handoff notification",
1945
- );
1946
- });
1473
+ const result = emitAccessRequestCallbackHandoff({
1474
+ reason,
1475
+ callbackOptIn: this.callbackOptIn,
1476
+ accessRequestId: this.accessRequestId,
1477
+ callbackHandoffNotified: this.callbackHandoffNotified,
1478
+ accessRequestAssistantId: this.accessRequestAssistantId,
1479
+ accessRequestFromNumber: this.accessRequestFromNumber,
1480
+ accessRequestCallerName: this.accessRequestCallerName,
1481
+ callSessionId: this.callSessionId,
1482
+ });
1483
+ this.callbackHandoffNotified = result.callbackHandoffNotified;
1947
1484
  }
1948
1485
 
1949
1486
  /**
@@ -1984,30 +1521,30 @@ export class RelayConnection {
1984
1521
  }
1985
1522
 
1986
1523
  /**
1987
- * Validate an entered invite code against active voice invites for the
1988
- * caller. On success, create/activate the contact and transition
1989
- * to the normal call flow. On failure, allow retries up to max attempts.
1524
+ * Validate an entered invite code against active voice invites.
1525
+ * Delegates to the extracted attemptInviteCodeRedemption() and
1526
+ * interprets the structured result to drive side-effects.
1990
1527
  */
1991
- private attemptInviteCodeRedemption(enteredCode: string): void {
1528
+ private handleInviteCodeRedemptionResult(enteredCode: string): void {
1992
1529
  if (!this.inviteRedemptionAssistantId || !this.inviteRedemptionFromNumber) {
1993
1530
  return;
1994
1531
  }
1995
1532
 
1996
- const result = redeemVoiceInviteCode({
1997
- assistantId: this.inviteRedemptionAssistantId,
1998
- callerExternalUserId: this.inviteRedemptionFromNumber,
1999
- sourceChannel: "voice",
2000
- code: enteredCode,
1533
+ const result = attemptInviteCodeRedemption({
1534
+ inviteRedemptionAssistantId: this.inviteRedemptionAssistantId,
1535
+ inviteRedemptionFromNumber: this.inviteRedemptionFromNumber,
1536
+ enteredCode,
1537
+ inviteRedemptionGuardianName: this.inviteRedemptionGuardianName,
2001
1538
  });
2002
1539
 
2003
- if (result.ok) {
1540
+ if (result.outcome === "success") {
2004
1541
  this.inviteRedemptionActive = false;
2005
1542
  this.verificationAttempts = 0;
2006
1543
  this.dtmfBuffer = "";
2007
1544
 
2008
1545
  recordCallEvent(this.callSessionId, "invite_redemption_succeeded", {
2009
1546
  memberId: result.memberId,
2010
- ...(result.type === "redeemed" ? { inviteId: result.inviteId } : {}),
1547
+ ...(result.inviteId ? { inviteId: result.inviteId } : {}),
2011
1548
  });
2012
1549
  log.info(
2013
1550
  {
@@ -2025,7 +1562,6 @@ export class RelayConnection {
2025
1562
  skipMemberActivation: true,
2026
1563
  });
2027
1564
  } else {
2028
- // On any invalid/expired code, emit exact deterministic failure copy and end call immediately.
2029
1565
  this.inviteRedemptionActive = false;
2030
1566
 
2031
1567
  recordCallEvent(this.callSessionId, "invite_redemption_failed", {
@@ -2036,12 +1572,7 @@ export class RelayConnection {
2036
1572
  "Voice invite redemption failed — invalid or expired code",
2037
1573
  );
2038
1574
 
2039
- const displayGuardian =
2040
- this.inviteRedemptionGuardianName ?? "your contact";
2041
- this.sendTextToken(
2042
- `Sorry, the code you provided is incorrect or has since expired. Please ask ${displayGuardian} for a new code. Goodbye.`,
2043
- true,
2044
- );
1575
+ this.sendTextToken(result.ttsMessage, true);
2045
1576
 
2046
1577
  this.connectionState = "disconnecting";
2047
1578
 
@@ -2053,24 +1584,7 @@ export class RelayConnection {
2053
1584
 
2054
1585
  const failSession = getCallSession(this.callSessionId);
2055
1586
  if (failSession) {
2056
- expirePendingQuestions(this.callSessionId);
2057
- persistCallCompletionMessage(
2058
- failSession.conversationId,
2059
- this.callSessionId,
2060
- ).catch((err) => {
2061
- log.error(
2062
- {
2063
- err,
2064
- conversationId: failSession.conversationId,
2065
- callSessionId: this.callSessionId,
2066
- },
2067
- "Failed to persist call completion message",
2068
- );
2069
- });
2070
- fireCallCompletionNotifier(
2071
- failSession.conversationId,
2072
- this.callSessionId,
2073
- );
1587
+ finalizeCall(this.callSessionId, failSession.conversationId);
2074
1588
  }
2075
1589
 
2076
1590
  setTimeout(() => {
@@ -2162,148 +1676,20 @@ export class RelayConnection {
2162
1676
  }
2163
1677
  }
2164
1678
 
2165
- /**
2166
- * Generate a non-repetitive heartbeat message for the caller based
2167
- * on the current sequence counter and guardian label.
2168
- */
2169
- private getHeartbeatMessage(): string {
2170
- const guardianLabel = this.resolveGuardianLabel();
2171
- const seq = this.heartbeatSequence++;
2172
- const messages = [
2173
- `Still waiting to hear back from ${guardianLabel}. Thank you for your patience.`,
2174
- `I'm still trying to reach ${guardianLabel}. One moment please.`,
2175
- `Hang tight, still waiting on ${guardianLabel}.`,
2176
- `Still checking with ${guardianLabel}. I appreciate you waiting.`,
2177
- `I haven't heard back from ${guardianLabel} yet. Thanks for holding.`,
2178
- ];
2179
- return messages[seq % messages.length];
2180
- }
2181
-
2182
- /**
2183
- * Schedule the next heartbeat update. Uses the initial fixed interval
2184
- * during the initial window, then jitters between steady min/max.
2185
- */
2186
1679
  private scheduleNextHeartbeat(): void {
2187
- if (!this.accessRequestWaitActive) return;
2188
-
2189
- const elapsed = Date.now() - this.accessRequestWaitStartedAt;
2190
- const initialWindow = getGuardianWaitUpdateInitialWindowMs();
2191
- const intervalMs =
2192
- elapsed < initialWindow
2193
- ? getGuardianWaitUpdateInitialIntervalMs()
2194
- : getGuardianWaitUpdateSteadyMinIntervalMs() +
2195
- Math.floor(
2196
- Math.random() *
2197
- Math.max(
2198
- 0,
2199
- getGuardianWaitUpdateSteadyMaxIntervalMs() -
2200
- getGuardianWaitUpdateSteadyMinIntervalMs(),
2201
- ),
2202
- );
2203
-
2204
- this.accessRequestHeartbeatTimer = setTimeout(() => {
2205
- if (!this.accessRequestWaitActive) return;
2206
-
2207
- const message = this.getHeartbeatMessage();
2208
- this.sendTextToken(message, true);
2209
-
2210
- recordCallEvent(
2211
- this.callSessionId,
2212
- "voice_guardian_wait_heartbeat_sent",
2213
- {
2214
- sequence: this.heartbeatSequence - 1,
2215
- message,
2216
- },
2217
- );
2218
-
2219
- log.debug(
2220
- {
2221
- callSessionId: this.callSessionId,
2222
- sequence: this.heartbeatSequence - 1,
2223
- },
2224
- "Guardian wait heartbeat sent",
2225
- );
2226
-
2227
- // Schedule the next heartbeat
2228
- this.scheduleNextHeartbeat();
2229
- }, intervalMs);
1680
+ this.accessRequestHeartbeatTimer = scheduleNextHeartbeat({
1681
+ isWaitActive: () => this.accessRequestWaitActive,
1682
+ accessRequestWaitStartedAt: this.accessRequestWaitStartedAt,
1683
+ callSessionId: this.callSessionId,
1684
+ consumeSequence: () => this.heartbeatSequence++,
1685
+ resolveGuardianLabel: () => this.resolveGuardianLabel(),
1686
+ sendTextToken: (text, last) => this.sendTextToken(text, last),
1687
+ scheduleNext: () => this.scheduleNextHeartbeat(),
1688
+ });
2230
1689
  }
2231
1690
 
2232
- /**
2233
- * Classify a caller utterance during guardian wait into one of:
2234
- * - 'empty': whitespace or noise
2235
- * - 'patience_check': asking for status or checking in
2236
- * - 'impatient': expressing frustration or wanting to end
2237
- * - 'callback_opt_in': explicitly agreeing to a callback
2238
- * - 'callback_decline': explicitly declining a callback
2239
- * - 'neutral': anything else
2240
- */
2241
- private classifyWaitUtterance(
2242
- text: string,
2243
- ):
2244
- | "empty"
2245
- | "patience_check"
2246
- | "impatient"
2247
- | "callback_opt_in"
2248
- | "callback_decline"
2249
- | "neutral" {
2250
- const lower = text.toLowerCase().trim();
2251
- if (lower.length === 0) return "empty";
2252
-
2253
- // Callback opt-in patterns (check before impatience to catch "yes call me back")
2254
- if (this.callbackOfferMade) {
2255
- if (
2256
- /\b(yes|yeah|yep|sure|okay|ok|please)\b.*\b(call\s*(me\s*)?back|callback)\b/.test(
2257
- lower,
2258
- ) ||
2259
- /\b(call\s*(me\s*)?back|callback)\b.*\b(yes|yeah|please|sure)\b/.test(
2260
- lower,
2261
- ) ||
2262
- /^(yes|yeah|yep|sure|okay|ok|please)\s*[.,!]?\s*$/.test(lower) ||
2263
- /\bcall\s*(me\s*)?back\b/.test(lower) ||
2264
- /\bplease\s+do\b/.test(lower)
2265
- ) {
2266
- return "callback_opt_in";
2267
- }
2268
- if (
2269
- /\b(no|nah|nope)\b/.test(lower) ||
2270
- /\bi('?ll| will)\s+hold\b/.test(lower) ||
2271
- /\bi('?ll| will)\s+wait\b/.test(lower)
2272
- ) {
2273
- return "callback_decline";
2274
- }
2275
- }
2276
-
2277
- // Impatience patterns
2278
- if (
2279
- /\bhurry\s*(up)?\b/.test(lower) ||
2280
- /\btaking\s+(too\s+|so\s+)?long\b/.test(lower) ||
2281
- /\bforget\s+it\b/.test(lower) ||
2282
- /\bnever\s*mind\b/.test(lower) ||
2283
- /\bdon'?t\s+have\s+time\b/.test(lower) ||
2284
- /\bhow\s+much\s+longer\b/.test(lower) ||
2285
- /\bi('?m| am)\s+(getting\s+)?impatient\b/.test(lower) ||
2286
- /\bthis\s+is\s+(ridiculous|absurd|crazy)\b/.test(lower) ||
2287
- /\bcome\s+on\b/.test(lower) ||
2288
- /\bi\s+(gotta|have\s+to|need\s+to)\s+go\b/.test(lower)
2289
- ) {
2290
- return "impatient";
2291
- }
2292
-
2293
- // Patience check / status inquiry patterns
2294
- if (
2295
- /\bhello\??\s*$/.test(lower) ||
2296
- /\bstill\s+there\b/.test(lower) ||
2297
- /\bany\s+(update|news)\b/.test(lower) ||
2298
- /\bwhat('?s| is)\s+(happening|going\s+on)\b/.test(lower) ||
2299
- /\bare\s+you\s+still\b/.test(lower) ||
2300
- /\bhow\s+(long|much\s+longer)\b/.test(lower) ||
2301
- /\banyone\s+there\b/.test(lower)
2302
- ) {
2303
- return "patience_check";
2304
- }
2305
-
2306
- return "neutral";
1691
+ private classifyWaitUtterance(text: string) {
1692
+ return classifyWaitUtterance(text, this.callbackOfferMade);
2307
1693
  }
2308
1694
 
2309
1695
  /**
@@ -2483,9 +1869,7 @@ export class RelayConnection {
2483
1869
  this.connectionState === "verification_pending" &&
2484
1870
  this.guardianVerificationActive
2485
1871
  ) {
2486
- const spokenDigits = RelayConnection.parseDigitsFromSpeech(
2487
- msg.voicePrompt,
2488
- );
1872
+ const spokenDigits = parseDigitsFromSpeech(msg.voicePrompt);
2489
1873
  log.info(
2490
1874
  {
2491
1875
  callSessionId: this.callSessionId,
@@ -2496,7 +1880,7 @@ export class RelayConnection {
2496
1880
  );
2497
1881
  if (spokenDigits.length >= this.verificationCodeLength) {
2498
1882
  const enteredCode = spokenDigits.slice(0, this.verificationCodeLength);
2499
- this.attemptGuardianCodeVerification(enteredCode);
1883
+ this.handleGuardianCodeVerificationResult(enteredCode);
2500
1884
  } else if (spokenDigits.length > 0) {
2501
1885
  this.sendTextToken(
2502
1886
  `I heard ${spokenDigits.length} digits. Please enter all ${this.verificationCodeLength} digits of your code.`,
@@ -2512,9 +1896,7 @@ export class RelayConnection {
2512
1896
  this.connectionState === "verification_pending" &&
2513
1897
  this.inviteRedemptionActive
2514
1898
  ) {
2515
- const spokenDigits = RelayConnection.parseDigitsFromSpeech(
2516
- msg.voicePrompt,
2517
- );
1899
+ const spokenDigits = parseDigitsFromSpeech(msg.voicePrompt);
2518
1900
  log.info(
2519
1901
  {
2520
1902
  callSessionId: this.callSessionId,
@@ -2528,7 +1910,7 @@ export class RelayConnection {
2528
1910
  0,
2529
1911
  this.inviteRedemptionCodeLength,
2530
1912
  );
2531
- this.attemptInviteCodeRedemption(enteredCode);
1913
+ this.handleInviteCodeRedemptionResult(enteredCode);
2532
1914
  } else if (spokenDigits.length > 0) {
2533
1915
  this.sendTextToken(
2534
1916
  `I heard ${spokenDigits.length} digits. Please enter all ${this.inviteRedemptionCodeLength} digits of your code.`,
@@ -2683,7 +2065,7 @@ export class RelayConnection {
2683
2065
  this.verificationCodeLength,
2684
2066
  );
2685
2067
  this.dtmfBuffer = "";
2686
- this.attemptGuardianCodeVerification(enteredCode);
2068
+ this.handleGuardianCodeVerificationResult(enteredCode);
2687
2069
  }
2688
2070
  return;
2689
2071
  }
@@ -2702,7 +2084,7 @@ export class RelayConnection {
2702
2084
  this.inviteRedemptionCodeLength,
2703
2085
  );
2704
2086
  this.dtmfBuffer = "";
2705
- this.attemptInviteCodeRedemption(enteredCode);
2087
+ this.handleInviteCodeRedemptionResult(enteredCode);
2706
2088
  }
2707
2089
  return;
2708
2090
  }
@@ -2777,24 +2159,7 @@ export class RelayConnection {
2777
2159
 
2778
2160
  const session = getCallSession(this.callSessionId);
2779
2161
  if (session) {
2780
- expirePendingQuestions(this.callSessionId);
2781
- persistCallCompletionMessage(
2782
- session.conversationId,
2783
- this.callSessionId,
2784
- ).catch((err) => {
2785
- log.error(
2786
- {
2787
- err,
2788
- conversationId: session.conversationId,
2789
- callSessionId: this.callSessionId,
2790
- },
2791
- "Failed to persist call completion message",
2792
- );
2793
- });
2794
- fireCallCompletionNotifier(
2795
- session.conversationId,
2796
- this.callSessionId,
2797
- );
2162
+ finalizeCall(this.callSessionId, session.conversationId);
2798
2163
  if (session.initiatedFromConversationId) {
2799
2164
  addPointerMessage(
2800
2165
  session.initiatedFromConversationId,