@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
@@ -21,13 +21,20 @@ import {
21
21
  applyCanonicalGuardianDecision,
22
22
  type CanonicalDecisionResult,
23
23
  } from '../approvals/guardian-decision-primitive.js';
24
- import type { ActorContext, ChannelDeliveryContext } from '../approvals/guardian-request-resolvers.js';
24
+ import type { ActorContext, ChannelDeliveryContext, ResolverEmissionContext } from '../approvals/guardian-request-resolvers.js';
25
25
  import {
26
26
  type CanonicalGuardianRequest,
27
27
  getCanonicalGuardianRequest,
28
28
  getCanonicalGuardianRequestByCode,
29
29
  listCanonicalGuardianRequests,
30
30
  } from '../memory/canonical-guardian-store.js';
31
+ import {
32
+ buildGuardianCodeOnlyClarification,
33
+ buildGuardianDisambiguationExample,
34
+ buildGuardianDisambiguationLabel,
35
+ buildGuardianInvalidActionReply,
36
+ resolveGuardianInstructionModeForRequest,
37
+ } from '../notifications/guardian-question-mode.js';
31
38
  import { getLogger } from '../util/logger.js';
32
39
  import { runApprovalConversationTurn } from './approval-conversation-turn.js';
33
40
  import type { ApprovalAction } from './channel-approval-types.js';
@@ -60,6 +67,8 @@ export interface GuardianReplyContext {
60
67
  approvalConversationGenerator?: ApprovalConversationGenerator;
61
68
  /** Optional channel delivery context for resolver-driven side effects. */
62
69
  channelDeliveryContext?: ChannelDeliveryContext;
70
+ /** Optional emission context threaded to handleConfirmationResponse for correct source attribution. */
71
+ emissionContext?: ResolverEmissionContext;
63
72
  }
64
73
 
65
74
  export type GuardianReplyResultType =
@@ -235,9 +244,11 @@ function notConsumed(): GuardianReplyResult {
235
244
  export async function routeGuardianReply(
236
245
  ctx: GuardianReplyContext,
237
246
  ): Promise<GuardianReplyResult> {
238
- const { messageText, actor, conversationId, callbackData, approvalConversationGenerator, channelDeliveryContext } = ctx;
247
+ const { messageText, actor, conversationId, callbackData, approvalConversationGenerator, channelDeliveryContext, emissionContext } = ctx;
239
248
  const pendingRequests = findPendingCanonicalRequests(actor, ctx.pendingRequestIds, conversationId);
240
- const scopedPendingRequestIds = ctx.pendingRequestIds ? new Set(ctx.pendingRequestIds) : null;
249
+ const scopedPendingRequestIds = ctx.pendingRequestIds && ctx.pendingRequestIds.length > 0
250
+ ? new Set(ctx.pendingRequestIds)
251
+ : null;
241
252
 
242
253
  // ── 1. Deterministic callback parsing (button presses) ──
243
254
  // No conversationId scoping here — the guardian's reply comes from a
@@ -247,7 +258,7 @@ export async function routeGuardianReply(
247
258
  if (callbackData) {
248
259
  const parsed = parseCallbackAction(callbackData);
249
260
  if (parsed) {
250
- return applyDecision(parsed.requestId, parsed.action, actor, undefined, channelDeliveryContext);
261
+ return applyDecision(parsed.requestId, parsed.action, actor, undefined, channelDeliveryContext, emissionContext);
251
262
  }
252
263
  }
253
264
 
@@ -280,7 +291,7 @@ export async function routeGuardianReply(
280
291
  consumed: true,
281
292
  type: 'canonical_decision_stale',
282
293
  requestId: request.id,
283
- replyText: failureReplyText('already_resolved', request.requestCode),
294
+ replyText: failureReplyText('already_resolved', request.requestCode, request),
284
295
  };
285
296
  }
286
297
 
@@ -333,7 +344,7 @@ export async function routeGuardianReply(
333
344
  // If the text indicates rejection, use reject; otherwise approve_once.
334
345
  const action = inferActionFromText(codeResult.remainingText);
335
346
 
336
- return applyDecision(request.id, action, actor, codeResult.remainingText, channelDeliveryContext);
347
+ return applyDecision(request.id, action, actor, codeResult.remainingText, channelDeliveryContext, emissionContext);
337
348
  }
338
349
  }
339
350
 
@@ -375,6 +386,7 @@ export async function routeGuardianReply(
375
386
  actor,
376
387
  messageText,
377
388
  channelDeliveryContext,
389
+ emissionContext,
378
390
  );
379
391
  }
380
392
 
@@ -475,12 +487,13 @@ export async function routeGuardianReply(
475
487
  };
476
488
  }
477
489
 
478
- const result = await applyDecision(targetId, decisionAction, actor, messageText, channelDeliveryContext);
490
+ const result = await applyDecision(targetId, decisionAction, actor, messageText, channelDeliveryContext, emissionContext);
479
491
 
480
492
  // Attach the engine's reply text for stale/expired/identity-mismatch cases,
481
- // but preserve the explicit failure text when the resolver failed — the engine
482
- // reply is typically an affirmative confirmation that would be misleading.
483
- if (engineResult.replyText && result.type !== 'canonical_resolver_failed') {
493
+ // but preserve resolver-authored replies (for example verification codes)
494
+ // and explicit resolver-failure text.
495
+ const hasResolverReplyText = Boolean(result.canonicalResult?.applied && result.canonicalResult.resolverReplyText);
496
+ if (engineResult.replyText && result.type !== 'canonical_resolver_failed' && !hasResolverReplyText) {
484
497
  result.replyText = engineResult.replyText;
485
498
  }
486
499
 
@@ -504,6 +517,7 @@ async function applyDecision(
504
517
  actor: ActorContext,
505
518
  userText?: string,
506
519
  channelDeliveryContext?: ChannelDeliveryContext,
520
+ emissionContext?: ResolverEmissionContext,
507
521
  ): Promise<GuardianReplyResult> {
508
522
  const canonicalResult = await applyCanonicalGuardianDecision({
509
523
  requestId,
@@ -511,6 +525,7 @@ async function applyDecision(
511
525
  actorContext: actor,
512
526
  userText,
513
527
  channelDeliveryContext,
528
+ emissionContext,
514
529
  });
515
530
 
516
531
  if (canonicalResult.applied) {
@@ -549,6 +564,7 @@ async function applyDecision(
549
564
  decisionApplied: true,
550
565
  consumed: true,
551
566
  type: 'canonical_decision_applied',
567
+ ...(canonicalResult.resolverReplyText ? { replyText: canonicalResult.resolverReplyText } : {}),
552
568
  requestId,
553
569
  canonicalResult,
554
570
  };
@@ -570,13 +586,15 @@ async function applyDecision(
570
586
  return notConsumed();
571
587
  }
572
588
 
589
+ const request = getCanonicalGuardianRequest(requestId);
590
+
573
591
  return {
574
592
  decisionApplied: false,
575
593
  consumed: true,
576
594
  type: 'canonical_decision_stale',
577
595
  requestId,
578
596
  canonicalResult,
579
- replyText: failureReplyText(canonicalResult.reason),
597
+ replyText: failureReplyText(canonicalResult.reason, request?.requestCode, request ?? undefined),
580
598
  };
581
599
  }
582
600
 
@@ -643,6 +661,12 @@ function inferActionFromText(text: string): ApprovalAction {
643
661
  return 'approve_once';
644
662
  }
645
663
 
664
+ function resolveRequestInstructionMode(
665
+ request?: Pick<CanonicalGuardianRequest, 'kind' | 'toolName'> | null,
666
+ ): 'approval' | 'answer' {
667
+ return resolveGuardianInstructionModeForRequest(request);
668
+ }
669
+
646
670
  // ---------------------------------------------------------------------------
647
671
  // Failure reason reply text
648
672
  // ---------------------------------------------------------------------------
@@ -653,7 +677,11 @@ type CanonicalFailureReason = 'already_resolved' | 'identity_mismatch' | 'invali
653
677
  * Map a canonical decision failure reason to a distinct, actionable reply
654
678
  * so the guardian understands exactly what happened and what to do next.
655
679
  */
656
- function failureReplyText(reason: CanonicalFailureReason, requestCode?: string | null): string {
680
+ function failureReplyText(
681
+ reason: CanonicalFailureReason,
682
+ requestCode?: string | null,
683
+ request?: CanonicalGuardianRequest,
684
+ ): string {
657
685
  switch (reason) {
658
686
  case 'already_resolved':
659
687
  return 'This request has already been resolved.';
@@ -662,9 +690,7 @@ function failureReplyText(reason: CanonicalFailureReason, requestCode?: string |
662
690
  case 'identity_mismatch':
663
691
  return "You don't have permission to decide on this request.";
664
692
  case 'invalid_action':
665
- return requestCode
666
- ? `I found request ${requestCode}, but I need to know your decision. Reply "${requestCode} approve" or "${requestCode} reject".`
667
- : "I couldn't determine your intended action. Reply with the request code followed by 'approve' or 'reject' (e.g., \"ABC123 approve\").";
693
+ return buildGuardianInvalidActionReply(resolveRequestInstructionMode(request), requestCode ?? undefined);
668
694
  default:
669
695
  return "I couldn't process that request. Please try again.";
670
696
  }
@@ -681,15 +707,12 @@ function failureReplyText(reason: CanonicalFailureReason, requestCode?: string |
681
707
  */
682
708
  function composeCodeOnlyClarification(request: CanonicalGuardianRequest): string {
683
709
  const code = request.requestCode ?? 'unknown';
684
- const toolLabel = request.toolName ?? 'an action';
685
- const lines: string[] = [
686
- `I found request ${code} for ${toolLabel}.`,
687
- ];
688
- if (request.questionText) {
689
- lines.push(`Details: ${request.questionText}`);
690
- }
691
- lines.push(`Reply "${code} approve" to approve or "${code} reject" to reject.`);
692
- return lines.join('\n');
710
+ const mode = resolveRequestInstructionMode(request);
711
+ return buildGuardianCodeOnlyClarification(mode, {
712
+ requestCode: code,
713
+ questionText: request.questionText,
714
+ toolName: request.toolName,
715
+ });
693
716
  }
694
717
 
695
718
  // ---------------------------------------------------------------------------
@@ -706,6 +729,10 @@ function composeDisambiguationReply(
706
729
  engineReplyText?: string,
707
730
  ): string {
708
731
  const lines: string[] = [];
732
+ const requestsWithMode = pendingRequests.map((request) => ({
733
+ request,
734
+ mode: resolveRequestInstructionMode(request),
735
+ }));
709
736
 
710
737
  if (engineReplyText) {
711
738
  lines.push(engineReplyText);
@@ -714,16 +741,26 @@ function composeDisambiguationReply(
714
741
 
715
742
  lines.push(`You have ${pendingRequests.length} pending requests. Please specify which one:`);
716
743
 
717
- for (const req of pendingRequests) {
718
- const toolLabel = req.toolName ?? 'action';
719
- const code = req.requestCode ?? req.id.slice(0, 6).toUpperCase();
744
+ for (const { request, mode } of requestsWithMode) {
745
+ const toolLabel = buildGuardianDisambiguationLabel(mode, {
746
+ questionText: request.questionText,
747
+ toolName: request.toolName,
748
+ });
749
+ const code = request.requestCode ?? request.id.slice(0, 6).toUpperCase();
720
750
  lines.push(` - ${code}: ${toolLabel}`);
721
751
  }
722
752
 
723
- // Include a concrete example using the first request's code
724
- const exampleCode = pendingRequests[0].requestCode ?? pendingRequests[0].id.slice(0, 6).toUpperCase();
753
+ const questionRequest = requestsWithMode.find(({ mode }) => mode === 'answer');
754
+ const decisionRequest = requestsWithMode.find(({ mode }) => mode === 'approval');
725
755
  lines.push('');
726
- lines.push(`Reply "${exampleCode} approve" to approve a specific request.`);
756
+ if (questionRequest) {
757
+ const exampleCode = questionRequest.request.requestCode ?? questionRequest.request.id.slice(0, 6).toUpperCase();
758
+ lines.push(buildGuardianDisambiguationExample(questionRequest.mode, exampleCode));
759
+ }
760
+ if (decisionRequest) {
761
+ const exampleCode = decisionRequest.request.requestCode ?? decisionRequest.request.id.slice(0, 6).toUpperCase();
762
+ lines.push(buildGuardianDisambiguationExample(decisionRequest.mode, exampleCode));
763
+ }
727
764
 
728
765
  return lines.join('\n');
729
766
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Startup migration: backfill channel='vellum' guardian binding.
3
+ *
4
+ * On runtime start, ensures that a guardian binding exists for the
5
+ * 'vellum' channel with a guardianPrincipalId. This is required for
6
+ * the identity-bound hatch bootstrap flow.
7
+ *
8
+ * - If a vellum binding already exists, no-op.
9
+ * - If no vellum binding exists, creates one with a fresh principal.
10
+ * - Preserves existing guardian bindings for other channels unchanged.
11
+ */
12
+
13
+ import { v4 as uuid } from 'uuid';
14
+
15
+ import {
16
+ createBinding,
17
+ getActiveBinding,
18
+ } from '../memory/guardian-bindings.js';
19
+ import { getLogger } from '../util/logger.js';
20
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
21
+
22
+ const log = getLogger('guardian-vellum-migration');
23
+
24
+ /**
25
+ * Ensure a vellum guardian binding exists for the given assistant.
26
+ * Called during daemon startup to backfill existing installations.
27
+ *
28
+ * Returns the guardianPrincipalId (existing or newly created).
29
+ */
30
+ export function ensureVellumGuardianBinding(assistantId: string = DAEMON_INTERNAL_ASSISTANT_ID): string {
31
+ const existing = getActiveBinding(assistantId, 'vellum');
32
+ if (existing) {
33
+ log.debug(
34
+ { assistantId, guardianPrincipalId: existing.guardianExternalUserId },
35
+ 'Vellum guardian binding already exists',
36
+ );
37
+ return existing.guardianExternalUserId;
38
+ }
39
+
40
+ const guardianPrincipalId = `vellum-principal-${uuid()}`;
41
+
42
+ createBinding({
43
+ assistantId,
44
+ channel: 'vellum',
45
+ guardianExternalUserId: guardianPrincipalId,
46
+ guardianDeliveryChatId: 'local',
47
+ verifiedVia: 'startup-migration',
48
+ metadataJson: JSON.stringify({ migratedAt: Date.now() }),
49
+ });
50
+
51
+ log.info(
52
+ { assistantId, guardianPrincipalId },
53
+ 'Backfilled vellum guardian binding on startup',
54
+ );
55
+
56
+ return guardianPrincipalId;
57
+ }
@@ -40,6 +40,7 @@ import { consumeCallback, consumeCallbackError } from '../security/oauth-callbac
40
40
  import { getLogger } from '../util/logger.js';
41
41
  import { buildAssistantEvent } from './assistant-event.js';
42
42
  import { assistantEventHub } from './assistant-event-hub.js';
43
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
43
44
  import { sweepFailedEvents } from './channel-retry-sweep.js';
44
45
  import { httpError } from './http-errors.js';
45
46
  // Middleware
@@ -86,6 +87,7 @@ import {
86
87
  handleGetAttachmentContent,
87
88
  handleUploadAttachment,
88
89
  } from './routes/attachment-routes.js';
90
+ import { handleGetBrainGraph, handleServeBrainGraphUI, handleServeHomeBaseUI } from './routes/brain-graph-routes.js';
89
91
  import {
90
92
  handleAnswerCall,
91
93
  handleCancelCall,
@@ -97,7 +99,10 @@ import {
97
99
  startCanonicalGuardianExpirySweep,
98
100
  stopCanonicalGuardianExpirySweep,
99
101
  } from './routes/canonical-guardian-expiry-sweep.js';
100
- import { canonicalChannelAssistantId } from './routes/channel-route-shared.js';
102
+ import {
103
+ handleGetChannelReadiness,
104
+ handleRefreshChannelReadiness,
105
+ } from './routes/channel-readiness-routes.js';
101
106
  import {
102
107
  handleChannelDeliveryAck,
103
108
  handleChannelInbound,
@@ -126,6 +131,7 @@ import {
126
131
  handleGuardianActionDecision,
127
132
  handleGuardianActionsPending,
128
133
  } from './routes/guardian-action-routes.js';
134
+ import { handleGuardianBootstrap } from './routes/guardian-bootstrap-routes.js';
129
135
  import { handleGetIdentity,handleHealth } from './routes/identity-routes.js';
130
136
  import {
131
137
  handleBlockMember,
@@ -160,6 +166,21 @@ import {
160
166
  handlePairingStatus,
161
167
  } from './routes/pairing-routes.js';
162
168
  import { handleAddSecret } from './routes/secret-routes.js';
169
+ import {
170
+ handleAssignTwilioNumber,
171
+ handleClearTwilioCredentials,
172
+ handleDeleteTollfreeVerification,
173
+ handleGetSmsCompliance,
174
+ handleGetTwilioConfig,
175
+ handleListTwilioNumbers,
176
+ handleProvisionTwilioNumber,
177
+ handleReleaseTwilioNumber,
178
+ handleSetTwilioCredentials,
179
+ handleSmsDoctor,
180
+ handleSmsSendTest,
181
+ handleSubmitTollfreeVerification,
182
+ handleUpdateTollfreeVerification,
183
+ } from './routes/twilio-routes.js';
163
184
 
164
185
  // Re-export for consumers
165
186
  export { isPrivateAddress } from './middleware/auth.js';
@@ -270,7 +291,7 @@ export class RuntimeHttpServer {
270
291
  ipcBroadcast(msg);
271
292
  // Also publish to the event hub so HTTP/SSE clients (e.g. macOS
272
293
  // app with localHttpEnabled) receive pairing approval requests.
273
- void assistantEventHub.publish(buildAssistantEvent('self', msg));
294
+ void assistantEventHub.publish(buildAssistantEvent(DAEMON_INTERNAL_ASSISTANT_ID, msg));
274
295
  }
275
296
  : undefined,
276
297
  };
@@ -445,7 +466,7 @@ export class RuntimeHttpServer {
445
466
  return rateLimitResponse(result);
446
467
  }
447
468
  // Attach rate limit headers to the eventual response
448
- const originalResponse = await this.handleAuthenticatedRequest(req, url, path);
469
+ const originalResponse = await this.handleAuthenticatedRequest(req, url, path, server);
449
470
  const headers = new Headers(originalResponse.headers);
450
471
  for (const [k, v] of Object.entries(rateLimitHeaders(result))) {
451
472
  headers.set(k, v);
@@ -457,13 +478,13 @@ export class RuntimeHttpServer {
457
478
  });
458
479
  }
459
480
 
460
- return this.handleAuthenticatedRequest(req, url, path);
481
+ return this.handleAuthenticatedRequest(req, url, path, server);
461
482
  }
462
483
 
463
484
  /**
464
485
  * Handle requests that have already passed auth and rate limiting.
465
486
  */
466
- private async handleAuthenticatedRequest(req: Request, url: URL, path: string): Promise<Response> {
487
+ private async handleAuthenticatedRequest(req: Request, url: URL, path: string, server: ReturnType<typeof Bun.serve>): Promise<Response> {
467
488
  // Pairing registration (bearer-authenticated)
468
489
  if (path === '/v1/pairing/register' && req.method === 'POST') {
469
490
  return await handlePairingRegister(req, this.pairingContext);
@@ -521,22 +542,13 @@ export class RuntimeHttpServer {
521
542
  }
522
543
  }
523
544
 
524
- // New assistant-less runtime routes: /v1/<endpoint>
525
- const newRouteMatch = path.match(/^\/v1\/(?!assistants\/)(.+)$/);
526
- if (newRouteMatch) {
527
- return this.dispatchEndpoint(newRouteMatch[1], req, url);
528
- }
529
-
530
- // Legacy: /v1/assistants/:assistantId/<endpoint>
531
- const match = path.match(/^\/v1\/assistants\/([^/]+)\/(.+)$/);
532
- if (!match) {
533
- return httpError('NOT_FOUND', 'Not found', 404);
545
+ // Runtime routes: /v1/<endpoint>
546
+ const routeMatch = path.match(/^\/v1\/(.+)$/);
547
+ if (routeMatch) {
548
+ return this.dispatchEndpoint(routeMatch[1], req, url, server);
534
549
  }
535
550
 
536
- const assistantId = canonicalChannelAssistantId(match[1]);
537
- const endpoint = match[2];
538
- log.warn({ endpoint, assistantId }, '[deprecated] /v1/assistants/:assistantId/... route used; migrate to /v1/...');
539
- return this.dispatchEndpoint(endpoint, req, url, assistantId);
551
+ return httpError('NOT_FOUND', 'Not found', 404);
540
552
  }
541
553
 
542
554
  private handleBrowserRelayUpgrade(req: Request, server: ReturnType<typeof Bun.serve>): Response {
@@ -616,8 +628,9 @@ export class RuntimeHttpServer {
616
628
  endpoint: string,
617
629
  req: Request,
618
630
  url: URL,
619
- assistantId: string = 'self',
631
+ server: ReturnType<typeof Bun.serve>,
620
632
  ): Promise<Response> {
633
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
621
634
  return withErrorHandling(endpoint, async () => {
622
635
  if (endpoint === 'health' && req.method === 'GET') return handleHealth();
623
636
  if (endpoint === 'debug' && req.method === 'GET') return handleDebug();
@@ -690,7 +703,7 @@ export class RuntimeHttpServer {
690
703
  try {
691
704
  recordConversationSeenSignal({
692
705
  conversationId,
693
- assistantId: 'self',
706
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
694
707
  sourceChannel: (body.sourceChannel as string) ?? 'vellum',
695
708
  signalType: (body.signalType as string ?? 'macos_conversation_opened') as SignalType,
696
709
  confidence: (body.confidence as string ?? 'explicit') as Confidence,
@@ -715,18 +728,18 @@ export class RuntimeHttpServer {
715
728
  persistAndProcessMessage: this.persistAndProcessMessage,
716
729
  sendMessageDeps: this.sendMessageDeps,
717
730
  approvalConversationGenerator: this.approvalConversationGenerator,
718
- });
731
+ }, server);
719
732
  }
720
733
 
721
734
  // Standalone approval endpoints — keyed by requestId, orthogonal to message sending
722
- if (endpoint === 'confirm' && req.method === 'POST') return await handleConfirm(req);
723
- if (endpoint === 'secret' && req.method === 'POST') return await handleSecret(req);
724
- if (endpoint === 'trust-rules' && req.method === 'POST') return await handleTrustRule(req);
725
- if (endpoint === 'pending-interactions' && req.method === 'GET') return handleListPendingInteractions(url);
735
+ if (endpoint === 'confirm' && req.method === 'POST') return await handleConfirm(req, server);
736
+ if (endpoint === 'secret' && req.method === 'POST') return await handleSecret(req, server);
737
+ if (endpoint === 'trust-rules' && req.method === 'POST') return await handleTrustRule(req, server);
738
+ if (endpoint === 'pending-interactions' && req.method === 'GET') return handleListPendingInteractions(url, req, server);
726
739
 
727
740
  // Guardian action endpoints — deterministic button-based decisions
728
- if (endpoint === 'guardian-actions/pending' && req.method === 'GET') return handleGuardianActionsPending(req);
729
- if (endpoint === 'guardian-actions/decision' && req.method === 'POST') return await handleGuardianActionDecision(req);
741
+ if (endpoint === 'guardian-actions/pending' && req.method === 'GET') return handleGuardianActionsPending(req, server);
742
+ if (endpoint === 'guardian-actions/decision' && req.method === 'POST') return await handleGuardianActionDecision(req, server);
730
743
 
731
744
  // Contacts
732
745
  if (endpoint === 'contacts' && req.method === 'GET') return handleListContacts(url);
@@ -768,6 +781,34 @@ export class RuntimeHttpServer {
768
781
  if (endpoint === 'integrations/guardian/outbound/resend' && req.method === 'POST') return await handleResendOutbound(req);
769
782
  if (endpoint === 'integrations/guardian/outbound/cancel' && req.method === 'POST') return await handleCancelOutbound(req);
770
783
 
784
+ // Guardian vellum channel bootstrap
785
+ if (endpoint === 'integrations/guardian/vellum/bootstrap' && req.method === 'POST') return await handleGuardianBootstrap(req, server);
786
+
787
+ // Integrations — Twilio config
788
+ if (endpoint === 'integrations/twilio/config' && req.method === 'GET') return handleGetTwilioConfig();
789
+ if (endpoint === 'integrations/twilio/credentials' && req.method === 'POST') return await handleSetTwilioCredentials(req);
790
+ if (endpoint === 'integrations/twilio/credentials' && req.method === 'DELETE') return handleClearTwilioCredentials();
791
+ if (endpoint === 'integrations/twilio/numbers' && req.method === 'GET') return await handleListTwilioNumbers();
792
+ if (endpoint === 'integrations/twilio/numbers/provision' && req.method === 'POST') return await handleProvisionTwilioNumber(req);
793
+ if (endpoint === 'integrations/twilio/numbers/assign' && req.method === 'POST') return await handleAssignTwilioNumber(req);
794
+ if (endpoint === 'integrations/twilio/numbers/release' && req.method === 'POST') return await handleReleaseTwilioNumber(req);
795
+ if (endpoint === 'integrations/twilio/sms/compliance' && req.method === 'GET') return await handleGetSmsCompliance();
796
+ if (endpoint === 'integrations/twilio/sms/compliance/tollfree' && req.method === 'POST') return await handleSubmitTollfreeVerification(req);
797
+ if (endpoint === 'integrations/twilio/sms/test' && req.method === 'POST') return await handleSmsSendTest(req);
798
+ if (endpoint === 'integrations/twilio/sms/doctor' && req.method === 'POST') return await handleSmsDoctor();
799
+
800
+ // Twilio toll-free verification PATCH/DELETE with :verificationSid
801
+ const tollfreeVerificationMatch = endpoint.match(/^integrations\/twilio\/sms\/compliance\/tollfree\/([^/]+)$/);
802
+ if (tollfreeVerificationMatch) {
803
+ const verificationSid = tollfreeVerificationMatch[1];
804
+ if (req.method === 'PATCH') return await handleUpdateTollfreeVerification(req, verificationSid);
805
+ if (req.method === 'DELETE') return await handleDeleteTollfreeVerification(verificationSid);
806
+ }
807
+
808
+ // Channel readiness
809
+ if (endpoint === 'channels/readiness' && req.method === 'GET') return await handleGetChannelReadiness(url);
810
+ if (endpoint === 'channels/readiness/refresh' && req.method === 'POST') return await handleRefreshChannelReadiness(req);
811
+
771
812
  if (endpoint === 'attachments' && req.method === 'POST') return await handleUploadAttachment(req);
772
813
  if (endpoint === 'attachments' && req.method === 'DELETE') return await handleDeleteAttachment(req);
773
814
 
@@ -813,11 +854,11 @@ export class RuntimeHttpServer {
813
854
 
814
855
  // Internal Twilio forwarding endpoints (gateway -> runtime)
815
856
  if (endpoint === 'internal/twilio/voice-webhook' && req.method === 'POST') {
816
- const json = await req.json() as { params: Record<string, string>; originalUrl?: string; assistantId?: string };
857
+ const json = await req.json() as { params: Record<string, string>; originalUrl?: string };
817
858
  const formBody = new URLSearchParams(json.params).toString();
818
859
  const reconstructedUrl = json.originalUrl ?? req.url;
819
860
  const fakeReq = new Request(reconstructedUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formBody });
820
- return await handleVoiceWebhook(fakeReq, json.assistantId);
861
+ return await handleVoiceWebhook(fakeReq);
821
862
  }
822
863
 
823
864
  if (endpoint === 'internal/twilio/status' && req.method === 'POST') {
@@ -835,7 +876,10 @@ export class RuntimeHttpServer {
835
876
  }
836
877
 
837
878
  if (endpoint === 'identity' && req.method === 'GET') return handleGetIdentity();
838
- if (endpoint === 'events' && req.method === 'GET') return handleSubscribeAssistantEvents(req, url);
879
+ if (endpoint === 'brain-graph' && req.method === 'GET') return handleGetBrainGraph();
880
+ if (endpoint === 'brain-graph-ui' && req.method === 'GET') return handleServeBrainGraphUI(this.bearerToken);
881
+ if (endpoint === 'home-base-ui' && req.method === 'GET') return handleServeHomeBaseUI(this.bearerToken);
882
+ if (endpoint === 'events' && req.method === 'GET') return handleSubscribeAssistantEvents(req, url, { server });
839
883
 
840
884
  // Internal OAuth callback endpoint (gateway -> runtime)
841
885
  if (endpoint === 'internal/oauth/callback' && req.method === 'POST') {
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Shared types for the runtime HTTP server and its route handlers.
3
3
  */
4
+ import type {
5
+ CallPointerMessageContext,
6
+ ComposeCallPointerMessageOptions,
7
+ } from '../calls/call-pointer-message-composer.js';
4
8
  import type { ChannelId, InterfaceId } from '../channels/types.js';
5
9
  import type { Session } from '../daemon/session.js';
6
10
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
@@ -55,6 +59,15 @@ export type ApprovalConversationGenerator = (
55
59
  context: ApprovalConversationContext,
56
60
  ) => Promise<ApprovalConversationResult>;
57
61
 
62
+ /**
63
+ * Daemon-injected function that generates call pointer copy using a provider.
64
+ * Returns generated text or `null` on failure (caller falls back to deterministic text).
65
+ */
66
+ export type PointerCopyGenerator = (
67
+ context: CallPointerMessageContext,
68
+ options?: ComposeCallPointerMessageOptions,
69
+ ) => Promise<string | null>;
70
+
58
71
  /**
59
72
  * Daemon-injected function that generates guardian action copy using a provider.
60
73
  * Returns generated text or `null` on failure (caller falls back to deterministic text).
@@ -57,6 +57,8 @@ export interface InviteResponseData {
57
57
  expectedExternalUserId?: string;
58
58
  voiceCode?: string;
59
59
  voiceCodeDigits?: number;
60
+ friendName?: string;
61
+ guardianName?: string;
60
62
  createdAt: number;
61
63
  }
62
64
 
@@ -110,6 +112,8 @@ function inviteToResponse(inv: IngressInvite, opts?: { rawToken?: string; voiceC
110
112
  ...(inv.expectedExternalUserId ? { expectedExternalUserId: inv.expectedExternalUserId } : {}),
111
113
  ...(opts?.voiceCode ? { voiceCode: opts.voiceCode } : {}),
112
114
  ...(inv.voiceCodeDigits != null ? { voiceCodeDigits: inv.voiceCodeDigits } : {}),
115
+ ...(inv.friendName ? { friendName: inv.friendName } : {}),
116
+ ...(inv.guardianName ? { guardianName: inv.guardianName } : {}),
113
117
  createdAt: inv.createdAt,
114
118
  };
115
119
  }
@@ -149,6 +153,8 @@ export function createIngressInvite(params: {
149
153
  // Voice invite parameters
150
154
  expectedExternalUserId?: string;
151
155
  voiceCodeDigits?: number;
156
+ friendName?: string;
157
+ guardianName?: string;
152
158
  }): IngressResult<InviteResponseData> {
153
159
  if (!params.sourceChannel) {
154
160
  return { ok: false, error: 'sourceChannel is required for create' };
@@ -168,6 +174,12 @@ export function createIngressInvite(params: {
168
174
  if (!isValidE164(params.expectedExternalUserId)) {
169
175
  return { ok: false, error: 'expectedExternalUserId must be in E.164 format (e.g., +15551234567)' };
170
176
  }
177
+ if (typeof params.friendName !== 'string' || !params.friendName.trim()) {
178
+ return { ok: false, error: 'friendName is required for voice invites' };
179
+ }
180
+ if (typeof params.guardianName !== 'string' || !params.guardianName.trim()) {
181
+ return { ok: false, error: 'guardianName is required for voice invites' };
182
+ }
171
183
  voiceCode = generateVoiceCode(6);
172
184
  voiceCodeHash = hashVoiceCode(voiceCode);
173
185
  }
@@ -181,6 +193,8 @@ export function createIngressInvite(params: {
181
193
  expectedExternalUserId: params.expectedExternalUserId,
182
194
  voiceCodeHash,
183
195
  voiceCodeDigits: 6,
196
+ friendName: params.friendName,
197
+ guardianName: params.guardianName,
184
198
  } : {}),
185
199
  });
186
200
  // Voice invites must not expose the token — callers must redeem via the
@@ -13,6 +13,7 @@ import { findActiveVoiceInvites,findByTokenHash, hashToken, markInviteExpired, r
13
13
  import { findMember, upsertMember } from '../memory/ingress-member-store.js';
14
14
  import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
15
15
  import { hashVoiceCode } from '../util/voice-code.js';
16
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Outcome type
@@ -223,7 +224,7 @@ export function redeemVoiceInviteCode(params: {
223
224
  sourceChannel: 'voice';
224
225
  code: string;
225
226
  }): VoiceRedemptionOutcome {
226
- const { assistantId = 'self', callerExternalUserId, code } = params;
227
+ const { assistantId = DAEMON_INTERNAL_ASSISTANT_ID, callerExternalUserId, code } = params;
227
228
 
228
229
  if (!callerExternalUserId) {
229
230
  return { ok: false, reason: 'invalid_or_expired' };
@@ -285,12 +286,20 @@ export function redeemVoiceInviteCode(params: {
285
286
  const STALE_INVITE = Symbol('stale_invite');
286
287
  let memberId: string | undefined;
287
288
 
289
+ // Reactivation should not overwrite a guardian-managed nickname (same
290
+ // protection as the token-based redemption path above).
291
+ const preservedDisplayName = existingMember?.displayName?.trim().length
292
+ ? existingMember.displayName
293
+ : (invite.friendName ?? undefined);
294
+
288
295
  try {
289
296
  getSqlite().transaction(() => {
290
297
  const member = upsertMember({
291
298
  assistantId: invite.assistantId,
292
299
  sourceChannel: 'voice',
293
300
  externalUserId: callerExternalUserId,
301
+ externalChatId: callerExternalUserId,
302
+ displayName: preservedDisplayName,
294
303
  status: 'active',
295
304
  policy: 'allow',
296
305
  inviteId: invite.id,