@vellumai/assistant 0.4.2 → 0.4.4

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 (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -10,17 +10,22 @@ 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
+ import { listActiveBindingsByAssistant } from '../memory/channel-guardian-store.js';
15
16
  import * as conversationStore from '../memory/conversation-store.js';
16
17
  import { findActiveVoiceInvites } from '../memory/ingress-invite-store.js';
18
+ import { findMember, upsertMember } from '../memory/ingress-member-store.js';
17
19
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
20
+ import { emitNotificationSignal } from '../notifications/emit-signal.js';
18
21
  import { notifyGuardianOfAccessRequest } from '../runtime/access-request-helper.js';
19
22
  import {
20
23
  resolveActorTrust,
21
24
  toGuardianRuntimeContextFromTrust,
22
25
  } from '../runtime/actor-trust-resolver.js';
26
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
23
27
  import {
28
+ getGuardianBinding,
24
29
  getPendingChallenge,
25
30
  validateAndConsumeChallenge,
26
31
  } from '../runtime/channel-guardian-service.js';
@@ -31,7 +36,15 @@ import {
31
36
  import { redeemVoiceInviteCode } from '../runtime/ingress-service.js';
32
37
  import { parseJsonSafe } from '../util/json.js';
33
38
  import { getLogger } from '../util/logger.js';
34
- import { normalizeAssistantId } from '../util/platform.js';
39
+ import {
40
+ getAccessRequestPollIntervalMs,
41
+ getGuardianWaitUpdateInitialIntervalMs,
42
+ getGuardianWaitUpdateInitialWindowMs,
43
+ getGuardianWaitUpdateSteadyMaxIntervalMs,
44
+ getGuardianWaitUpdateSteadyMinIntervalMs,
45
+ getTtsPlaybackDelayMs,
46
+ getUserConsultationTimeoutMs,
47
+ } from './call-constants.js';
35
48
  import { CallController } from './call-controller.js';
36
49
  import { persistCallCompletionMessage } from './call-conversation-messages.js';
37
50
  import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
@@ -144,7 +157,7 @@ export function setRelayBroadcast(fn: (msg: import('../daemon/ipc-contract.js').
144
157
  /**
145
158
  * Manages a single WebSocket connection for one call.
146
159
  */
147
- export type RelayConnectionState = 'connected' | 'verification_pending' | 'disconnecting';
160
+ export type RelayConnectionState = 'connected' | 'verification_pending' | 'awaiting_name' | 'awaiting_guardian_decision' | 'disconnecting';
148
161
 
149
162
  export class RelayConnection {
150
163
  private ws: ServerWebSocket<RelayWebSocketData>;
@@ -180,6 +193,34 @@ export class RelayConnection {
180
193
  private inviteRedemptionAssistantId: string | null = null;
181
194
  private inviteRedemptionFromNumber: string | null = null;
182
195
  private inviteRedemptionCodeLength = 6;
196
+ private inviteRedemptionFriendName: string | null = null;
197
+ private inviteRedemptionGuardianName: string | null = null;
198
+
199
+ // In-call guardian approval wait state (friend-initiated)
200
+ private accessRequestWaitActive = false;
201
+ private accessRequestId: string | null = null;
202
+ private accessRequestAssistantId: string | null = null;
203
+ private accessRequestFromNumber: string | null = null;
204
+ private accessRequestPollTimer: ReturnType<typeof setInterval> | null = null;
205
+ private accessRequestTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
206
+ private accessRequestCallerName: string | null = null;
207
+
208
+ // Name capture timeout (unknown inbound callers)
209
+ private nameCaptureTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
210
+
211
+ // Guardian wait heartbeat state
212
+ private accessRequestHeartbeatTimer: ReturnType<typeof setTimeout> | null = null;
213
+ private accessRequestWaitStartedAt: number = 0;
214
+ private heartbeatSequence = 0;
215
+
216
+ // In-wait prompt handling state
217
+ private lastInWaitReplyAt = 0;
218
+ private static readonly IN_WAIT_REPLY_COOLDOWN_MS = 3000;
219
+
220
+ // Callback offer state (in-memory per-call)
221
+ private callbackOfferMade = false;
222
+ private callbackOptIn = false;
223
+ private callbackHandoffNotified = false;
183
224
 
184
225
  constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
185
226
  this.ws = ws;
@@ -304,6 +345,23 @@ export class RelayConnection {
304
345
  this.controller.destroy();
305
346
  this.controller = null;
306
347
  }
348
+ if (this.accessRequestPollTimer) {
349
+ clearInterval(this.accessRequestPollTimer);
350
+ this.accessRequestPollTimer = null;
351
+ }
352
+ if (this.accessRequestTimeoutTimer) {
353
+ clearTimeout(this.accessRequestTimeoutTimer);
354
+ this.accessRequestTimeoutTimer = null;
355
+ }
356
+ if (this.accessRequestHeartbeatTimer) {
357
+ clearTimeout(this.accessRequestHeartbeatTimer);
358
+ this.accessRequestHeartbeatTimer = null;
359
+ }
360
+ if (this.nameCaptureTimeoutTimer) {
361
+ clearTimeout(this.nameCaptureTimeoutTimer);
362
+ this.nameCaptureTimeoutTimer = null;
363
+ }
364
+ this.accessRequestWaitActive = false;
307
365
  this.abortController.abort();
308
366
  log.info({ callSessionId: this.callSessionId }, 'RelayConnection destroyed');
309
367
  }
@@ -315,6 +373,19 @@ export class RelayConnection {
315
373
  * we still finalize the call lifecycle from the relay close signal.
316
374
  */
317
375
  handleTransportClosed(code?: number, reason?: string): void {
376
+ // If the call was still in guardian-wait with callback opt-in, emit the
377
+ // handoff notification before cleaning up wait state.
378
+ if (this.accessRequestWaitActive && this.callbackOptIn) {
379
+ this.emitAccessRequestCallbackHandoff('transport_closed');
380
+ }
381
+
382
+ // Clean up access request wait state on disconnect to stop polling
383
+ this.clearAccessRequestWait();
384
+ if (this.nameCaptureTimeoutTimer) {
385
+ clearTimeout(this.nameCaptureTimeoutTimer);
386
+ this.nameCaptureTimeoutTimer = null;
387
+ }
388
+
318
389
  const session = getCallSession(this.callSessionId);
319
390
  if (!session) return;
320
391
  if (isTerminalState(session.status)) return;
@@ -427,7 +498,7 @@ export class RelayConnection {
427
498
  // calls (created via createInboundVoiceSession) never do. Relying on
428
499
  // task == null is unreliable: task-less outbound sessions would
429
500
  // incorrectly bypass outbound verification.
430
- const assistantId = normalizeAssistantId(session?.assistantId ?? 'self');
501
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
431
502
  const isInbound = session?.initiatedFromConversationId == null;
432
503
 
433
504
  // Create and attach the session-backed voice controller. Seed guardian
@@ -500,89 +571,80 @@ export class RelayConnection {
500
571
  const pendingChallenge = getPendingChallenge(assistantId, 'voice');
501
572
 
502
573
  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
- );
574
+ // Before entering the name capture flow, check if there is an
575
+ // active voice invite bound to the caller's phone number. If so,
576
+ // enter the invite redemption subflow instead.
577
+ let voiceInvites: ReturnType<typeof findActiveVoiceInvites> = [];
578
+ try {
579
+ voiceInvites = findActiveVoiceInvites({
580
+ assistantId,
581
+ expectedExternalUserId: msg.from,
582
+ });
583
+ } catch (err) {
584
+ log.warn({ err, callSessionId: this.callSessionId }, 'Failed to check voice invites for unknown caller');
585
+ }
511
586
 
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
- }
587
+ // Exclude invites that are past their expiresAt even if the DB
588
+ // status hasn't been lazily flipped to 'expired' yet.
589
+ const now = Date.now();
590
+ const nonExpiredInvites = voiceInvites.filter(i => !i.expiresAt || i.expiresAt > now);
591
+
592
+ // Blocked members get immediate denial — the guardian already made
593
+ // an explicit decision to block them. This must be checked before
594
+ // invite redemption so a blocked caller cannot bypass the block by
595
+ // redeeming an active invite.
596
+ if (actorTrust.memberRecord?.status === 'blocked') {
597
+ log.info(
598
+ { callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
599
+ 'Inbound voice ACL: blocked caller denied',
600
+ );
522
601
 
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);
602
+ recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
603
+ from: msg.from,
604
+ trustClass: actorTrust.trustClass,
605
+ denialReason: actorTrust.denialReason,
606
+ });
527
607
 
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
- }
608
+ this.sendTextToken('This number is not authorized to use this assistant.', true);
609
+
610
+ this.connectionState = 'disconnecting';
611
+
612
+ updateCallSession(this.callSessionId, {
613
+ status: 'failed',
614
+ endedAt: Date.now(),
615
+ lastError: 'Inbound voice ACL: caller blocked',
616
+ });
617
+
618
+ setTimeout(() => {
619
+ this.endSession('Inbound voice ACL denied — blocked');
620
+ }, getTtsPlaybackDelayMs());
621
+ return;
622
+ }
623
+
624
+ if (nonExpiredInvites.length > 0) {
625
+ // Use the first matching invite's metadata for personalized prompts
626
+ const matchedInvite = nonExpiredInvites[0];
627
+ log.info(
628
+ { callSessionId: this.callSessionId, from: msg.from },
629
+ 'Inbound voice ACL: unknown caller has active voice invite — entering redemption flow',
630
+ );
631
+ this.startInviteRedemption(assistantId, msg.from, matchedInvite.friendName, matchedInvite.guardianName);
632
+ return;
536
633
  }
537
634
 
635
+ // Unknown/revoked/pending callers enter the name capture + guardian
636
+ // approval wait flow instead of being hard-rejected.
538
637
  log.info(
539
638
  { callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
540
- 'Inbound voice ACL: unknown caller denied',
639
+ 'Inbound voice ACL: unknown caller — entering name capture flow',
541
640
  );
542
641
 
543
- recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
642
+ recordCallEvent(this.callSessionId, 'inbound_acl_name_capture_started', {
544
643
  from: msg.from,
545
644
  trustClass: actorTrust.trustClass,
546
- denialReason: actorTrust.denialReason,
547
- });
548
-
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
645
  });
582
646
 
583
- setTimeout(() => {
584
- this.endSession('Inbound voice ACL denied');
585
- }, 3000);
647
+ this.startNameCapture(assistantId, msg.from);
586
648
  return;
587
649
  }
588
650
 
@@ -614,7 +676,7 @@ export class RelayConnection {
614
676
 
615
677
  setTimeout(() => {
616
678
  this.endSession('Inbound voice ACL: member policy deny');
617
- }, 3000);
679
+ }, getTtsPlaybackDelayMs());
618
680
  return;
619
681
  }
620
682
 
@@ -646,7 +708,7 @@ export class RelayConnection {
646
708
 
647
709
  setTimeout(() => {
648
710
  this.endSession('Inbound voice ACL: member policy escalate');
649
- }, 3000);
711
+ }, getTtsPlaybackDelayMs());
650
712
  return;
651
713
  }
652
714
 
@@ -910,7 +972,7 @@ export class RelayConnection {
910
972
 
911
973
  setTimeout(() => {
912
974
  this.endSession('Guardian verification succeeded');
913
- }, 3000);
975
+ }, getTtsPlaybackDelayMs());
914
976
  } else {
915
977
  // Inbound: proceed to normal call flow
916
978
  if (this.controller) {
@@ -981,7 +1043,7 @@ export class RelayConnection {
981
1043
 
982
1044
  setTimeout(() => {
983
1045
  this.endSession('Guardian verification failed');
984
- }, 2000);
1046
+ }, getTtsPlaybackDelayMs());
985
1047
  } else {
986
1048
  const retryText = isOutbound
987
1049
  ? composeVerificationVoice(GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_RETRY, { codeDigits })
@@ -1001,13 +1063,15 @@ export class RelayConnection {
1001
1063
  * who has an active voice invite. Prompts the caller to enter their
1002
1064
  * invite code via DTMF or speech.
1003
1065
  */
1004
- private startInviteRedemption(assistantId: string, fromNumber: string): void {
1066
+ private startInviteRedemption(assistantId: string, fromNumber: string, friendName: string | null, guardianName: string | null): void {
1005
1067
  this.inviteRedemptionActive = true;
1006
1068
  this.inviteRedemptionAssistantId = assistantId;
1007
1069
  this.inviteRedemptionFromNumber = fromNumber;
1070
+ this.inviteRedemptionFriendName = friendName;
1071
+ this.inviteRedemptionGuardianName = guardianName;
1008
1072
  this.connectionState = 'verification_pending';
1009
1073
  this.verificationAttempts = 0;
1010
- this.verificationMaxAttempts = 3;
1074
+ this.verificationMaxAttempts = 1;
1011
1075
  this.inviteRedemptionCodeLength = 6;
1012
1076
  this.dtmfBuffer = '';
1013
1077
 
@@ -1017,8 +1081,10 @@ export class RelayConnection {
1017
1081
  maxAttempts: this.verificationMaxAttempts,
1018
1082
  });
1019
1083
 
1084
+ const displayFriend = friendName ?? 'there';
1085
+ const displayGuardian = guardianName ?? 'your contact';
1020
1086
  this.sendTextToken(
1021
- 'Please enter your 6-digit invite code using your keypad, or speak the digits now.',
1087
+ `Welcome ${displayFriend}. Please enter the 6-digit code that ${displayGuardian} provided you to verify your identity.`,
1022
1088
  true,
1023
1089
  );
1024
1090
 
@@ -1028,6 +1094,459 @@ export class RelayConnection {
1028
1094
  );
1029
1095
  }
1030
1096
 
1097
+ /**
1098
+ * Enter the name capture subflow for unknown inbound callers.
1099
+ * Prompts the caller to provide their name so we can include it
1100
+ * in the guardian notification.
1101
+ */
1102
+ private startNameCapture(assistantId: string, fromNumber: string): void {
1103
+ this.accessRequestAssistantId = assistantId;
1104
+ this.accessRequestFromNumber = fromNumber;
1105
+ this.connectionState = 'awaiting_name';
1106
+
1107
+ this.sendTextToken(
1108
+ "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?",
1109
+ true,
1110
+ );
1111
+
1112
+ // Start a timeout so silent callers don't keep the call open indefinitely.
1113
+ // Uses a 30-second window — enough time to speak a name but short enough
1114
+ // to avoid wasting resources on callers who never respond.
1115
+ const NAME_CAPTURE_TIMEOUT_MS = 30_000;
1116
+ this.nameCaptureTimeoutTimer = setTimeout(() => {
1117
+ if (this.connectionState !== 'awaiting_name') return;
1118
+ this.handleNameCaptureTimeout();
1119
+ }, NAME_CAPTURE_TIMEOUT_MS);
1120
+
1121
+ log.info(
1122
+ { callSessionId: this.callSessionId, assistantId, timeoutMs: NAME_CAPTURE_TIMEOUT_MS },
1123
+ 'Name capture started for unknown inbound caller',
1124
+ );
1125
+ }
1126
+
1127
+ /**
1128
+ * Handle the caller's name response during the name capture subflow.
1129
+ * Creates a canonical access request, notifies the guardian, and
1130
+ * enters the bounded wait loop for the guardian decision.
1131
+ */
1132
+ private handleNameCaptureResponse(callerName: string): void {
1133
+ if (!this.accessRequestAssistantId || !this.accessRequestFromNumber) {
1134
+ return;
1135
+ }
1136
+
1137
+ // Clear the name capture timeout since the caller responded.
1138
+ if (this.nameCaptureTimeoutTimer) {
1139
+ clearTimeout(this.nameCaptureTimeoutTimer);
1140
+ this.nameCaptureTimeoutTimer = null;
1141
+ }
1142
+
1143
+ this.accessRequestCallerName = callerName;
1144
+
1145
+ recordCallEvent(this.callSessionId, 'inbound_acl_name_captured', {
1146
+ from: this.accessRequestFromNumber,
1147
+ callerName,
1148
+ });
1149
+
1150
+ // Create canonical access request and notify the guardian, including
1151
+ // the caller's spoken name and voice channel metadata.
1152
+ try {
1153
+ const accessResult = notifyGuardianOfAccessRequest({
1154
+ canonicalAssistantId: this.accessRequestAssistantId,
1155
+ sourceChannel: 'voice',
1156
+ externalChatId: this.accessRequestFromNumber,
1157
+ senderExternalUserId: this.accessRequestFromNumber,
1158
+ senderName: callerName,
1159
+ });
1160
+
1161
+ if (accessResult.notified) {
1162
+ this.accessRequestId = accessResult.requestId;
1163
+ log.info(
1164
+ { callSessionId: this.callSessionId, requestId: accessResult.requestId, callerName },
1165
+ 'Guardian notified of voice access request with caller name',
1166
+ );
1167
+ } else {
1168
+ log.warn(
1169
+ { callSessionId: this.callSessionId },
1170
+ 'Failed to notify guardian of voice access request — no sender ID',
1171
+ );
1172
+ }
1173
+ } catch (err) {
1174
+ log.error({ err, callSessionId: this.callSessionId }, 'Failed to create access request for voice caller');
1175
+ }
1176
+
1177
+ // If the access request was not successfully created (notifyGuardianOfAccessRequest
1178
+ // threw or returned notified: false), fail closed rather than leaving the caller
1179
+ // stuck on hold with no guardian poll target.
1180
+ if (!this.accessRequestId) {
1181
+ log.warn(
1182
+ { callSessionId: this.callSessionId },
1183
+ 'Access request ID is null after notification attempt — failing closed',
1184
+ );
1185
+ this.handleAccessRequestTimeout();
1186
+ return;
1187
+ }
1188
+
1189
+ // Enter the bounded wait loop for the guardian decision
1190
+ this.startAccessRequestWait();
1191
+ }
1192
+
1193
+ /**
1194
+ * Start a bounded in-call wait loop polling the canonical request
1195
+ * status until approved, denied, or timeout.
1196
+ */
1197
+ private startAccessRequestWait(): void {
1198
+ this.accessRequestWaitActive = true;
1199
+ this.connectionState = 'awaiting_guardian_decision';
1200
+
1201
+ const timeoutMs = getUserConsultationTimeoutMs();
1202
+ const pollIntervalMs = getAccessRequestPollIntervalMs();
1203
+
1204
+ const guardianLabel = this.resolveGuardianLabel();
1205
+ this.sendTextToken(
1206
+ `Thank you. I've let ${guardianLabel} know. Please hold while I check if I have permission to speak with you.`,
1207
+ true,
1208
+ );
1209
+
1210
+ updateCallSession(this.callSessionId, { status: 'waiting_on_user' });
1211
+
1212
+ // Start the heartbeat timer for periodic progress updates
1213
+ this.accessRequestWaitStartedAt = Date.now();
1214
+ this.heartbeatSequence = 0;
1215
+ this.scheduleNextHeartbeat();
1216
+
1217
+ // Poll the canonical request status
1218
+ this.accessRequestPollTimer = setInterval(() => {
1219
+ if (!this.accessRequestWaitActive || !this.accessRequestId) {
1220
+ this.clearAccessRequestWait();
1221
+ return;
1222
+ }
1223
+
1224
+ const request = getCanonicalGuardianRequest(this.accessRequestId);
1225
+ if (!request) {
1226
+ return;
1227
+ }
1228
+
1229
+ if (request.status === 'approved') {
1230
+ this.handleAccessRequestApproved();
1231
+ } else if (request.status === 'denied') {
1232
+ this.handleAccessRequestDenied();
1233
+ }
1234
+ // 'pending' continues polling; 'expired'/'cancelled' handled by timeout
1235
+ }, pollIntervalMs);
1236
+
1237
+ // Timeout: give up waiting for the guardian
1238
+ this.accessRequestTimeoutTimer = setTimeout(() => {
1239
+ if (!this.accessRequestWaitActive) return;
1240
+
1241
+ log.info(
1242
+ { callSessionId: this.callSessionId, requestId: this.accessRequestId },
1243
+ 'Access request in-call wait timed out',
1244
+ );
1245
+
1246
+ this.handleAccessRequestTimeout();
1247
+ }, timeoutMs);
1248
+
1249
+ log.info(
1250
+ { callSessionId: this.callSessionId, requestId: this.accessRequestId, timeoutMs },
1251
+ 'Access request in-call wait started',
1252
+ );
1253
+ }
1254
+
1255
+ /**
1256
+ * Clean up access request wait state (timers, flags).
1257
+ */
1258
+ private clearAccessRequestWait(): void {
1259
+ this.accessRequestWaitActive = false;
1260
+ if (this.accessRequestPollTimer) {
1261
+ clearInterval(this.accessRequestPollTimer);
1262
+ this.accessRequestPollTimer = null;
1263
+ }
1264
+ if (this.accessRequestTimeoutTimer) {
1265
+ clearTimeout(this.accessRequestTimeoutTimer);
1266
+ this.accessRequestTimeoutTimer = null;
1267
+ }
1268
+ if (this.accessRequestHeartbeatTimer) {
1269
+ clearTimeout(this.accessRequestHeartbeatTimer);
1270
+ this.accessRequestHeartbeatTimer = null;
1271
+ }
1272
+ }
1273
+
1274
+ /**
1275
+ * Handle an approved access request: activate the caller as a trusted
1276
+ * contact, update runtime context, and continue with normal call flow.
1277
+ */
1278
+ private handleAccessRequestApproved(): void {
1279
+ this.clearAccessRequestWait();
1280
+ this.connectionState = 'connected';
1281
+
1282
+ const assistantId = this.accessRequestAssistantId!;
1283
+ const fromNumber = this.accessRequestFromNumber!;
1284
+ const callerName = this.accessRequestCallerName;
1285
+
1286
+ recordCallEvent(this.callSessionId, 'inbound_acl_access_approved', {
1287
+ from: fromNumber,
1288
+ callerName,
1289
+ requestId: this.accessRequestId,
1290
+ });
1291
+
1292
+ // Activate the caller as a trusted contact via the existing upsert path
1293
+ try {
1294
+ upsertMember({
1295
+ assistantId,
1296
+ sourceChannel: 'voice',
1297
+ externalUserId: fromNumber,
1298
+ externalChatId: fromNumber,
1299
+ displayName: callerName ?? undefined,
1300
+ status: 'active',
1301
+ policy: 'allow',
1302
+ });
1303
+ } catch (err) {
1304
+ log.error({ err, callSessionId: this.callSessionId }, 'Failed to activate voice caller as trusted contact');
1305
+ }
1306
+
1307
+ // Re-resolve actor trust now that the member is active
1308
+ const updatedTrust = resolveActorTrust({
1309
+ assistantId,
1310
+ sourceChannel: 'voice',
1311
+ externalChatId: fromNumber,
1312
+ senderExternalUserId: fromNumber,
1313
+ });
1314
+
1315
+ if (this.controller) {
1316
+ this.controller.setGuardianContext(
1317
+ toGuardianRuntimeContextFromTrust(updatedTrust, fromNumber),
1318
+ );
1319
+ }
1320
+
1321
+ updateCallSession(this.callSessionId, { status: 'in_progress' });
1322
+
1323
+ log.info(
1324
+ { callSessionId: this.callSessionId, from: fromNumber },
1325
+ 'Access request approved — caller activated and continuing call',
1326
+ );
1327
+
1328
+ // Use handleUserInstruction to deliver the approval-aware greeting
1329
+ // through the normal session pipeline.
1330
+ const guardianLabel = this.resolveGuardianLabel();
1331
+ if (this.controller) {
1332
+ this.controller.handleUserInstruction(
1333
+ `Great, ${guardianLabel} approved! Now how can I help you?`,
1334
+ ).catch((err) => {
1335
+ log.error({ err, callSessionId: this.callSessionId }, 'Failed to deliver approval greeting');
1336
+ });
1337
+ }
1338
+ }
1339
+
1340
+ /**
1341
+ * Handle a denied access request: deliver deterministic copy and hang up.
1342
+ */
1343
+ private handleAccessRequestDenied(): void {
1344
+ this.clearAccessRequestWait();
1345
+
1346
+ const guardianLabel = this.resolveGuardianLabel();
1347
+
1348
+ recordCallEvent(this.callSessionId, 'inbound_acl_access_denied', {
1349
+ from: this.accessRequestFromNumber,
1350
+ requestId: this.accessRequestId,
1351
+ });
1352
+
1353
+ this.sendTextToken(
1354
+ `Sorry, ${guardianLabel} says I'm not allowed to speak with you. Goodbye.`,
1355
+ true,
1356
+ );
1357
+
1358
+ this.connectionState = 'disconnecting';
1359
+
1360
+ updateCallSession(this.callSessionId, {
1361
+ status: 'failed',
1362
+ endedAt: Date.now(),
1363
+ lastError: 'Inbound voice ACL: guardian denied access request',
1364
+ });
1365
+
1366
+ log.info(
1367
+ { callSessionId: this.callSessionId },
1368
+ 'Access request denied — ending call',
1369
+ );
1370
+
1371
+ setTimeout(() => {
1372
+ this.endSession('Access request denied');
1373
+ }, getTtsPlaybackDelayMs());
1374
+ }
1375
+
1376
+ /**
1377
+ * Handle an access request timeout: deliver deterministic copy and hang up.
1378
+ */
1379
+ private handleAccessRequestTimeout(): void {
1380
+ // Emit callback handoff notification before clearing wait state
1381
+ this.emitAccessRequestCallbackHandoff('timeout');
1382
+
1383
+ this.clearAccessRequestWait();
1384
+
1385
+ const guardianLabel = this.resolveGuardianLabel();
1386
+
1387
+ recordCallEvent(this.callSessionId, 'inbound_acl_access_timeout', {
1388
+ from: this.accessRequestFromNumber,
1389
+ requestId: this.accessRequestId,
1390
+ callbackOptIn: this.callbackOptIn,
1391
+ });
1392
+
1393
+ const callbackNote = this.callbackOptIn
1394
+ ? ` I've noted that you'd like a callback — I'll pass that along to ${guardianLabel}.`
1395
+ : '';
1396
+ this.sendTextToken(
1397
+ `Sorry, I can't get ahold of ${guardianLabel} right now. I'll let them know you called.${callbackNote}`,
1398
+ true,
1399
+ );
1400
+
1401
+ this.connectionState = 'disconnecting';
1402
+
1403
+ updateCallSession(this.callSessionId, {
1404
+ status: 'failed',
1405
+ endedAt: Date.now(),
1406
+ lastError: 'Inbound voice ACL: guardian approval wait timed out',
1407
+ });
1408
+
1409
+ log.info(
1410
+ { callSessionId: this.callSessionId },
1411
+ 'Access request timed out — ending call',
1412
+ );
1413
+
1414
+ setTimeout(() => {
1415
+ this.endSession('Access request timed out');
1416
+ }, getTtsPlaybackDelayMs());
1417
+ }
1418
+
1419
+ /**
1420
+ * Emit a callback handoff notification to the guardian when the caller
1421
+ * opted into a callback during guardian wait but the wait ended without
1422
+ * resolution (timeout or transport close).
1423
+ *
1424
+ * Idempotent: uses callbackHandoffNotified guard + deterministic dedupeKey
1425
+ * to ensure at most one notification per call/request.
1426
+ */
1427
+ private emitAccessRequestCallbackHandoff(reason: 'timeout' | 'transport_closed'): void {
1428
+ if (!this.callbackOptIn) return;
1429
+ if (!this.accessRequestId) return;
1430
+ if (this.callbackHandoffNotified) return;
1431
+
1432
+ this.callbackHandoffNotified = true;
1433
+
1434
+ const assistantId = this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
1435
+ const fromNumber = this.accessRequestFromNumber ?? null;
1436
+
1437
+ // Resolve canonical request for requestCode and conversationId
1438
+ const canonicalRequest = this.accessRequestId
1439
+ ? getCanonicalGuardianRequest(this.accessRequestId)
1440
+ : null;
1441
+
1442
+ // Resolve trusted-contact member reference when possible
1443
+ let requesterMemberId: string | null = null;
1444
+ if (fromNumber) {
1445
+ try {
1446
+ const member = findMember({
1447
+ assistantId,
1448
+ sourceChannel: 'voice',
1449
+ externalUserId: fromNumber,
1450
+ externalChatId: fromNumber,
1451
+ });
1452
+ if (member && member.status === 'active' && member.policy === 'allow') {
1453
+ requesterMemberId = member.id;
1454
+ }
1455
+ } catch (err) {
1456
+ log.warn({ err, callSessionId: this.callSessionId }, 'Failed to resolve member for callback handoff');
1457
+ }
1458
+ }
1459
+
1460
+ const dedupeKey = `access-request-callback-handoff:${this.accessRequestId}`;
1461
+ const sourceSessionId = canonicalRequest?.conversationId
1462
+ ?? `access-req-callback-${this.accessRequestId}`;
1463
+
1464
+ void emitNotificationSignal({
1465
+ sourceEventName: 'ingress.access_request.callback_handoff',
1466
+ sourceChannel: 'voice',
1467
+ sourceSessionId,
1468
+ assistantId,
1469
+ attentionHints: {
1470
+ requiresAction: false,
1471
+ urgency: 'medium',
1472
+ isAsyncBackground: true,
1473
+ visibleInSourceNow: false,
1474
+ },
1475
+ contextPayload: {
1476
+ requestId: this.accessRequestId,
1477
+ requestCode: canonicalRequest?.requestCode ?? null,
1478
+ callSessionId: this.callSessionId,
1479
+ sourceChannel: 'voice',
1480
+ reason,
1481
+ callbackOptIn: true,
1482
+ callerPhoneNumber: fromNumber,
1483
+ callerName: this.accessRequestCallerName ?? null,
1484
+ requesterExternalUserId: fromNumber,
1485
+ requesterChatId: fromNumber,
1486
+ requesterMemberId,
1487
+ requesterMemberSourceChannel: requesterMemberId ? 'voice' : null,
1488
+ },
1489
+ dedupeKey,
1490
+ }).then(() => {
1491
+ recordCallEvent(this.callSessionId, 'callback_handoff_notified', {
1492
+ requestId: this.accessRequestId,
1493
+ reason,
1494
+ requesterMemberId,
1495
+ });
1496
+ log.info(
1497
+ { callSessionId: this.callSessionId, requestId: this.accessRequestId, reason },
1498
+ 'Callback handoff notification emitted',
1499
+ );
1500
+ }).catch((err) => {
1501
+ recordCallEvent(this.callSessionId, 'callback_handoff_failed', {
1502
+ requestId: this.accessRequestId,
1503
+ reason,
1504
+ error: err instanceof Error ? err.message : String(err),
1505
+ });
1506
+ log.error(
1507
+ { err, callSessionId: this.callSessionId, requestId: this.accessRequestId },
1508
+ 'Failed to emit callback handoff notification',
1509
+ );
1510
+ });
1511
+ }
1512
+
1513
+ /**
1514
+ * Handle a name capture timeout: the caller never provided their name
1515
+ * within the allotted window. Deliver deterministic copy and hang up.
1516
+ */
1517
+ private handleNameCaptureTimeout(): void {
1518
+ if (this.nameCaptureTimeoutTimer) {
1519
+ clearTimeout(this.nameCaptureTimeoutTimer);
1520
+ this.nameCaptureTimeoutTimer = null;
1521
+ }
1522
+
1523
+ recordCallEvent(this.callSessionId, 'inbound_acl_name_capture_timeout', {
1524
+ from: this.accessRequestFromNumber,
1525
+ });
1526
+
1527
+ this.sendTextToken(
1528
+ "Sorry, I didn't catch your name. Please try calling back. Goodbye.",
1529
+ true,
1530
+ );
1531
+
1532
+ this.connectionState = 'disconnecting';
1533
+
1534
+ updateCallSession(this.callSessionId, {
1535
+ status: 'failed',
1536
+ endedAt: Date.now(),
1537
+ lastError: 'Inbound voice ACL: name capture timed out',
1538
+ });
1539
+
1540
+ log.info(
1541
+ { callSessionId: this.callSessionId },
1542
+ 'Name capture timed out — ending call',
1543
+ );
1544
+
1545
+ setTimeout(() => {
1546
+ this.endSession('Name capture timed out');
1547
+ }, getTtsPlaybackDelayMs());
1548
+ }
1549
+
1031
1550
  /**
1032
1551
  * Validate an entered invite code against active voice invites for the
1033
1552
  * caller. On success, create/activate the ingress member and transition
@@ -1073,45 +1592,306 @@ export class RelayConnection {
1073
1592
  this.startNormalCallFlow(this.controller, true);
1074
1593
  }
1075
1594
  } else {
1076
- this.verificationAttempts++;
1595
+ // On any invalid/expired code, emit exact deterministic failure copy and end call immediately.
1596
+ this.inviteRedemptionActive = false;
1077
1597
 
1078
- if (this.verificationAttempts >= this.verificationMaxAttempts) {
1079
- this.inviteRedemptionActive = false;
1598
+ recordCallEvent(this.callSessionId, 'invite_redemption_failed', {
1599
+ attempts: 1,
1600
+ });
1601
+ log.warn(
1602
+ { callSessionId: this.callSessionId },
1603
+ 'Voice invite redemption failed — invalid or expired code',
1604
+ );
1080
1605
 
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
- );
1606
+ const displayGuardian = this.inviteRedemptionGuardianName ?? 'your contact';
1607
+ this.sendTextToken(
1608
+ `Sorry, the code you provided is incorrect or has since expired. Please ask ${displayGuardian} for a new code. Goodbye.`,
1609
+ true,
1610
+ );
1088
1611
 
1089
- this.sendTextToken('Too many invalid attempts. Goodbye.', true);
1612
+ this.connectionState = 'disconnecting';
1090
1613
 
1091
- updateCallSession(this.callSessionId, {
1092
- status: 'failed',
1093
- endedAt: Date.now(),
1094
- lastError: 'Voice invite redemption failed — max attempts exceeded',
1614
+ updateCallSession(this.callSessionId, {
1615
+ status: 'failed',
1616
+ endedAt: Date.now(),
1617
+ lastError: 'Voice invite redemption failed — invalid or expired code',
1618
+ });
1619
+
1620
+ const failSession = getCallSession(this.callSessionId);
1621
+ if (failSession) {
1622
+ expirePendingQuestions(this.callSessionId);
1623
+ persistCallCompletionMessage(failSession.conversationId, this.callSessionId).catch((err) => {
1624
+ log.error({ err, conversationId: failSession.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
1095
1625
  });
1626
+ fireCallCompletionNotifier(failSession.conversationId, this.callSessionId);
1627
+ }
1096
1628
 
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);
1629
+ setTimeout(() => {
1630
+ this.endSession('Invite redemption failed');
1631
+ }, getTtsPlaybackDelayMs());
1632
+ }
1633
+ }
1634
+
1635
+ // ── Guardian wait UX layer ─────────────────────────────────────
1636
+
1637
+ /**
1638
+ * Resolve a human-readable guardian label for voice wait copy.
1639
+ * Prefers displayName from the guardian binding metadata, falls back
1640
+ * to @username, then "my guardian".
1641
+ */
1642
+ private resolveGuardianLabel(): string {
1643
+ const assistantId = this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
1644
+
1645
+ // Try the voice-channel binding first, then fall back to any active
1646
+ // binding for the assistant (mirrors the cross-channel fallback pattern
1647
+ // in access-request-helper.ts).
1648
+ let metadataJson: string | null = null;
1649
+ const voiceBinding = getGuardianBinding(assistantId, 'voice');
1650
+ if (voiceBinding?.metadataJson) {
1651
+ metadataJson = voiceBinding.metadataJson;
1652
+ } else {
1653
+ const allBindings = listActiveBindingsByAssistant(assistantId);
1654
+ if (allBindings.length > 0 && allBindings[0].metadataJson) {
1655
+ metadataJson = allBindings[0].metadataJson;
1656
+ }
1657
+ }
1658
+
1659
+ if (metadataJson) {
1660
+ try {
1661
+ const parsed = JSON.parse(metadataJson) as Record<string, unknown>;
1662
+ if (typeof parsed.displayName === 'string' && parsed.displayName.trim().length > 0) {
1663
+ return parsed.displayName.trim();
1664
+ }
1665
+ if (typeof parsed.username === 'string' && parsed.username.trim().length > 0) {
1666
+ return `@${parsed.username.trim()}`;
1667
+ }
1668
+ } catch {
1669
+ // ignore malformed metadata
1670
+ }
1671
+ }
1672
+ return 'my guardian';
1673
+ }
1674
+
1675
+ /**
1676
+ * Generate a non-repetitive heartbeat message for the caller based
1677
+ * on the current sequence counter and guardian label.
1678
+ */
1679
+ private getHeartbeatMessage(): string {
1680
+ const guardianLabel = this.resolveGuardianLabel();
1681
+ const seq = this.heartbeatSequence++;
1682
+ const messages = [
1683
+ `Still waiting to hear back from ${guardianLabel}. Thank you for your patience.`,
1684
+ `I'm still trying to reach ${guardianLabel}. One moment please.`,
1685
+ `Hang tight, still waiting on ${guardianLabel}.`,
1686
+ `Still checking with ${guardianLabel}. I appreciate you waiting.`,
1687
+ `I haven't heard back from ${guardianLabel} yet. Thanks for holding.`,
1688
+ ];
1689
+ return messages[seq % messages.length];
1690
+ }
1691
+
1692
+ /**
1693
+ * Schedule the next heartbeat update. Uses the initial fixed interval
1694
+ * during the initial window, then jitters between steady min/max.
1695
+ */
1696
+ private scheduleNextHeartbeat(): void {
1697
+ if (!this.accessRequestWaitActive) return;
1698
+
1699
+ const elapsed = Date.now() - this.accessRequestWaitStartedAt;
1700
+ const initialWindow = getGuardianWaitUpdateInitialWindowMs();
1701
+ const intervalMs = elapsed < initialWindow
1702
+ ? getGuardianWaitUpdateInitialIntervalMs()
1703
+ : getGuardianWaitUpdateSteadyMinIntervalMs() +
1704
+ Math.floor(Math.random() * Math.max(0, getGuardianWaitUpdateSteadyMaxIntervalMs() - getGuardianWaitUpdateSteadyMinIntervalMs()));
1705
+
1706
+ this.accessRequestHeartbeatTimer = setTimeout(() => {
1707
+ if (!this.accessRequestWaitActive) return;
1708
+
1709
+ const message = this.getHeartbeatMessage();
1710
+ this.sendTextToken(message, true);
1711
+
1712
+ recordCallEvent(this.callSessionId, 'voice_guardian_wait_heartbeat_sent', {
1713
+ sequence: this.heartbeatSequence - 1,
1714
+ message,
1715
+ });
1716
+
1717
+ log.debug(
1718
+ { callSessionId: this.callSessionId, sequence: this.heartbeatSequence - 1 },
1719
+ 'Guardian wait heartbeat sent',
1720
+ );
1721
+
1722
+ // Schedule the next heartbeat
1723
+ this.scheduleNextHeartbeat();
1724
+ }, intervalMs);
1725
+ }
1726
+
1727
+ /**
1728
+ * Classify a caller utterance during guardian wait into one of:
1729
+ * - 'empty': whitespace or noise
1730
+ * - 'patience_check': asking for status or checking in
1731
+ * - 'impatient': expressing frustration or wanting to end
1732
+ * - 'callback_opt_in': explicitly agreeing to a callback
1733
+ * - 'callback_decline': explicitly declining a callback
1734
+ * - 'neutral': anything else
1735
+ */
1736
+ private classifyWaitUtterance(text: string): 'empty' | 'patience_check' | 'impatient' | 'callback_opt_in' | 'callback_decline' | 'neutral' {
1737
+ const lower = text.toLowerCase().trim();
1738
+ if (lower.length === 0) return 'empty';
1739
+
1740
+ // Callback opt-in patterns (check before impatience to catch "yes call me back")
1741
+ if (this.callbackOfferMade) {
1742
+ if (/\b(yes|yeah|yep|sure|okay|ok|please)\b.*\b(call\s*(me\s*)?back|callback)\b/.test(lower)
1743
+ || /\b(call\s*(me\s*)?back|callback)\b.*\b(yes|yeah|please|sure)\b/.test(lower)
1744
+ || /^(yes|yeah|yep|sure|okay|ok|please)\s*[.,!]?\s*$/.test(lower)
1745
+ || /\bcall\s*(me\s*)?back\b/.test(lower)
1746
+ || /\bplease\s+do\b/.test(lower)) {
1747
+ return 'callback_opt_in';
1748
+ }
1749
+ if (/\b(no|nah|nope)\b/.test(lower)
1750
+ || /\bi('?ll| will)\s+hold\b/.test(lower)
1751
+ || /\bi('?ll| will)\s+wait\b/.test(lower)) {
1752
+ return 'callback_decline';
1753
+ }
1754
+ }
1755
+
1756
+ // Impatience patterns
1757
+ if (/\bhurry\s*(up)?\b/.test(lower)
1758
+ || /\btaking\s+(too\s+|so\s+)?long\b/.test(lower)
1759
+ || /\bforget\s+it\b/.test(lower)
1760
+ || /\bnever\s*mind\b/.test(lower)
1761
+ || /\bdon'?t\s+have\s+time\b/.test(lower)
1762
+ || /\bhow\s+much\s+longer\b/.test(lower)
1763
+ || /\bi('?m| am)\s+(getting\s+)?impatient\b/.test(lower)
1764
+ || /\bthis\s+is\s+(ridiculous|absurd|crazy)\b/.test(lower)
1765
+ || /\bcome\s+on\b/.test(lower)
1766
+ || /\bi\s+(gotta|have\s+to|need\s+to)\s+go\b/.test(lower)) {
1767
+ return 'impatient';
1768
+ }
1769
+
1770
+ // Patience check / status inquiry patterns
1771
+ if (/\bhello\??\s*$/.test(lower)
1772
+ || /\bstill\s+there\b/.test(lower)
1773
+ || /\bany\s+(update|news)\b/.test(lower)
1774
+ || /\bwhat('?s| is)\s+(happening|going\s+on)\b/.test(lower)
1775
+ || /\bare\s+you\s+still\b/.test(lower)
1776
+ || /\bhow\s+(long|much\s+longer)\b/.test(lower)
1777
+ || /\banyone\s+there\b/.test(lower)) {
1778
+ return 'patience_check';
1779
+ }
1780
+
1781
+ return 'neutral';
1782
+ }
1783
+
1784
+ /**
1785
+ * Handle a caller utterance during the guardian decision wait state.
1786
+ * Provides reassurance, impatience detection, and callback offer.
1787
+ */
1788
+ private handleWaitStatePrompt(text: string): void {
1789
+ const now = Date.now();
1790
+ const classification = this.classifyWaitUtterance(text);
1791
+
1792
+ recordCallEvent(this.callSessionId, 'voice_guardian_wait_prompt_classified', {
1793
+ classification,
1794
+ transcript: text,
1795
+ });
1796
+
1797
+ if (classification === 'empty') return;
1798
+
1799
+ const guardianLabel = this.resolveGuardianLabel();
1800
+
1801
+ // Callback decisions must always be processed regardless of cooldown —
1802
+ // the caller is answering a direct question and dropping their response
1803
+ // would silently discard their decision.
1804
+ switch (classification) {
1805
+ case 'callback_opt_in': {
1806
+ this.callbackOptIn = true;
1807
+ this.lastInWaitReplyAt = now;
1808
+ recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_set', {});
1809
+ if (this.accessRequestHeartbeatTimer) {
1810
+ clearTimeout(this.accessRequestHeartbeatTimer);
1811
+ this.accessRequestHeartbeatTimer = null;
1104
1812
  }
1813
+ this.sendTextToken(
1814
+ `Noted, I'll make sure ${guardianLabel} knows you'd like a callback. For now, I'll keep trying to reach them.`,
1815
+ true,
1816
+ );
1817
+ this.scheduleNextHeartbeat();
1818
+ return;
1819
+ }
1820
+ case 'callback_decline': {
1821
+ this.callbackOptIn = false;
1822
+ this.lastInWaitReplyAt = now;
1823
+ recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_declined', {});
1824
+ if (this.accessRequestHeartbeatTimer) {
1825
+ clearTimeout(this.accessRequestHeartbeatTimer);
1826
+ this.accessRequestHeartbeatTimer = null;
1827
+ }
1828
+ this.sendTextToken(
1829
+ `No problem, I'll keep holding. Still waiting on ${guardianLabel}.`,
1830
+ true,
1831
+ );
1832
+ this.scheduleNextHeartbeat();
1833
+ return;
1834
+ }
1835
+ default:
1836
+ break;
1837
+ }
1105
1838
 
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',
1839
+ // Enforce cooldown on non-callback utterances to prevent spam
1840
+ if (now - this.lastInWaitReplyAt < RelayConnection.IN_WAIT_REPLY_COOLDOWN_MS) {
1841
+ log.debug({ callSessionId: this.callSessionId }, 'In-wait reply suppressed by cooldown');
1842
+ return;
1843
+ }
1844
+ this.lastInWaitReplyAt = now;
1845
+
1846
+ switch (classification) {
1847
+ case 'impatient': {
1848
+ if (this.accessRequestHeartbeatTimer) {
1849
+ clearTimeout(this.accessRequestHeartbeatTimer);
1850
+ this.accessRequestHeartbeatTimer = null;
1851
+ }
1852
+ if (!this.callbackOfferMade) {
1853
+ this.callbackOfferMade = true;
1854
+ recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_offer_sent', {});
1855
+ this.sendTextToken(
1856
+ `I understand this is taking a while. I can have ${guardianLabel} call you back once I hear from them. Would you like that, or would you prefer to keep holding?`,
1857
+ true,
1858
+ );
1859
+ } else {
1860
+ // Already offered callback — just reassure
1861
+ this.sendTextToken(
1862
+ `I hear you, I'm sorry for the wait. Still trying to reach ${guardianLabel}.`,
1863
+ true,
1864
+ );
1865
+ }
1866
+ this.scheduleNextHeartbeat();
1867
+ break;
1868
+ }
1869
+ case 'patience_check': {
1870
+ // Immediate reassurance — reset the heartbeat timer so we
1871
+ // don't double up with a scheduled heartbeat
1872
+ if (this.accessRequestHeartbeatTimer) {
1873
+ clearTimeout(this.accessRequestHeartbeatTimer);
1874
+ this.accessRequestHeartbeatTimer = null;
1875
+ }
1876
+ this.sendTextToken(
1877
+ `Yes, I'm still here. Still waiting to hear back from ${guardianLabel}.`,
1878
+ true,
1113
1879
  );
1114
- this.sendTextToken('Invalid code. Please try again.', true);
1880
+ this.scheduleNextHeartbeat();
1881
+ break;
1882
+ }
1883
+ case 'neutral':
1884
+ default: {
1885
+ if (this.accessRequestHeartbeatTimer) {
1886
+ clearTimeout(this.accessRequestHeartbeatTimer);
1887
+ this.accessRequestHeartbeatTimer = null;
1888
+ }
1889
+ this.sendTextToken(
1890
+ `Thanks for that. I'm still waiting on ${guardianLabel}. I'll let you know as soon as I hear back.`,
1891
+ true,
1892
+ );
1893
+ this.scheduleNextHeartbeat();
1894
+ break;
1115
1895
  }
1116
1896
  }
1117
1897
  }
@@ -1126,6 +1906,30 @@ export class RelayConnection {
1126
1906
  return;
1127
1907
  }
1128
1908
 
1909
+ // During name capture, the caller's response is their name.
1910
+ if (this.connectionState === 'awaiting_name') {
1911
+ const callerName = msg.voicePrompt.trim();
1912
+ if (!callerName) {
1913
+ // Whitespace-only or empty transcript (e.g. silence/noise) —
1914
+ // keep waiting for a real name. The name-capture timeout will
1915
+ // still fire if the caller never provides one.
1916
+ return;
1917
+ }
1918
+ log.info(
1919
+ { callSessionId: this.callSessionId, callerName },
1920
+ 'Name captured from unknown inbound caller',
1921
+ );
1922
+ this.handleNameCaptureResponse(callerName);
1923
+ return;
1924
+ }
1925
+
1926
+ // During guardian decision wait, classify caller speech for
1927
+ // reassurance, impatience detection, and callback offer.
1928
+ if (this.connectionState === 'awaiting_guardian_decision') {
1929
+ this.handleWaitStatePrompt(msg.voicePrompt);
1930
+ return;
1931
+ }
1932
+
1129
1933
  // During guardian verification (inbound or outbound), attempt to parse
1130
1934
  // spoken digits from the transcript and validate them.
1131
1935
  if (this.connectionState === 'verification_pending' && this.guardianVerificationActive) {
@@ -1256,6 +2060,11 @@ export class RelayConnection {
1256
2060
  return;
1257
2061
  }
1258
2062
 
2063
+ // Ignore DTMF during name capture and guardian decision wait
2064
+ if (this.connectionState === 'awaiting_name' || this.connectionState === 'awaiting_guardian_decision') {
2065
+ return;
2066
+ }
2067
+
1259
2068
  log.info(
1260
2069
  { callSessionId: this.callSessionId, digit: msg.digit },
1261
2070
  'DTMF digit received',
@@ -1354,7 +2163,7 @@ export class RelayConnection {
1354
2163
  // End the call with failed status after TTS plays
1355
2164
  setTimeout(() => {
1356
2165
  this.endSession('Verification failed');
1357
- }, 2000);
2166
+ }, getTtsPlaybackDelayMs());
1358
2167
  } else {
1359
2168
  // Allow another attempt
1360
2169
  log.info(