@vellumai/assistant 0.4.2 → 0.4.3

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 (84) hide show
  1. package/ARCHITECTURE.md +84 -7
  2. package/docs/trusted-contact-access.md +20 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/access-request-decision.test.ts +0 -1
  5. package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
  6. package/src/__tests__/call-routes-http.test.ts +0 -25
  7. package/src/__tests__/channel-guardian.test.ts +6 -5
  8. package/src/__tests__/config-schema.test.ts +2 -0
  9. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
  11. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  12. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  13. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  14. package/src/__tests__/non-member-access-request.test.ts +28 -1
  15. package/src/__tests__/notification-decision-strategy.test.ts +44 -0
  16. package/src/__tests__/relay-server.test.ts +644 -4
  17. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  18. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  19. package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
  20. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  21. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  22. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  23. package/src/__tests__/twilio-routes.test.ts +4 -3
  24. package/src/__tests__/update-bulletin.test.ts +0 -1
  25. package/src/approvals/guardian-decision-primitive.ts +2 -1
  26. package/src/approvals/guardian-request-resolvers.ts +42 -3
  27. package/src/calls/call-constants.ts +8 -0
  28. package/src/calls/call-controller.ts +2 -1
  29. package/src/calls/call-domain.ts +5 -4
  30. package/src/calls/relay-server.ts +513 -116
  31. package/src/calls/twilio-routes.ts +3 -5
  32. package/src/calls/types.ts +1 -1
  33. package/src/calls/voice-session-bridge.ts +4 -3
  34. package/src/cli/core-commands.ts +7 -4
  35. package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
  36. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
  37. package/src/config/calls-schema.ts +12 -0
  38. package/src/config/feature-flag-registry.json +0 -8
  39. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
  40. package/src/daemon/handlers/config-channels.ts +5 -7
  41. package/src/daemon/handlers/config-inbox.ts +2 -0
  42. package/src/daemon/handlers/index.ts +2 -1
  43. package/src/daemon/handlers/publish.ts +11 -46
  44. package/src/daemon/handlers/sessions.ts +11 -2
  45. package/src/daemon/ipc-contract/apps.ts +1 -0
  46. package/src/daemon/ipc-contract/inbox.ts +4 -0
  47. package/src/daemon/ipc-contract/integrations.ts +3 -1
  48. package/src/daemon/server.ts +2 -1
  49. package/src/daemon/session-agent-loop.ts +2 -1
  50. package/src/daemon/session-runtime-assembly.ts +3 -1
  51. package/src/daemon/session-surfaces.ts +29 -1
  52. package/src/memory/conversation-crud.ts +2 -1
  53. package/src/memory/conversation-title-service.ts +16 -2
  54. package/src/memory/db-init.ts +4 -0
  55. package/src/memory/delivery-crud.ts +2 -1
  56. package/src/memory/guardian-action-store.ts +2 -1
  57. package/src/memory/guardian-approvals.ts +3 -2
  58. package/src/memory/ingress-invite-store.ts +12 -2
  59. package/src/memory/ingress-member-store.ts +4 -3
  60. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  61. package/src/memory/migrations/index.ts +1 -0
  62. package/src/memory/schema.ts +10 -5
  63. package/src/notifications/copy-composer.ts +11 -1
  64. package/src/notifications/emit-signal.ts +2 -1
  65. package/src/runtime/access-request-helper.ts +11 -3
  66. package/src/runtime/actor-trust-resolver.ts +2 -2
  67. package/src/runtime/assistant-scope.ts +10 -0
  68. package/src/runtime/guardian-outbound-actions.ts +5 -4
  69. package/src/runtime/http-server.ts +11 -20
  70. package/src/runtime/ingress-service.ts +14 -0
  71. package/src/runtime/invite-redemption-service.ts +2 -1
  72. package/src/runtime/middleware/twilio-validation.ts +2 -4
  73. package/src/runtime/routes/call-routes.ts +2 -1
  74. package/src/runtime/routes/channel-route-shared.ts +3 -3
  75. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  76. package/src/runtime/routes/conversation-routes.ts +2 -1
  77. package/src/runtime/routes/events-routes.ts +2 -3
  78. package/src/runtime/routes/inbound-conversation.ts +4 -3
  79. package/src/runtime/routes/inbound-message-handler.ts +4 -3
  80. package/src/runtime/routes/ingress-routes.ts +2 -0
  81. package/src/tools/calls/call-start.ts +2 -1
  82. package/src/tools/terminal/parser.ts +12 -0
  83. package/src/tools/tool-approval-handler.ts +2 -1
  84. package/src/workspace/git-service.ts +19 -0
@@ -10,16 +10,18 @@ import { randomInt } from 'node:crypto';
10
10
 
11
11
  import type { ServerWebSocket } from 'bun';
12
12
 
13
- import { isAssistantFeatureFlagEnabled } from '../config/assistant-feature-flags.js';
14
13
  import { getConfig } from '../config/loader.js';
14
+ import { getCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
15
15
  import * as conversationStore from '../memory/conversation-store.js';
16
16
  import { findActiveVoiceInvites } from '../memory/ingress-invite-store.js';
17
+ import { upsertMember } from '../memory/ingress-member-store.js';
17
18
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
18
19
  import { notifyGuardianOfAccessRequest } from '../runtime/access-request-helper.js';
19
20
  import {
20
21
  resolveActorTrust,
21
22
  toGuardianRuntimeContextFromTrust,
22
23
  } from '../runtime/actor-trust-resolver.js';
24
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
23
25
  import {
24
26
  getPendingChallenge,
25
27
  validateAndConsumeChallenge,
@@ -31,7 +33,7 @@ import {
31
33
  import { redeemVoiceInviteCode } from '../runtime/ingress-service.js';
32
34
  import { parseJsonSafe } from '../util/json.js';
33
35
  import { getLogger } from '../util/logger.js';
34
- import { normalizeAssistantId } from '../util/platform.js';
36
+ import { getAccessRequestPollIntervalMs, getTtsPlaybackDelayMs, getUserConsultationTimeoutMs } from './call-constants.js';
35
37
  import { CallController } from './call-controller.js';
36
38
  import { persistCallCompletionMessage } from './call-conversation-messages.js';
37
39
  import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
@@ -144,7 +146,7 @@ export function setRelayBroadcast(fn: (msg: import('../daemon/ipc-contract.js').
144
146
  /**
145
147
  * Manages a single WebSocket connection for one call.
146
148
  */
147
- export type RelayConnectionState = 'connected' | 'verification_pending' | 'disconnecting';
149
+ export type RelayConnectionState = 'connected' | 'verification_pending' | 'awaiting_name' | 'awaiting_guardian_decision' | 'disconnecting';
148
150
 
149
151
  export class RelayConnection {
150
152
  private ws: ServerWebSocket<RelayWebSocketData>;
@@ -180,6 +182,20 @@ export class RelayConnection {
180
182
  private inviteRedemptionAssistantId: string | null = null;
181
183
  private inviteRedemptionFromNumber: string | null = null;
182
184
  private inviteRedemptionCodeLength = 6;
185
+ private inviteRedemptionFriendName: string | null = null;
186
+ private inviteRedemptionGuardianName: string | null = null;
187
+
188
+ // In-call guardian approval wait state (friend-initiated)
189
+ private accessRequestWaitActive = false;
190
+ private accessRequestId: string | null = null;
191
+ private accessRequestAssistantId: string | null = null;
192
+ private accessRequestFromNumber: string | null = null;
193
+ private accessRequestPollTimer: ReturnType<typeof setInterval> | null = null;
194
+ private accessRequestTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
195
+ private accessRequestCallerName: string | null = null;
196
+
197
+ // Name capture timeout (unknown inbound callers)
198
+ private nameCaptureTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
183
199
 
184
200
  constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
185
201
  this.ws = ws;
@@ -304,6 +320,19 @@ export class RelayConnection {
304
320
  this.controller.destroy();
305
321
  this.controller = null;
306
322
  }
323
+ if (this.accessRequestPollTimer) {
324
+ clearInterval(this.accessRequestPollTimer);
325
+ this.accessRequestPollTimer = null;
326
+ }
327
+ if (this.accessRequestTimeoutTimer) {
328
+ clearTimeout(this.accessRequestTimeoutTimer);
329
+ this.accessRequestTimeoutTimer = null;
330
+ }
331
+ if (this.nameCaptureTimeoutTimer) {
332
+ clearTimeout(this.nameCaptureTimeoutTimer);
333
+ this.nameCaptureTimeoutTimer = null;
334
+ }
335
+ this.accessRequestWaitActive = false;
307
336
  this.abortController.abort();
308
337
  log.info({ callSessionId: this.callSessionId }, 'RelayConnection destroyed');
309
338
  }
@@ -315,6 +344,13 @@ export class RelayConnection {
315
344
  * we still finalize the call lifecycle from the relay close signal.
316
345
  */
317
346
  handleTransportClosed(code?: number, reason?: string): void {
347
+ // Clean up access request wait state on disconnect to stop polling
348
+ this.clearAccessRequestWait();
349
+ if (this.nameCaptureTimeoutTimer) {
350
+ clearTimeout(this.nameCaptureTimeoutTimer);
351
+ this.nameCaptureTimeoutTimer = null;
352
+ }
353
+
318
354
  const session = getCallSession(this.callSessionId);
319
355
  if (!session) return;
320
356
  if (isTerminalState(session.status)) return;
@@ -427,7 +463,7 @@ export class RelayConnection {
427
463
  // calls (created via createInboundVoiceSession) never do. Relying on
428
464
  // task == null is unreliable: task-less outbound sessions would
429
465
  // incorrectly bypass outbound verification.
430
- const assistantId = normalizeAssistantId(session?.assistantId ?? 'self');
466
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
431
467
  const isInbound = session?.initiatedFromConversationId == null;
432
468
 
433
469
  // Create and attach the session-backed voice controller. Seed guardian
@@ -500,89 +536,80 @@ export class RelayConnection {
500
536
  const pendingChallenge = getPendingChallenge(assistantId, 'voice');
501
537
 
502
538
  if (actorTrust.trustClass === 'unknown' && !pendingChallenge) {
503
- // Before denying, check if there is an active voice invite bound
504
- // to the caller's phone number. If so, enter the invite redemption
505
- // subflow instead of denying the call outright.
506
- // Gated behind the voice-invite-redemption feature flag (defaults OFF).
507
- const voiceInviteEnabled = isAssistantFeatureFlagEnabled(
508
- 'feature_flags.voice-invite-redemption.enabled',
509
- config,
510
- );
539
+ // Before entering the name capture flow, check if there is an
540
+ // active voice invite bound to the caller's phone number. If so,
541
+ // enter the invite redemption subflow instead.
542
+ let voiceInvites: ReturnType<typeof findActiveVoiceInvites> = [];
543
+ try {
544
+ voiceInvites = findActiveVoiceInvites({
545
+ assistantId,
546
+ expectedExternalUserId: msg.from,
547
+ });
548
+ } catch (err) {
549
+ log.warn({ err, callSessionId: this.callSessionId }, 'Failed to check voice invites for unknown caller');
550
+ }
511
551
 
512
- if (voiceInviteEnabled) {
513
- let voiceInvites: ReturnType<typeof findActiveVoiceInvites> = [];
514
- try {
515
- voiceInvites = findActiveVoiceInvites({
516
- assistantId,
517
- expectedExternalUserId: msg.from,
518
- });
519
- } catch (err) {
520
- log.warn({ err, callSessionId: this.callSessionId }, 'Failed to check voice invites for unknown caller');
521
- }
552
+ // Exclude invites that are past their expiresAt even if the DB
553
+ // status hasn't been lazily flipped to 'expired' yet.
554
+ const now = Date.now();
555
+ const nonExpiredInvites = voiceInvites.filter(i => !i.expiresAt || i.expiresAt > now);
556
+
557
+ // Blocked members get immediate denial — the guardian already made
558
+ // an explicit decision to block them. This must be checked before
559
+ // invite redemption so a blocked caller cannot bypass the block by
560
+ // redeeming an active invite.
561
+ if (actorTrust.memberRecord?.status === 'blocked') {
562
+ log.info(
563
+ { callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
564
+ 'Inbound voice ACL: blocked caller denied',
565
+ );
522
566
 
523
- // Exclude invites that are past their expiresAt even if the DB
524
- // status hasn't been lazily flipped to 'expired' yet.
525
- const now = Date.now();
526
- const nonExpiredInvites = voiceInvites.filter(i => !i.expiresAt || i.expiresAt > now);
567
+ recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
568
+ from: msg.from,
569
+ trustClass: actorTrust.trustClass,
570
+ denialReason: actorTrust.denialReason,
571
+ });
527
572
 
528
- if (nonExpiredInvites.length > 0) {
529
- log.info(
530
- { callSessionId: this.callSessionId, from: msg.from },
531
- 'Inbound voice ACL: unknown caller has active voice invite — entering redemption flow',
532
- );
533
- this.startInviteRedemption(assistantId, msg.from);
534
- return;
535
- }
573
+ this.sendTextToken('This number is not authorized to use this assistant.', true);
574
+
575
+ this.connectionState = 'disconnecting';
576
+
577
+ updateCallSession(this.callSessionId, {
578
+ status: 'failed',
579
+ endedAt: Date.now(),
580
+ lastError: 'Inbound voice ACL: caller blocked',
581
+ });
582
+
583
+ setTimeout(() => {
584
+ this.endSession('Inbound voice ACL denied — blocked');
585
+ }, getTtsPlaybackDelayMs());
586
+ return;
536
587
  }
537
588
 
589
+ if (nonExpiredInvites.length > 0) {
590
+ // Use the first matching invite's metadata for personalized prompts
591
+ const matchedInvite = nonExpiredInvites[0];
592
+ log.info(
593
+ { callSessionId: this.callSessionId, from: msg.from },
594
+ 'Inbound voice ACL: unknown caller has active voice invite — entering redemption flow',
595
+ );
596
+ this.startInviteRedemption(assistantId, msg.from, matchedInvite.friendName, matchedInvite.guardianName);
597
+ return;
598
+ }
599
+
600
+ // Unknown/revoked/pending callers enter the name capture + guardian
601
+ // approval wait flow instead of being hard-rejected.
538
602
  log.info(
539
603
  { callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
540
- 'Inbound voice ACL: unknown caller denied',
604
+ 'Inbound voice ACL: unknown caller — entering name capture flow',
541
605
  );
542
606
 
543
- recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
607
+ recordCallEvent(this.callSessionId, 'inbound_acl_name_capture_started', {
544
608
  from: msg.from,
545
609
  trustClass: actorTrust.trustClass,
546
- denialReason: actorTrust.denialReason,
547
610
  });
548
611
 
549
- // For revoked/pending members, notify the guardian so they can
550
- // re-approve. Blocked members are intentionally excluded — the
551
- // guardian already made an explicit decision to block them.
552
- let guardianNotified = false;
553
- if (actorTrust.memberRecord?.status !== 'blocked') {
554
- try {
555
- const accessResult = notifyGuardianOfAccessRequest({
556
- canonicalAssistantId: assistantId,
557
- sourceChannel: 'voice',
558
- externalChatId: msg.from,
559
- senderExternalUserId: actorTrust.canonicalSenderId ?? msg.from,
560
- });
561
- guardianNotified = accessResult.notified;
562
- } catch (err) {
563
- log.error({ err, callSessionId: this.callSessionId }, 'Failed to create access request for denied voice caller');
564
- }
565
- }
566
-
567
- // Deny with deterministic voice copy and end the call.
568
- // Mark as disconnecting so handlePrompt ignores caller input
569
- // during the delay before the session ends.
570
- const denialMessage = guardianNotified
571
- ? 'This number is not authorized. Your request has been forwarded to the account guardian.'
572
- : 'This number is not authorized to use this assistant.';
573
- this.sendTextToken(denialMessage, true);
574
-
575
- this.connectionState = 'disconnecting';
576
-
577
- updateCallSession(this.callSessionId, {
578
- status: 'failed',
579
- endedAt: Date.now(),
580
- lastError: 'Inbound voice ACL: caller not authorized',
581
- });
582
-
583
- setTimeout(() => {
584
- this.endSession('Inbound voice ACL denied');
585
- }, 3000);
612
+ this.startNameCapture(assistantId, msg.from);
586
613
  return;
587
614
  }
588
615
 
@@ -614,7 +641,7 @@ export class RelayConnection {
614
641
 
615
642
  setTimeout(() => {
616
643
  this.endSession('Inbound voice ACL: member policy deny');
617
- }, 3000);
644
+ }, getTtsPlaybackDelayMs());
618
645
  return;
619
646
  }
620
647
 
@@ -646,7 +673,7 @@ export class RelayConnection {
646
673
 
647
674
  setTimeout(() => {
648
675
  this.endSession('Inbound voice ACL: member policy escalate');
649
- }, 3000);
676
+ }, getTtsPlaybackDelayMs());
650
677
  return;
651
678
  }
652
679
 
@@ -910,7 +937,7 @@ export class RelayConnection {
910
937
 
911
938
  setTimeout(() => {
912
939
  this.endSession('Guardian verification succeeded');
913
- }, 3000);
940
+ }, getTtsPlaybackDelayMs());
914
941
  } else {
915
942
  // Inbound: proceed to normal call flow
916
943
  if (this.controller) {
@@ -981,7 +1008,7 @@ export class RelayConnection {
981
1008
 
982
1009
  setTimeout(() => {
983
1010
  this.endSession('Guardian verification failed');
984
- }, 2000);
1011
+ }, getTtsPlaybackDelayMs());
985
1012
  } else {
986
1013
  const retryText = isOutbound
987
1014
  ? composeVerificationVoice(GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_RETRY, { codeDigits })
@@ -1001,13 +1028,15 @@ export class RelayConnection {
1001
1028
  * who has an active voice invite. Prompts the caller to enter their
1002
1029
  * invite code via DTMF or speech.
1003
1030
  */
1004
- private startInviteRedemption(assistantId: string, fromNumber: string): void {
1031
+ private startInviteRedemption(assistantId: string, fromNumber: string, friendName: string | null, guardianName: string | null): void {
1005
1032
  this.inviteRedemptionActive = true;
1006
1033
  this.inviteRedemptionAssistantId = assistantId;
1007
1034
  this.inviteRedemptionFromNumber = fromNumber;
1035
+ this.inviteRedemptionFriendName = friendName;
1036
+ this.inviteRedemptionGuardianName = guardianName;
1008
1037
  this.connectionState = 'verification_pending';
1009
1038
  this.verificationAttempts = 0;
1010
- this.verificationMaxAttempts = 3;
1039
+ this.verificationMaxAttempts = 1;
1011
1040
  this.inviteRedemptionCodeLength = 6;
1012
1041
  this.dtmfBuffer = '';
1013
1042
 
@@ -1017,8 +1046,10 @@ export class RelayConnection {
1017
1046
  maxAttempts: this.verificationMaxAttempts,
1018
1047
  });
1019
1048
 
1049
+ const displayFriend = friendName ?? 'there';
1050
+ const displayGuardian = guardianName ?? 'your contact';
1020
1051
  this.sendTextToken(
1021
- 'Please enter your 6-digit invite code using your keypad, or speak the digits now.',
1052
+ `Welcome ${displayFriend}. Please enter the 6-digit code that ${displayGuardian} provided you to verify your identity.`,
1022
1053
  true,
1023
1054
  );
1024
1055
 
@@ -1028,6 +1059,344 @@ export class RelayConnection {
1028
1059
  );
1029
1060
  }
1030
1061
 
1062
+ /**
1063
+ * Enter the name capture subflow for unknown inbound callers.
1064
+ * Prompts the caller to provide their name so we can include it
1065
+ * in the guardian notification.
1066
+ */
1067
+ private startNameCapture(assistantId: string, fromNumber: string): void {
1068
+ this.accessRequestAssistantId = assistantId;
1069
+ this.accessRequestFromNumber = fromNumber;
1070
+ this.connectionState = 'awaiting_name';
1071
+
1072
+ this.sendTextToken(
1073
+ "Sorry, I don't recognize this number. I'll let my guardian know you called and see if I have permission to speak with you. Can I get your name?",
1074
+ true,
1075
+ );
1076
+
1077
+ // Start a timeout so silent callers don't keep the call open indefinitely.
1078
+ // Uses a 30-second window — enough time to speak a name but short enough
1079
+ // to avoid wasting resources on callers who never respond.
1080
+ const NAME_CAPTURE_TIMEOUT_MS = 30_000;
1081
+ this.nameCaptureTimeoutTimer = setTimeout(() => {
1082
+ if (this.connectionState !== 'awaiting_name') return;
1083
+ this.handleNameCaptureTimeout();
1084
+ }, NAME_CAPTURE_TIMEOUT_MS);
1085
+
1086
+ log.info(
1087
+ { callSessionId: this.callSessionId, assistantId, timeoutMs: NAME_CAPTURE_TIMEOUT_MS },
1088
+ 'Name capture started for unknown inbound caller',
1089
+ );
1090
+ }
1091
+
1092
+ /**
1093
+ * Handle the caller's name response during the name capture subflow.
1094
+ * Creates a canonical access request, notifies the guardian, and
1095
+ * enters the bounded wait loop for the guardian decision.
1096
+ */
1097
+ private handleNameCaptureResponse(callerName: string): void {
1098
+ if (!this.accessRequestAssistantId || !this.accessRequestFromNumber) {
1099
+ return;
1100
+ }
1101
+
1102
+ // Clear the name capture timeout since the caller responded.
1103
+ if (this.nameCaptureTimeoutTimer) {
1104
+ clearTimeout(this.nameCaptureTimeoutTimer);
1105
+ this.nameCaptureTimeoutTimer = null;
1106
+ }
1107
+
1108
+ this.accessRequestCallerName = callerName;
1109
+
1110
+ recordCallEvent(this.callSessionId, 'inbound_acl_name_captured', {
1111
+ from: this.accessRequestFromNumber,
1112
+ callerName,
1113
+ });
1114
+
1115
+ // Create canonical access request and notify the guardian, including
1116
+ // the caller's spoken name and voice channel metadata.
1117
+ try {
1118
+ const accessResult = notifyGuardianOfAccessRequest({
1119
+ canonicalAssistantId: this.accessRequestAssistantId,
1120
+ sourceChannel: 'voice',
1121
+ externalChatId: this.accessRequestFromNumber,
1122
+ senderExternalUserId: this.accessRequestFromNumber,
1123
+ senderName: callerName,
1124
+ });
1125
+
1126
+ if (accessResult.notified) {
1127
+ this.accessRequestId = accessResult.requestId;
1128
+ log.info(
1129
+ { callSessionId: this.callSessionId, requestId: accessResult.requestId, callerName },
1130
+ 'Guardian notified of voice access request with caller name',
1131
+ );
1132
+ } else {
1133
+ log.warn(
1134
+ { callSessionId: this.callSessionId },
1135
+ 'Failed to notify guardian of voice access request — no sender ID',
1136
+ );
1137
+ }
1138
+ } catch (err) {
1139
+ log.error({ err, callSessionId: this.callSessionId }, 'Failed to create access request for voice caller');
1140
+ }
1141
+
1142
+ // If the access request was not successfully created (notifyGuardianOfAccessRequest
1143
+ // threw or returned notified: false), fail closed rather than leaving the caller
1144
+ // stuck on hold with no guardian poll target.
1145
+ if (!this.accessRequestId) {
1146
+ log.warn(
1147
+ { callSessionId: this.callSessionId },
1148
+ 'Access request ID is null after notification attempt — failing closed',
1149
+ );
1150
+ this.handleAccessRequestTimeout();
1151
+ return;
1152
+ }
1153
+
1154
+ // Enter the bounded wait loop for the guardian decision
1155
+ this.startAccessRequestWait();
1156
+ }
1157
+
1158
+ /**
1159
+ * Start a bounded in-call wait loop polling the canonical request
1160
+ * status until approved, denied, or timeout.
1161
+ */
1162
+ private startAccessRequestWait(): void {
1163
+ this.accessRequestWaitActive = true;
1164
+ this.connectionState = 'awaiting_guardian_decision';
1165
+
1166
+ const timeoutMs = getUserConsultationTimeoutMs();
1167
+ const pollIntervalMs = getAccessRequestPollIntervalMs();
1168
+
1169
+ this.sendTextToken(
1170
+ "Thank you. I've let my guardian know. Please hold while I check if I have permission to speak with you.",
1171
+ true,
1172
+ );
1173
+
1174
+ updateCallSession(this.callSessionId, { status: 'waiting_on_user' });
1175
+
1176
+ // Poll the canonical request status
1177
+ this.accessRequestPollTimer = setInterval(() => {
1178
+ if (!this.accessRequestWaitActive || !this.accessRequestId) {
1179
+ this.clearAccessRequestWait();
1180
+ return;
1181
+ }
1182
+
1183
+ const request = getCanonicalGuardianRequest(this.accessRequestId);
1184
+ if (!request) {
1185
+ return;
1186
+ }
1187
+
1188
+ if (request.status === 'approved') {
1189
+ this.handleAccessRequestApproved();
1190
+ } else if (request.status === 'denied') {
1191
+ this.handleAccessRequestDenied();
1192
+ }
1193
+ // 'pending' continues polling; 'expired'/'cancelled' handled by timeout
1194
+ }, pollIntervalMs);
1195
+
1196
+ // Timeout: give up waiting for the guardian
1197
+ this.accessRequestTimeoutTimer = setTimeout(() => {
1198
+ if (!this.accessRequestWaitActive) return;
1199
+
1200
+ log.info(
1201
+ { callSessionId: this.callSessionId, requestId: this.accessRequestId },
1202
+ 'Access request in-call wait timed out',
1203
+ );
1204
+
1205
+ this.handleAccessRequestTimeout();
1206
+ }, timeoutMs);
1207
+
1208
+ log.info(
1209
+ { callSessionId: this.callSessionId, requestId: this.accessRequestId, timeoutMs },
1210
+ 'Access request in-call wait started',
1211
+ );
1212
+ }
1213
+
1214
+ /**
1215
+ * Clean up access request wait state (timers, flags).
1216
+ */
1217
+ private clearAccessRequestWait(): void {
1218
+ this.accessRequestWaitActive = false;
1219
+ if (this.accessRequestPollTimer) {
1220
+ clearInterval(this.accessRequestPollTimer);
1221
+ this.accessRequestPollTimer = null;
1222
+ }
1223
+ if (this.accessRequestTimeoutTimer) {
1224
+ clearTimeout(this.accessRequestTimeoutTimer);
1225
+ this.accessRequestTimeoutTimer = null;
1226
+ }
1227
+ }
1228
+
1229
+ /**
1230
+ * Handle an approved access request: activate the caller as a trusted
1231
+ * contact, update runtime context, and continue with normal call flow.
1232
+ */
1233
+ private handleAccessRequestApproved(): void {
1234
+ this.clearAccessRequestWait();
1235
+ this.connectionState = 'connected';
1236
+
1237
+ const assistantId = this.accessRequestAssistantId!;
1238
+ const fromNumber = this.accessRequestFromNumber!;
1239
+ const callerName = this.accessRequestCallerName;
1240
+
1241
+ recordCallEvent(this.callSessionId, 'inbound_acl_access_approved', {
1242
+ from: fromNumber,
1243
+ callerName,
1244
+ requestId: this.accessRequestId,
1245
+ });
1246
+
1247
+ // Activate the caller as a trusted contact via the existing upsert path
1248
+ try {
1249
+ upsertMember({
1250
+ assistantId,
1251
+ sourceChannel: 'voice',
1252
+ externalUserId: fromNumber,
1253
+ externalChatId: fromNumber,
1254
+ displayName: callerName ?? undefined,
1255
+ status: 'active',
1256
+ policy: 'allow',
1257
+ });
1258
+ } catch (err) {
1259
+ log.error({ err, callSessionId: this.callSessionId }, 'Failed to activate voice caller as trusted contact');
1260
+ }
1261
+
1262
+ // Re-resolve actor trust now that the member is active
1263
+ const updatedTrust = resolveActorTrust({
1264
+ assistantId,
1265
+ sourceChannel: 'voice',
1266
+ externalChatId: fromNumber,
1267
+ senderExternalUserId: fromNumber,
1268
+ });
1269
+
1270
+ if (this.controller) {
1271
+ this.controller.setGuardianContext(
1272
+ toGuardianRuntimeContextFromTrust(updatedTrust, fromNumber),
1273
+ );
1274
+ }
1275
+
1276
+ updateCallSession(this.callSessionId, { status: 'in_progress' });
1277
+
1278
+ log.info(
1279
+ { callSessionId: this.callSessionId, from: fromNumber },
1280
+ 'Access request approved — caller activated and continuing call',
1281
+ );
1282
+
1283
+ // Use handleUserInstruction to deliver the approval-aware greeting
1284
+ // through the normal session pipeline.
1285
+ const guardianName = 'my guardian';
1286
+ if (this.controller) {
1287
+ this.controller.handleUserInstruction(
1288
+ `Great, ${guardianName} approved! Now how can I help you?`,
1289
+ ).catch((err) => {
1290
+ log.error({ err, callSessionId: this.callSessionId }, 'Failed to deliver approval greeting');
1291
+ });
1292
+ }
1293
+ }
1294
+
1295
+ /**
1296
+ * Handle a denied access request: deliver deterministic copy and hang up.
1297
+ */
1298
+ private handleAccessRequestDenied(): void {
1299
+ this.clearAccessRequestWait();
1300
+
1301
+ recordCallEvent(this.callSessionId, 'inbound_acl_access_denied', {
1302
+ from: this.accessRequestFromNumber,
1303
+ requestId: this.accessRequestId,
1304
+ });
1305
+
1306
+ this.sendTextToken(
1307
+ "Sorry, my guardian says I'm not allowed to speak with you. Goodbye.",
1308
+ true,
1309
+ );
1310
+
1311
+ this.connectionState = 'disconnecting';
1312
+
1313
+ updateCallSession(this.callSessionId, {
1314
+ status: 'failed',
1315
+ endedAt: Date.now(),
1316
+ lastError: 'Inbound voice ACL: guardian denied access request',
1317
+ });
1318
+
1319
+ log.info(
1320
+ { callSessionId: this.callSessionId },
1321
+ 'Access request denied — ending call',
1322
+ );
1323
+
1324
+ setTimeout(() => {
1325
+ this.endSession('Access request denied');
1326
+ }, getTtsPlaybackDelayMs());
1327
+ }
1328
+
1329
+ /**
1330
+ * Handle an access request timeout: deliver deterministic copy and hang up.
1331
+ */
1332
+ private handleAccessRequestTimeout(): void {
1333
+ this.clearAccessRequestWait();
1334
+
1335
+ recordCallEvent(this.callSessionId, 'inbound_acl_access_timeout', {
1336
+ from: this.accessRequestFromNumber,
1337
+ requestId: this.accessRequestId,
1338
+ });
1339
+
1340
+ this.sendTextToken(
1341
+ "Sorry, I can't get ahold of my guardian right now. I'll let them know you called.",
1342
+ true,
1343
+ );
1344
+
1345
+ this.connectionState = 'disconnecting';
1346
+
1347
+ updateCallSession(this.callSessionId, {
1348
+ status: 'failed',
1349
+ endedAt: Date.now(),
1350
+ lastError: 'Inbound voice ACL: guardian approval wait timed out',
1351
+ });
1352
+
1353
+ log.info(
1354
+ { callSessionId: this.callSessionId },
1355
+ 'Access request timed out — ending call',
1356
+ );
1357
+
1358
+ setTimeout(() => {
1359
+ this.endSession('Access request timed out');
1360
+ }, getTtsPlaybackDelayMs());
1361
+ }
1362
+
1363
+ /**
1364
+ * Handle a name capture timeout: the caller never provided their name
1365
+ * within the allotted window. Deliver deterministic copy and hang up.
1366
+ */
1367
+ private handleNameCaptureTimeout(): void {
1368
+ if (this.nameCaptureTimeoutTimer) {
1369
+ clearTimeout(this.nameCaptureTimeoutTimer);
1370
+ this.nameCaptureTimeoutTimer = null;
1371
+ }
1372
+
1373
+ recordCallEvent(this.callSessionId, 'inbound_acl_name_capture_timeout', {
1374
+ from: this.accessRequestFromNumber,
1375
+ });
1376
+
1377
+ this.sendTextToken(
1378
+ "Sorry, I didn't catch your name. Please try calling back. Goodbye.",
1379
+ true,
1380
+ );
1381
+
1382
+ this.connectionState = 'disconnecting';
1383
+
1384
+ updateCallSession(this.callSessionId, {
1385
+ status: 'failed',
1386
+ endedAt: Date.now(),
1387
+ lastError: 'Inbound voice ACL: name capture timed out',
1388
+ });
1389
+
1390
+ log.info(
1391
+ { callSessionId: this.callSessionId },
1392
+ 'Name capture timed out — ending call',
1393
+ );
1394
+
1395
+ setTimeout(() => {
1396
+ this.endSession('Name capture timed out');
1397
+ }, getTtsPlaybackDelayMs());
1398
+ }
1399
+
1031
1400
  /**
1032
1401
  * Validate an entered invite code against active voice invites for the
1033
1402
  * caller. On success, create/activate the ingress member and transition
@@ -1073,46 +1442,43 @@ export class RelayConnection {
1073
1442
  this.startNormalCallFlow(this.controller, true);
1074
1443
  }
1075
1444
  } else {
1076
- this.verificationAttempts++;
1077
-
1078
- if (this.verificationAttempts >= this.verificationMaxAttempts) {
1079
- this.inviteRedemptionActive = false;
1445
+ // On any invalid/expired code, emit exact deterministic failure copy and end call immediately.
1446
+ this.inviteRedemptionActive = false;
1080
1447
 
1081
- recordCallEvent(this.callSessionId, 'invite_redemption_failed', {
1082
- attempts: this.verificationAttempts,
1083
- });
1084
- log.warn(
1085
- { callSessionId: this.callSessionId, attempts: this.verificationAttempts },
1086
- 'Voice invite redemption failed — max attempts reached',
1087
- );
1448
+ recordCallEvent(this.callSessionId, 'invite_redemption_failed', {
1449
+ attempts: 1,
1450
+ });
1451
+ log.warn(
1452
+ { callSessionId: this.callSessionId },
1453
+ 'Voice invite redemption failed — invalid or expired code',
1454
+ );
1088
1455
 
1089
- this.sendTextToken('Too many invalid attempts. Goodbye.', true);
1456
+ const displayGuardian = this.inviteRedemptionGuardianName ?? 'your contact';
1457
+ this.sendTextToken(
1458
+ `Sorry, the code you provided is incorrect or has since expired. Please ask ${displayGuardian} for a new code. Goodbye.`,
1459
+ true,
1460
+ );
1090
1461
 
1091
- updateCallSession(this.callSessionId, {
1092
- status: 'failed',
1093
- endedAt: Date.now(),
1094
- lastError: 'Voice invite redemption failed — max attempts exceeded',
1095
- });
1462
+ this.connectionState = 'disconnecting';
1096
1463
 
1097
- const failSession = getCallSession(this.callSessionId);
1098
- if (failSession) {
1099
- expirePendingQuestions(this.callSessionId);
1100
- persistCallCompletionMessage(failSession.conversationId, this.callSessionId).catch((err) => {
1101
- log.error({ err, conversationId: failSession.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
1102
- });
1103
- fireCallCompletionNotifier(failSession.conversationId, this.callSessionId);
1104
- }
1464
+ updateCallSession(this.callSessionId, {
1465
+ status: 'failed',
1466
+ endedAt: Date.now(),
1467
+ lastError: 'Voice invite redemption failed — invalid or expired code',
1468
+ });
1105
1469
 
1106
- setTimeout(() => {
1107
- this.endSession('Invite redemption failed');
1108
- }, 2000);
1109
- } else {
1110
- log.info(
1111
- { callSessionId: this.callSessionId, attempt: this.verificationAttempts, maxAttempts: this.verificationMaxAttempts },
1112
- 'Voice invite redemption attempt failed — retrying',
1113
- );
1114
- this.sendTextToken('Invalid code. Please try again.', true);
1470
+ const failSession = getCallSession(this.callSessionId);
1471
+ if (failSession) {
1472
+ expirePendingQuestions(this.callSessionId);
1473
+ persistCallCompletionMessage(failSession.conversationId, this.callSessionId).catch((err) => {
1474
+ log.error({ err, conversationId: failSession.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
1475
+ });
1476
+ fireCallCompletionNotifier(failSession.conversationId, this.callSessionId);
1115
1477
  }
1478
+
1479
+ setTimeout(() => {
1480
+ this.endSession('Invite redemption failed');
1481
+ }, getTtsPlaybackDelayMs());
1116
1482
  }
1117
1483
  }
1118
1484
 
@@ -1126,6 +1492,32 @@ export class RelayConnection {
1126
1492
  return;
1127
1493
  }
1128
1494
 
1495
+ // During name capture, the caller's response is their name.
1496
+ if (this.connectionState === 'awaiting_name') {
1497
+ const callerName = msg.voicePrompt.trim();
1498
+ if (!callerName) {
1499
+ // Whitespace-only or empty transcript (e.g. silence/noise) —
1500
+ // keep waiting for a real name. The name-capture timeout will
1501
+ // still fire if the caller never provides one.
1502
+ return;
1503
+ }
1504
+ log.info(
1505
+ { callSessionId: this.callSessionId, callerName },
1506
+ 'Name captured from unknown inbound caller',
1507
+ );
1508
+ this.handleNameCaptureResponse(callerName);
1509
+ return;
1510
+ }
1511
+
1512
+ // During guardian decision wait, ignore caller speech — they are on hold.
1513
+ if (this.connectionState === 'awaiting_guardian_decision') {
1514
+ log.debug(
1515
+ { callSessionId: this.callSessionId },
1516
+ 'Ignoring voice prompt during guardian decision wait',
1517
+ );
1518
+ return;
1519
+ }
1520
+
1129
1521
  // During guardian verification (inbound or outbound), attempt to parse
1130
1522
  // spoken digits from the transcript and validate them.
1131
1523
  if (this.connectionState === 'verification_pending' && this.guardianVerificationActive) {
@@ -1256,6 +1648,11 @@ export class RelayConnection {
1256
1648
  return;
1257
1649
  }
1258
1650
 
1651
+ // Ignore DTMF during name capture and guardian decision wait
1652
+ if (this.connectionState === 'awaiting_name' || this.connectionState === 'awaiting_guardian_decision') {
1653
+ return;
1654
+ }
1655
+
1259
1656
  log.info(
1260
1657
  { callSessionId: this.callSessionId, digit: msg.digit },
1261
1658
  'DTMF digit received',
@@ -1354,7 +1751,7 @@ export class RelayConnection {
1354
1751
  // End the call with failed status after TTS plays
1355
1752
  setTimeout(() => {
1356
1753
  this.endSession('Verification failed');
1357
- }, 2000);
1754
+ }, getTtsPlaybackDelayMs());
1358
1755
  } else {
1359
1756
  // Allow another attempt
1360
1757
  log.info(