@vellumai/assistant 0.4.4 → 0.4.6

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 (90) hide show
  1. package/ARCHITECTURE.md +4 -4
  2. package/README.md +6 -6
  3. package/bun.lock +6 -2
  4. package/docs/architecture/memory.md +4 -4
  5. package/package.json +2 -2
  6. package/src/__tests__/actor-token-service.test.ts +5 -2
  7. package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
  8. package/src/__tests__/call-controller.test.ts +78 -0
  9. package/src/__tests__/call-domain.test.ts +148 -10
  10. package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
  11. package/src/__tests__/call-pointer-messages.test.ts +105 -43
  12. package/src/__tests__/canonical-guardian-store.test.ts +44 -10
  13. package/src/__tests__/channel-approval-routes.test.ts +67 -65
  14. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
  15. package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
  16. package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
  17. package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
  18. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
  19. package/src/__tests__/guardian-grant-minting.test.ts +24 -24
  20. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
  21. package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
  22. package/src/__tests__/guardian-routing-state.test.ts +4 -4
  23. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
  24. package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
  25. package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
  26. package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
  27. package/src/__tests__/non-member-access-request.test.ts +50 -47
  28. package/src/__tests__/relay-server.test.ts +71 -0
  29. package/src/__tests__/send-endpoint-busy.test.ts +6 -0
  30. package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
  31. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
  32. package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
  33. package/src/__tests__/system-prompt.test.ts +1 -0
  34. package/src/__tests__/tool-approval-handler.test.ts +1 -1
  35. package/src/__tests__/tool-grant-request-escalation.test.ts +9 -2
  36. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +8 -1
  37. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +22 -22
  38. package/src/__tests__/trusted-contact-multichannel.test.ts +4 -4
  39. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  40. package/src/approvals/guardian-decision-primitive.ts +29 -25
  41. package/src/approvals/guardian-request-resolvers.ts +9 -5
  42. package/src/calls/call-pointer-message-composer.ts +27 -85
  43. package/src/calls/call-pointer-messages.ts +54 -21
  44. package/src/calls/guardian-dispatch.ts +30 -0
  45. package/src/calls/relay-server.ts +13 -13
  46. package/src/config/system-prompt.ts +10 -3
  47. package/src/config/templates/BOOTSTRAP.md +6 -5
  48. package/src/config/templates/USER.md +1 -0
  49. package/src/config/user-reference.ts +44 -0
  50. package/src/daemon/handlers/guardian-actions.ts +5 -2
  51. package/src/daemon/handlers/sessions.ts +8 -3
  52. package/src/daemon/lifecycle.ts +109 -3
  53. package/src/daemon/server.ts +32 -24
  54. package/src/daemon/session-agent-loop.ts +4 -3
  55. package/src/daemon/session-lifecycle.ts +1 -9
  56. package/src/daemon/session-process.ts +2 -2
  57. package/src/daemon/session-runtime-assembly.ts +2 -0
  58. package/src/daemon/session-tool-setup.ts +10 -0
  59. package/src/daemon/session.ts +1 -0
  60. package/src/memory/canonical-guardian-store.ts +40 -0
  61. package/src/memory/conversation-crud.ts +26 -0
  62. package/src/memory/conversation-store.ts +1 -0
  63. package/src/memory/db-init.ts +8 -0
  64. package/src/memory/guardian-bindings.ts +4 -0
  65. package/src/memory/job-handlers/backfill.ts +2 -9
  66. package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
  67. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
  68. package/src/memory/migrations/index.ts +2 -0
  69. package/src/memory/migrations/registry.ts +5 -0
  70. package/src/memory/schema.ts +3 -0
  71. package/src/notifications/copy-composer.ts +2 -2
  72. package/src/runtime/access-request-helper.ts +43 -28
  73. package/src/runtime/actor-trust-resolver.ts +19 -14
  74. package/src/runtime/channel-guardian-service.ts +6 -0
  75. package/src/runtime/guardian-context-resolver.ts +6 -2
  76. package/src/runtime/guardian-reply-router.ts +33 -16
  77. package/src/runtime/guardian-vellum-migration.ts +29 -5
  78. package/src/runtime/http-types.ts +0 -13
  79. package/src/runtime/local-actor-identity.ts +19 -13
  80. package/src/runtime/middleware/actor-token.ts +2 -2
  81. package/src/runtime/routes/channel-delivery-routes.ts +5 -5
  82. package/src/runtime/routes/conversation-routes.ts +45 -35
  83. package/src/runtime/routes/guardian-action-routes.ts +7 -1
  84. package/src/runtime/routes/guardian-approval-interception.ts +52 -52
  85. package/src/runtime/routes/guardian-bootstrap-routes.ts +1 -0
  86. package/src/runtime/routes/inbound-conversation.ts +7 -7
  87. package/src/runtime/routes/inbound-message-handler.ts +105 -94
  88. package/src/runtime/tool-grant-request-helper.ts +1 -0
  89. package/src/util/logger.ts +10 -0
  90. package/src/daemon/call-pointer-generators.ts +0 -59
@@ -1,17 +1,10 @@
1
1
  /**
2
- * Layered call pointer message composition system.
2
+ * Deterministic call pointer message templates and instruction builder.
3
3
  *
4
- * Generates pointer/status copy through a priority chain:
5
- * 1. Generator-produced text (when provided by daemon and audience is trusted)
6
- * 2. Deterministic fallback templates (preserving existing semantics)
7
- *
8
- * Follows the same pattern as approval-message-composer.ts and
9
- * guardian-action-message-composer.ts.
4
+ * Provides fallback templates for untrusted audiences and builds
5
+ * structured instructions for the daemon session to generate pointer
6
+ * copy as a natural conversation turn for trusted audiences.
10
7
  */
11
- import type { PointerCopyGenerator } from '../runtime/http-types.js';
12
- import { getLogger } from '../util/logger.js';
13
-
14
- const log = getLogger('call-pointer-message-composer');
15
8
 
16
9
  // ---------------------------------------------------------------------------
17
10
  // Types
@@ -33,83 +26,33 @@ export interface CallPointerMessageContext {
33
26
  channel?: string;
34
27
  }
35
28
 
36
- export interface ComposeCallPointerMessageOptions {
37
- fallbackText?: string;
38
- requiredFacts?: string[];
39
- maxTokens?: number;
40
- timeoutMs?: number;
41
- }
42
-
43
- // ---------------------------------------------------------------------------
44
- // Constants (exported for the daemon-injected generator implementation)
45
- // ---------------------------------------------------------------------------
46
-
47
- export const POINTER_COPY_TIMEOUT_MS = 3_000;
48
- export const POINTER_COPY_MAX_TOKENS = 120;
49
- export const POINTER_COPY_SYSTEM_PROMPT =
50
- 'You are an assistant writing a brief status update about a phone call. '
51
- + 'Keep it concise (1-2 sentences), natural, and informative. '
52
- + 'Preserve all factual details exactly (phone numbers, durations, failure reasons, verification status). '
53
- + 'Do not mention internal systems or technical details. '
54
- + 'Return plain text only.';
55
-
56
29
  // ---------------------------------------------------------------------------
57
- // Public API
30
+ // Daemon instruction builder
58
31
  // ---------------------------------------------------------------------------
59
32
 
60
33
  /**
61
- * Compose pointer copy using the daemon-injected generator when available,
62
- * with deterministic fallback for reliability.
63
- *
64
- * The generator parameter is the daemon-provided function that knows about
65
- * providers. When absent (or in test env), only the deterministic fallback
66
- * is used.
34
+ * Build an instruction message to send to the daemon session so the
35
+ * assistant generates a natural pointer status update as a conversation turn.
67
36
  */
68
- export async function composeCallPointerMessageGenerative(
69
- context: CallPointerMessageContext,
70
- options: ComposeCallPointerMessageOptions = {},
71
- generator?: PointerCopyGenerator,
72
- ): Promise<string> {
73
- const fallbackText = options.fallbackText?.trim() || getPointerFallbackMessage(context);
74
-
75
- if (process.env.NODE_ENV === 'test') {
76
- return fallbackText;
77
- }
78
-
79
- if (generator) {
80
- try {
81
- const generated = await generator(context, options);
82
- if (generated) return generated;
83
- } catch (err) {
84
- log.warn({ err, scenario: context.scenario }, 'Failed to generate pointer copy, using fallback');
85
- }
86
- }
87
-
88
- return fallbackText;
89
- }
90
-
91
- /** @internal Exported for use by the daemon-injected generator implementation. */
92
- export function buildPointerGenerationPrompt(
93
- context: CallPointerMessageContext,
94
- fallbackText: string,
95
- requiredFacts: string[] | undefined,
96
- ): string {
97
- const factClause = requiredFacts && requiredFacts.length > 0
98
- ? `Required facts to include: ${requiredFacts.join(', ')}.\n`
99
- : '';
100
- return [
101
- 'Rewrite the following call status message as a natural, conversational update.',
102
- 'Keep the same concrete facts (phone number, duration, failure reason, verification status).',
103
- factClause,
104
- `Context JSON: ${JSON.stringify(context)}`,
105
- `Fallback message: ${fallbackText}`,
106
- ].filter(Boolean).join('\n\n');
107
- }
108
-
109
- /** @internal Exported for use by the daemon-injected generator implementation. */
110
- export function includesRequiredFacts(text: string, requiredFacts: string[] | undefined): boolean {
111
- if (!requiredFacts || requiredFacts.length === 0) return true;
112
- return requiredFacts.every((fact) => text.includes(fact));
37
+ export function buildPointerInstruction(context: CallPointerMessageContext): string {
38
+ const parts: string[] = [
39
+ '[CALL_STATUS_EVENT]',
40
+ `Event: ${context.scenario}`,
41
+ `Phone number: ${context.phoneNumber}`,
42
+ ];
43
+ if (context.duration) parts.push(`Duration: ${context.duration}`);
44
+ if (context.reason) parts.push(`Reason: ${context.reason}`);
45
+ if (context.verificationCode) parts.push(`Verification code: ${context.verificationCode}`);
46
+ if (context.channel) parts.push(`Channel: ${context.channel}`);
47
+
48
+ parts.push('');
49
+ parts.push(
50
+ 'Write a brief (1-2 sentence) status update about this phone call event for the user. '
51
+ + 'Preserve all factual details exactly (phone numbers, durations, failure reasons, verification codes). '
52
+ + 'Be concise, natural, and informative.',
53
+ );
54
+
55
+ return parts.join('\n');
113
56
  }
114
57
 
115
58
  // ---------------------------------------------------------------------------
@@ -119,8 +62,7 @@ export function includesRequiredFacts(text: string, requiredFacts: string[] | un
119
62
  /**
120
63
  * Return a scenario-specific deterministic fallback message.
121
64
  *
122
- * These preserve the exact semantics of the original hard-coded pointer
123
- * templates from call-pointer-messages.ts.
65
+ * Used for untrusted audiences and when the daemon processor is unavailable.
124
66
  */
125
67
  export function getPointerFallbackMessage(context: CallPointerMessageContext): string {
126
68
  switch (context.scenario) {
@@ -3,17 +3,17 @@
3
3
  * so the user sees call lifecycle events without the full transcript
4
4
  * (which lives in the dedicated voice conversation).
5
5
  *
6
- * Trust-aware: trusted audiences receive assistant-generated copy when a
7
- * generator is available; untrusted/unknown audiences always receive
8
- * deterministic fallback text.
6
+ * Trust-aware: trusted audiences get pointer messages routed through the
7
+ * daemon session as a conversation turn (the assistant generates the text).
8
+ * Untrusted/unknown audiences always receive deterministic fallback text
9
+ * written directly to the conversation store.
9
10
  */
10
11
 
11
12
  import * as conversationStore from '../memory/conversation-store.js';
12
- import type { PointerCopyGenerator } from '../runtime/http-types.js';
13
13
  import { getLogger } from '../util/logger.js';
14
14
  import {
15
+ buildPointerInstruction,
15
16
  type CallPointerMessageContext,
16
- composeCallPointerMessageGenerative,
17
17
  getPointerFallbackMessage,
18
18
  } from './call-pointer-message-composer.js';
19
19
 
@@ -23,24 +23,40 @@ export type PointerEvent = 'started' | 'completed' | 'failed' | 'guardian_verifi
23
23
 
24
24
  export type PointerAudienceMode = 'auto' | 'trusted' | 'untrusted';
25
25
 
26
+ /**
27
+ * Daemon-injected function that sends a message through the daemon session
28
+ * pipeline (persistAndProcessMessage), letting the assistant generate the
29
+ * pointer text as a natural conversation turn.
30
+ *
31
+ * @param requiredFacts - facts that must appear verbatim in the generated
32
+ * text (phone number, duration, outcome keyword, etc.). The processor
33
+ * should validate the output and throw if any are missing so the
34
+ * deterministic fallback fires.
35
+ */
36
+ export type PointerMessageProcessor = (
37
+ conversationId: string,
38
+ instruction: string,
39
+ requiredFacts?: string[],
40
+ ) => Promise<void>;
41
+
26
42
  // ---------------------------------------------------------------------------
27
- // Module-level generator injection (set by daemon lifecycle at startup)
43
+ // Module-level processor injection (set by daemon lifecycle at startup)
28
44
  // ---------------------------------------------------------------------------
29
45
 
30
- let pointerCopyGenerator: PointerCopyGenerator | undefined;
46
+ let pointerMessageProcessor: PointerMessageProcessor | undefined;
31
47
 
32
48
  /**
33
- * Inject the daemon-provided pointer copy generator.
49
+ * Inject the daemon-provided pointer message processor.
34
50
  * Called from daemon/lifecycle.ts at startup, following the same pattern
35
51
  * as setRelayBroadcast.
36
52
  */
37
- export function setPointerCopyGenerator(generator: PointerCopyGenerator): void {
38
- pointerCopyGenerator = generator;
53
+ export function setPointerMessageProcessor(processor: PointerMessageProcessor): void {
54
+ pointerMessageProcessor = processor;
39
55
  }
40
56
 
41
57
  /** @internal Reset for tests. */
42
- export function resetPointerCopyGenerator(): void {
43
- pointerCopyGenerator = undefined;
58
+ export function resetPointerMessageProcessor(): void {
59
+ pointerMessageProcessor = undefined;
44
60
  }
45
61
 
46
62
  // ---------------------------------------------------------------------------
@@ -51,6 +67,7 @@ export function resetPointerCopyGenerator(): void {
51
67
  * Resolve whether the audience for a pointer message is trusted.
52
68
  *
53
69
  * Trusted when:
70
+ * - recent message provenance trust class is 'guardian' or 'trusted_contact'
54
71
  * - conversation threadType is 'private' (local desktop-origin context)
55
72
  * - conversation origin channel is 'vellum' (desktop app)
56
73
  *
@@ -58,6 +75,12 @@ export function resetPointerCopyGenerator(): void {
58
75
  */
59
76
  function resolvePointerAudienceTrust(conversationId: string): boolean {
60
77
  try {
78
+ // Check provenance trust class on recent messages first — this catches
79
+ // trusted contacts who initiate calls from gateway channels (e.g. WhatsApp)
80
+ // where the conversation itself isn't a desktop-origin private thread.
81
+ const provenance = conversationStore.getConversationRecentProvenanceTrustClass(conversationId);
82
+ if (provenance === 'guardian' || provenance === 'trusted_contact') return true;
83
+
61
84
  const threadType = conversationStore.getConversationThreadType(conversationId);
62
85
  if (threadType === 'private') return true;
63
86
 
@@ -91,6 +114,7 @@ export async function addPointerMessage(
91
114
  };
92
115
 
93
116
  // Build required-facts list so generated text cannot drop key details.
117
+ // These are passed to the processor for post-generation validation.
94
118
  const requiredFacts: string[] = [phoneNumber];
95
119
  if (extra?.duration) requiredFacts.push(extra.duration);
96
120
  if (extra?.verificationCode) requiredFacts.push(extra.verificationCode);
@@ -109,21 +133,30 @@ export async function addPointerMessage(
109
133
  const outcomeKeyword = eventOutcomeKeywords[event];
110
134
  if (outcomeKeyword) requiredFacts.push(outcomeKeyword);
111
135
 
112
- let text: string;
113
-
114
- const isTrusted =
136
+ const trustedAudience =
115
137
  audienceMode === 'trusted' ||
116
138
  (audienceMode === 'auto' && resolvePointerAudienceTrust(conversationId));
117
139
 
118
- if (isTrusted && pointerCopyGenerator) {
119
- text = await composeCallPointerMessageGenerative(context, { requiredFacts }, pointerCopyGenerator);
120
- } else {
121
- if (!isTrusted && pointerCopyGenerator) {
122
- log.debug({ event, conversationId }, 'Untrusted audience — using deterministic pointer copy');
140
+ if (trustedAudience && pointerMessageProcessor) {
141
+ // Route through the daemon session the assistant generates the
142
+ // pointer text as a natural conversation turn, shaped by context,
143
+ // identity, and preferences.
144
+ const instruction = buildPointerInstruction(context);
145
+ try {
146
+ await pointerMessageProcessor(conversationId, instruction, requiredFacts);
147
+ return;
148
+ } catch (err) {
149
+ log.warn({ err, event, conversationId }, 'Daemon pointer processing failed, falling back to deterministic');
123
150
  }
124
- text = getPointerFallbackMessage(context);
151
+ } else if (!trustedAudience && pointerMessageProcessor) {
152
+ log.debug({ event, conversationId }, 'Untrusted audience — using deterministic pointer copy');
125
153
  }
126
154
 
155
+ // Deterministic fallback: write directly to the conversation store.
156
+ // Used for untrusted audiences, when the daemon processor is unavailable,
157
+ // or when daemon processing fails.
158
+ const text = getPointerFallbackMessage(context);
159
+
127
160
  // Pointer messages are assistant-generated status updates in the initiating
128
161
  // desktop thread. Do not set userMessageChannel — doing so would mark the
129
162
  // conversation's origin channel as voice, causing it to leak into the
@@ -14,8 +14,10 @@ import {
14
14
  listCanonicalGuardianRequests,
15
15
  updateCanonicalGuardianDelivery,
16
16
  } from '../memory/canonical-guardian-store.js';
17
+ import { getActiveBinding } from '../memory/guardian-bindings.js';
17
18
  import { emitNotificationSignal } from '../notifications/emit-signal.js';
18
19
  import type { NotificationDeliveryResult } from '../notifications/types.js';
20
+ import { ensureVellumGuardianBinding } from '../runtime/guardian-vellum-migration.js';
19
21
  import { getLogger } from '../util/logger.js';
20
22
  import { getUserConsultationTimeoutMs } from './call-constants.js';
21
23
  import type { CallPendingQuestion } from './types.js';
@@ -88,6 +90,33 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
88
90
  try {
89
91
  const expiresAt = Date.now() + getUserConsultationTimeoutMs();
90
92
 
93
+ // Voice decisions are handled in guardian threads tied to the assistant-
94
+ // level guardian identity. Resolve the principal from the vellum binding
95
+ // (the canonical assistant-level binding) so the request is attributed to
96
+ // the assistant's guardian principal.
97
+ let vellumBinding = getActiveBinding(assistantId, 'vellum');
98
+ let guardianPrincipalId = vellumBinding?.guardianPrincipalId ?? undefined;
99
+
100
+ // Self-heal: if the vellum binding is missing or lacks a principal,
101
+ // bootstrap it so the pending_question request can be attributed.
102
+ if (!guardianPrincipalId) {
103
+ log.info(
104
+ { callSessionId, assistantId, hadBinding: !!vellumBinding },
105
+ 'Vellum binding missing or lacks principal — self-healing for voice dispatch',
106
+ );
107
+ const healedPrincipalId = ensureVellumGuardianBinding(assistantId);
108
+ vellumBinding = getActiveBinding(assistantId, 'vellum');
109
+ guardianPrincipalId = vellumBinding?.guardianPrincipalId ?? healedPrincipalId;
110
+ }
111
+
112
+ if (!guardianPrincipalId) {
113
+ log.error(
114
+ { callSessionId, assistantId },
115
+ 'Voice guardian dispatch: no guardianPrincipalId after self-heal — cannot create pending_question',
116
+ );
117
+ return;
118
+ }
119
+
91
120
  // Create the canonical guardian request as the primary record.
92
121
  const request = createCanonicalGuardianRequest({
93
122
  kind: 'pending_question',
@@ -97,6 +126,7 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
97
126
  callSessionId,
98
127
  pendingQuestionId: pendingQuestion.id,
99
128
  questionText: pendingQuestion.questionText,
129
+ guardianPrincipalId,
100
130
  toolName,
101
131
  inputDigest,
102
132
  expiresAt: new Date(expiresAt).toISOString(),
@@ -510,8 +510,8 @@ export class RelayConnection {
510
510
  const initialActorTrust = resolveActorTrust({
511
511
  assistantId,
512
512
  sourceChannel: 'voice',
513
- externalChatId: otherPartyNumber,
514
- senderExternalUserId: otherPartyNumber || undefined,
513
+ conversationExternalId: otherPartyNumber,
514
+ actorExternalId: otherPartyNumber || undefined,
515
515
  });
516
516
  const initialGuardianContext = toGuardianRuntimeContextFromTrust(initialActorTrust, otherPartyNumber);
517
517
 
@@ -561,8 +561,8 @@ export class RelayConnection {
561
561
  const actorTrust = resolveActorTrust({
562
562
  assistantId,
563
563
  sourceChannel: 'voice',
564
- externalChatId: msg.from,
565
- senderExternalUserId: msg.from || undefined,
564
+ conversationExternalId: msg.from,
565
+ actorExternalId: msg.from || undefined,
566
566
  });
567
567
 
568
568
  // Check for a pending voice guardian challenge before the ACL deny
@@ -979,8 +979,8 @@ export class RelayConnection {
979
979
  const verifiedActorTrust = resolveActorTrust({
980
980
  assistantId: this.guardianChallengeAssistantId,
981
981
  sourceChannel: 'voice',
982
- externalChatId: this.guardianVerificationFromNumber,
983
- senderExternalUserId: this.guardianVerificationFromNumber,
982
+ conversationExternalId: this.guardianVerificationFromNumber,
983
+ actorExternalId: this.guardianVerificationFromNumber,
984
984
  });
985
985
  this.controller.setGuardianContext(
986
986
  toGuardianRuntimeContextFromTrust(verifiedActorTrust, this.guardianVerificationFromNumber),
@@ -1153,9 +1153,9 @@ export class RelayConnection {
1153
1153
  const accessResult = notifyGuardianOfAccessRequest({
1154
1154
  canonicalAssistantId: this.accessRequestAssistantId,
1155
1155
  sourceChannel: 'voice',
1156
- externalChatId: this.accessRequestFromNumber,
1157
- senderExternalUserId: this.accessRequestFromNumber,
1158
- senderName: callerName,
1156
+ conversationExternalId: this.accessRequestFromNumber,
1157
+ actorExternalId: this.accessRequestFromNumber,
1158
+ actorDisplayName: callerName,
1159
1159
  });
1160
1160
 
1161
1161
  if (accessResult.notified) {
@@ -1308,8 +1308,8 @@ export class RelayConnection {
1308
1308
  const updatedTrust = resolveActorTrust({
1309
1309
  assistantId,
1310
1310
  sourceChannel: 'voice',
1311
- externalChatId: fromNumber,
1312
- senderExternalUserId: fromNumber,
1311
+ conversationExternalId: fromNumber,
1312
+ actorExternalId: fromNumber,
1313
1313
  });
1314
1314
 
1315
1315
  if (this.controller) {
@@ -1583,8 +1583,8 @@ export class RelayConnection {
1583
1583
  const redeemedActorTrust = resolveActorTrust({
1584
1584
  assistantId: this.inviteRedemptionAssistantId,
1585
1585
  sourceChannel: 'voice',
1586
- externalChatId: this.inviteRedemptionFromNumber,
1587
- senderExternalUserId: this.inviteRedemptionFromNumber,
1586
+ conversationExternalId: this.inviteRedemptionFromNumber,
1587
+ actorExternalId: this.inviteRedemptionFromNumber,
1588
1588
  });
1589
1589
  this.controller.setGuardianContext(
1590
1590
  toGuardianRuntimeContextFromTrust(redeemedActorTrust, this.inviteRedemptionFromNumber),
@@ -11,7 +11,7 @@ import { getBaseDataDir, getIsContainerized } from './env-registry.js';
11
11
  import { getConfig } from './loader.js';
12
12
  import { skillFlagKey } from './skill-state.js';
13
13
  import { loadSkillCatalog, type SkillSummary } from './skills.js';
14
- import { resolveUserReference } from './user-reference.js';
14
+ import { resolveUserPronouns, resolveUserReference } from './user-reference.js';
15
15
 
16
16
  const log = getLogger('system-prompt');
17
17
 
@@ -489,16 +489,23 @@ export function buildChannelCommandIntentSection(): string {
489
489
 
490
490
  export function buildExternalCommsIdentitySection(): string {
491
491
  const userRef = resolveUserReference();
492
- return [
492
+ const userPronouns = resolveUserPronouns();
493
+ const lines = [
493
494
  '## External Communications Identity',
494
495
  '',
495
496
  'When communicating with people outside your user\'s household (phone calls, messages to non-guardian contacts, emails to third parties, etc.):',
496
497
  '',
497
498
  `- Refer to yourself as an **assistant**, not an "AI assistant" or "artificial intelligence". Keep identity framing simple and natural.`,
498
499
  `- When introducing who you represent, say you are acting on behalf of **${userRef}**.`,
500
+ ];
501
+ if (userPronouns) {
502
+ lines.push(`- Your user's pronouns are **${userPronouns}**. Use these when referring to your user in the third person.`);
503
+ }
504
+ lines.push(
499
505
  '- Do not volunteer that you are an AI unless directly asked. If asked, answer honestly.',
500
506
  '- This is guidance for natural, human-like communication — not a hard constraint. Occasional variations are acceptable.',
501
- ].join('\n');
507
+ );
508
+ return lines.join('\n');
502
509
  }
503
510
 
504
511
  export function buildSwarmGuidanceSection(): string {
@@ -33,7 +33,8 @@ Once they respond, follow the remaining steps in order, one at a time:
33
33
  - What they do for work (role, field, day-to-day)
34
34
  - What they do for fun (hobbies, interests)
35
35
  - What tools they rely on daily (apps, platforms, workflows)
36
- Weave these into the conversation. Inferred answers are fine when confidence is high. If something is unclear, ask one short follow-up, but don't turn it into an interview. One or two natural exchanges should cover it. If the user declines to share something, respect that and move on (see Privacy below).
36
+ - Their pronouns (he/him, she/her, they/them, etc.)
37
+ Weave these into the conversation. Inferred answers are fine when confidence is high — for pronouns, if the user's name is strongly gendered, you can infer with reasonable confidence, but default to they/them if unsure. If something is unclear, ask one short follow-up, but don't turn it into an interview. One or two natural exchanges should cover it. If the user declines to share something, respect that and move on (see Privacy below).
37
38
 
38
39
  6. **Show them what you can take off their plate.** Based on everything you've learned, present exactly 2 actionable task suggestions. Each should feel specific to this user, not generic. Frame it as: here's what you can hand off to me right now. Avoid language like "let's build automations" or "let's set up workflows." If `ui_show` is available (dashboard channels), show the suggestions as a card with 2 action buttons. Use `surface_type: "card"` with a short title and body, and add one `relay_prompt` action per suggestion. Each action's `data.prompt` should contain a natural-language request the user would say. Example structure:
39
40
  ```
@@ -54,18 +55,18 @@ Ask one question at a time. Don't dump a form on them.
54
55
 
55
56
  ## Privacy
56
57
 
57
- Only the assistant's name is hard-required. Everything else about the user (their name, work role, hobbies, daily tools) is best-effort. Ask naturally, not as a form. If something is unclear, you can ask one short follow-up, but if the user declines or dodges, do not push. Just move on.
58
+ Only the assistant's name is hard-required. Everything else about the user (their name, pronouns, work role, hobbies, daily tools) is best-effort. Ask naturally, not as a form. If something is unclear, you can ask one short follow-up, but if the user declines or dodges, do not push. Just move on.
58
59
 
59
60
  A field is "resolved" when any of these is true:
60
61
  - The user gave an explicit answer
61
62
  - You confidently inferred it from conversation
62
63
  - The user declined, dodged, or sidestepped it (treat all of these as declined)
63
64
 
64
- When saving to `USER.md`, mark declined fields so you don't re-ask later (e.g., `Work role: declined_by_user`). Inferred values can note the source (e.g., `Daily tools: inferred: Slack, Figma`).
65
+ When saving to `USER.md`, mark declined fields so you don't re-ask later (e.g., `Work role: declined_by_user`). Inferred values can note the source (e.g., `Daily tools: inferred: Slack, Figma`). For pronouns, if inferred from name, note the source (e.g., `Pronouns: inferred: he/him`).
65
66
 
66
67
  ## Saving What You Learn
67
68
 
68
- Save what you learn as you go. Update `IDENTITY.md` (name, nature, personality, emoji, style tendency) and `USER.md` (their name, how to address them, goals, locale, work role, hobbies, daily tools) using `file_edit`. If the conversation reveals how the user wants you to behave (e.g., "be direct," "don't be too chatty"), save those behavioral guidelines to `SOUL.md` — that file is about your personality and how you operate, not the user's data. Just do it quietly. Don't tell the user which files you're editing or mention tool names.
69
+ Save what you learn as you go. Update `IDENTITY.md` (name, nature, personality, emoji, style tendency) and `USER.md` (their name, pronouns, how to address them, goals, locale, work role, hobbies, daily tools) using `file_edit`. If the conversation reveals how the user wants you to behave (e.g., "be direct," "don't be too chatty"), save those behavioral guidelines to `SOUL.md` — that file is about your personality and how you operate, not the user's data. Just do it quietly. Don't tell the user which files you're editing or mention tool names.
69
70
 
70
71
  When saving to `IDENTITY.md`, be specific about the tone, energy, and conversational style you discovered during onboarding. This file persists after onboarding, so everything about how you should come across needs to be captured there -- not just your name and emoji, but the full vibe: how you talk, how much energy you bring, whether you're blunt or gentle, funny or serious.
71
72
 
@@ -74,7 +75,7 @@ When saving to `IDENTITY.md`, be specific about the tone, energy, and conversati
74
75
  Do NOT delete this file until ALL of the following are true:
75
76
  - You have a name (hard requirement)
76
77
  - You've figured out your vibe and adopted it
77
- - User detail fields are resolved: name, work role, hobbies/interests, and daily tools. Resolved means the user provided a value, you confidently inferred one, or the user declined/dodged it. All four must be in one of those states.
78
+ - User detail fields are resolved: name, pronouns, work role, hobbies/interests, and daily tools. Resolved means the user provided a value, you confidently inferred one, or the user declined/dodged it. All five must be in one of those states.
78
79
  - 2 suggestions shown (via `ui_show` or as text if UI unavailable)
79
80
  - The user selected one, deferred both, or typed an alternate direction
80
81
  - Home Base has been created silently
@@ -17,6 +17,7 @@ _(What do they care about? What projects are they working on? What annoys them?
17
17
  _Each field below should end up in one of three states: an explicit value, an inferred value (note the source), or `declined_by_user`. All fields must be resolved before onboarding completes, but declining is a valid resolution._
18
18
 
19
19
  - Preferred name/reference:
20
+ - Pronouns:
20
21
  - Goals:
21
22
  - Locale:
22
23
  - Work role:
@@ -22,3 +22,47 @@ export function resolveUserReference(): string {
22
22
 
23
23
  return DEFAULT_USER_REFERENCE;
24
24
  }
25
+
26
+ /**
27
+ * Resolve the user's pronouns from USER.md. Returns `null` when the
28
+ * file is missing, the field is empty, or the value is a sentinel like
29
+ * `declined_by_user`.
30
+ *
31
+ * Priority order:
32
+ * 1. Any `Pronouns:` line outside the Onboarding Snapshot section
33
+ * (explicit user update post-onboarding takes precedence).
34
+ * 2. The structured `- Pronouns:` field inside the Onboarding Snapshot.
35
+ */
36
+ export function resolveUserPronouns(): string | null {
37
+ const content = readTextFileSync(getWorkspacePromptPath('USER.md'));
38
+ if (content == null) return null;
39
+
40
+ const snapshotIdx = content.indexOf('## Onboarding Snapshot');
41
+
42
+ // 1. Check for a Pronouns line outside the Onboarding Snapshot section.
43
+ // This represents an explicit post-onboarding update and takes priority.
44
+ if (snapshotIdx >= 0) {
45
+ const beforeSnapshot = content.slice(0, snapshotIdx);
46
+ const outsideMatch = beforeSnapshot.match(/Pronouns:[ \t]*(.*)/);
47
+ if (outsideMatch && outsideMatch[1].trim()) {
48
+ return cleanPronounValue(outsideMatch[1].trim());
49
+ }
50
+ }
51
+
52
+ // 2. Fall back to the structured field in the Onboarding Snapshot.
53
+ if (snapshotIdx >= 0) {
54
+ const section = content.slice(snapshotIdx);
55
+ const match = section.match(/^- Pronouns:[ \t]*(.*)/m);
56
+ if (match && match[1].trim()) {
57
+ return cleanPronounValue(match[1].trim());
58
+ }
59
+ }
60
+
61
+ return null;
62
+ }
63
+
64
+ function cleanPronounValue(raw: string): string | null {
65
+ if (raw === 'declined_by_user') return null;
66
+ // Strip "inferred: " prefix for clean output
67
+ return raw.replace(/^inferred:\s*/i, '');
68
+ }
@@ -3,6 +3,7 @@ import {
3
3
  } from '../../approvals/guardian-decision-primitive.js';
4
4
  import { getCanonicalGuardianRequest } from '../../memory/canonical-guardian-store.js';
5
5
  import type { ApprovalAction } from '../../runtime/channel-approval-types.js';
6
+ import { resolveLocalIpcGuardianContext } from '../../runtime/local-actor-identity.js';
6
7
  import { listGuardianDecisionPrompts } from '../../runtime/routes/guardian-action-routes.js';
7
8
  import type { GuardianActionDecision, GuardianActionsPendingRequest } from '../ipc-protocol.js';
8
9
  import { defineHandlers, log } from './shared.js';
@@ -45,13 +46,15 @@ export const guardianActionsHandlers = defineHandlers({
45
46
  }
46
47
  }
47
48
 
49
+ // Resolve the local IPC actor's principal via the vellum guardian binding.
50
+ const localGuardianCtx = resolveLocalIpcGuardianContext('vellum');
48
51
  const canonicalResult = await applyCanonicalGuardianDecision({
49
52
  requestId: msg.requestId,
50
53
  action: msg.action as ApprovalAction,
51
54
  actorContext: {
52
- externalUserId: undefined,
55
+ externalUserId: localGuardianCtx.guardianExternalUserId,
53
56
  channel: 'vellum',
54
- isTrusted: true,
57
+ guardianPrincipalId: localGuardianCtx.guardianPrincipalId ?? undefined,
55
58
  },
56
59
  userText: undefined,
57
60
  });
@@ -123,12 +123,14 @@ function makeIpcEventSender(params: {
123
123
  });
124
124
 
125
125
  try {
126
+ const guardianContext = session.guardianContext;
126
127
  createCanonicalGuardianRequest({
127
128
  id: event.requestId,
128
129
  kind: 'tool_approval',
129
130
  sourceType: 'desktop',
130
131
  sourceChannel,
131
132
  conversationId,
133
+ guardianPrincipalId: guardianContext?.guardianPrincipalId ?? undefined,
132
134
  toolName: event.toolName,
133
135
  status: 'pending',
134
136
  requestCode: generateCanonicalRequestCode(),
@@ -597,13 +599,16 @@ export async function handleUserMessage(
597
599
  ]));
598
600
 
599
601
  if (pendingRequestIdsForConversation.length > 0) {
602
+ // Resolve the local IPC actor's principal via the vellum guardian binding
603
+ // for principal-based authorization in the canonical decision primitive.
604
+ const localCtx = resolveLocalIpcGuardianContext(ipcChannel);
600
605
  const routerResult = await routeGuardianReply({
601
606
  messageText: messageText.trim(),
602
607
  channel: ipcChannel,
603
608
  actor: {
604
- externalUserId: undefined,
609
+ externalUserId: localCtx.guardianExternalUserId,
605
610
  channel: ipcChannel,
606
- isTrusted: true,
611
+ guardianPrincipalId: localCtx.guardianPrincipalId ?? undefined,
607
612
  },
608
613
  conversationId: msg.sessionId,
609
614
  pendingRequestIds: pendingRequestIdsForConversation,
@@ -636,7 +641,7 @@ export async function handleUserMessage(
636
641
  assistantMessageChannel: ipcChannel,
637
642
  userMessageInterface: ipcInterface,
638
643
  assistantMessageInterface: ipcInterface,
639
- provenanceActorRole: 'guardian' as const,
644
+ provenanceTrustClass: 'guardian' as const,
640
645
  };
641
646
 
642
647
  const consumedUserMessage = createUserMessage(messageText, msg.attachments ?? []);