@vellumai/assistant 0.3.28 → 0.4.1

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 (201) hide show
  1. package/ARCHITECTURE.md +33 -3
  2. package/bun.lock +4 -1
  3. package/docs/trusted-contact-access.md +9 -2
  4. package/package.json +6 -3
  5. package/scripts/ipc/generate-swift.ts +3 -3
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  7. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +13 -5
  9. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  10. package/src/__tests__/asset-search-tool.test.ts +2 -0
  11. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  12. package/src/__tests__/attachments-store.test.ts +2 -0
  13. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  14. package/src/__tests__/call-controller.test.ts +30 -29
  15. package/src/__tests__/call-routes-http.test.ts +34 -32
  16. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  17. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  18. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  19. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  20. package/src/__tests__/clarification-resolver.test.ts +2 -0
  21. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  22. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  24. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  25. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  26. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  27. package/src/__tests__/config-schema.test.ts +5 -5
  28. package/src/__tests__/config-watcher.test.ts +3 -1
  29. package/src/__tests__/connection-policy.test.ts +14 -5
  30. package/src/__tests__/contacts-tools.test.ts +3 -1
  31. package/src/__tests__/contradiction-checker.test.ts +2 -0
  32. package/src/__tests__/conversation-pairing.test.ts +10 -0
  33. package/src/__tests__/conversation-routes.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  35. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  36. package/src/__tests__/credential-vault.test.ts +5 -4
  37. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  38. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  39. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  40. package/src/__tests__/encrypted-store.test.ts +10 -5
  41. package/src/__tests__/followup-tools.test.ts +3 -1
  42. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  43. package/src/__tests__/gmail-integration.test.ts +0 -1
  44. package/src/__tests__/guardian-control-plane-policy.test.ts +25 -21
  45. package/src/__tests__/guardian-dispatch.test.ts +2 -0
  46. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  47. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  48. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  49. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  50. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  51. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  52. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  53. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  54. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  55. package/src/__tests__/heartbeat-service.test.ts +20 -0
  56. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  57. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  58. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  59. package/src/__tests__/intent-routing.test.ts +2 -0
  60. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  61. package/src/__tests__/media-generate-image.test.ts +21 -0
  62. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  63. package/src/__tests__/memory-regressions.test.ts +20 -20
  64. package/src/__tests__/non-member-access-request.test.ts +183 -9
  65. package/src/__tests__/notification-decision-fallback.test.ts +2 -0
  66. package/src/__tests__/notification-decision-strategy.test.ts +61 -0
  67. package/src/__tests__/notification-guardian-path.test.ts +2 -0
  68. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  69. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  70. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  71. package/src/__tests__/pairing-routes.test.ts +171 -0
  72. package/src/__tests__/playbook-execution.test.ts +3 -1
  73. package/src/__tests__/playbook-tools.test.ts +3 -1
  74. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  75. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  76. package/src/__tests__/recording-handler.test.ts +11 -0
  77. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  78. package/src/__tests__/recording-state-machine.test.ts +13 -2
  79. package/src/__tests__/registry.test.ts +7 -3
  80. package/src/__tests__/relay-server.test.ts +148 -28
  81. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  82. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  83. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  84. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  85. package/src/__tests__/schedule-tools.test.ts +3 -1
  86. package/src/__tests__/send-endpoint-busy.test.ts +288 -0
  87. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  88. package/src/__tests__/session-agent-loop.test.ts +16 -0
  89. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  90. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  91. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  92. package/src/__tests__/session-profile-injection.test.ts +21 -0
  93. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  94. package/src/__tests__/session-queue.test.ts +23 -0
  95. package/src/__tests__/session-runtime-assembly.test.ts +50 -12
  96. package/src/__tests__/session-skill-tools.test.ts +27 -5
  97. package/src/__tests__/session-slash-known.test.ts +23 -0
  98. package/src/__tests__/session-slash-queue.test.ts +23 -0
  99. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  100. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  101. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  102. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  103. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  104. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  105. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  106. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  107. package/src/__tests__/skills.test.ts +8 -4
  108. package/src/__tests__/slack-channel-config.test.ts +3 -1
  109. package/src/__tests__/subagent-tools.test.ts +19 -0
  110. package/src/__tests__/swarm-recursion.test.ts +2 -0
  111. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  112. package/src/__tests__/swarm-tool.test.ts +2 -0
  113. package/src/__tests__/system-prompt.test.ts +3 -1
  114. package/src/__tests__/task-compiler.test.ts +3 -1
  115. package/src/__tests__/task-management-tools.test.ts +3 -1
  116. package/src/__tests__/task-tools.test.ts +3 -1
  117. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  118. package/src/__tests__/terminal-tools.test.ts +2 -0
  119. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  120. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  121. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  122. package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
  123. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  124. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  125. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  126. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  127. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  128. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  129. package/src/__tests__/view-image-tool.test.ts +3 -1
  130. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  131. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  132. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  133. package/src/__tests__/work-item-output.test.ts +3 -1
  134. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  135. package/src/calls/call-controller.ts +26 -23
  136. package/src/calls/guardian-action-sweep.ts +10 -2
  137. package/src/calls/relay-server.ts +216 -27
  138. package/src/calls/types.ts +1 -1
  139. package/src/calls/voice-session-bridge.ts +3 -3
  140. package/src/cli.ts +12 -0
  141. package/src/config/agent-schema.ts +14 -3
  142. package/src/config/calls-schema.ts +6 -6
  143. package/src/config/core-schema.ts +3 -3
  144. package/src/config/feature-flag-registry.json +8 -0
  145. package/src/config/mcp-schema.ts +1 -1
  146. package/src/config/memory-schema.ts +27 -19
  147. package/src/config/schema.ts +21 -21
  148. package/src/config/skills-schema.ts +7 -7
  149. package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
  150. package/src/daemon/handlers/config-inbox.ts +4 -4
  151. package/src/daemon/handlers/sessions.ts +148 -4
  152. package/src/daemon/ipc-contract/messages.ts +16 -0
  153. package/src/daemon/ipc-contract-inventory.json +1 -0
  154. package/src/daemon/lifecycle.ts +19 -0
  155. package/src/daemon/pairing-store.ts +86 -3
  156. package/src/daemon/response-tier.ts +6 -5
  157. package/src/daemon/session-agent-loop.ts +5 -5
  158. package/src/daemon/session-lifecycle.ts +25 -17
  159. package/src/daemon/session-memory.ts +2 -2
  160. package/src/daemon/session-process.ts +1 -20
  161. package/src/daemon/session-runtime-assembly.ts +28 -22
  162. package/src/daemon/session-tool-setup.ts +2 -2
  163. package/src/daemon/session.ts +3 -3
  164. package/src/memory/canonical-guardian-store.ts +63 -1
  165. package/src/memory/channel-guardian-store.ts +1 -0
  166. package/src/memory/conversation-crud.ts +7 -7
  167. package/src/memory/db-init.ts +4 -0
  168. package/src/memory/embedding-local.ts +257 -39
  169. package/src/memory/embedding-runtime-manager.ts +471 -0
  170. package/src/memory/guardian-bindings.ts +25 -1
  171. package/src/memory/indexer.ts +3 -3
  172. package/src/memory/ingress-invite-store.ts +45 -0
  173. package/src/memory/job-handlers/backfill.ts +16 -9
  174. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  175. package/src/memory/migrations/index.ts +1 -0
  176. package/src/memory/qdrant-client.ts +31 -22
  177. package/src/memory/schema.ts +4 -0
  178. package/src/notifications/copy-composer.ts +15 -0
  179. package/src/runtime/access-request-helper.ts +43 -7
  180. package/src/runtime/actor-trust-resolver.ts +46 -50
  181. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  182. package/src/runtime/channel-retry-sweep.ts +18 -6
  183. package/src/runtime/guardian-context-resolver.ts +38 -96
  184. package/src/runtime/guardian-reply-router.ts +31 -1
  185. package/src/runtime/ingress-service.ts +80 -3
  186. package/src/runtime/invite-redemption-service.ts +141 -2
  187. package/src/runtime/routes/channel-route-shared.ts +1 -1
  188. package/src/runtime/routes/channel-routes.ts +1 -1
  189. package/src/runtime/routes/conversation-routes.ts +166 -2
  190. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  191. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  192. package/src/runtime/routes/ingress-routes.ts +52 -4
  193. package/src/runtime/routes/pairing-routes.ts +3 -0
  194. package/src/tools/guardian-control-plane-policy.ts +2 -2
  195. package/src/tools/reminder/reminder-store.ts +10 -14
  196. package/src/tools/tool-approval-handler.ts +11 -11
  197. package/src/tools/types.ts +2 -2
  198. package/src/util/logger.ts +20 -8
  199. package/src/util/platform.ts +10 -0
  200. package/src/util/voice-code.ts +29 -0
  201. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -377,6 +377,25 @@ export async function runDaemon(): Promise<void> {
377
377
  socketPath: getSocketPath(),
378
378
  });
379
379
 
380
+ // Download embedding runtime in background (non-blocking).
381
+ // If download fails, local embeddings gracefully fall back to cloud backends.
382
+ void (async () => {
383
+ try {
384
+ const { EmbeddingRuntimeManager } = await import('../memory/embedding-runtime-manager.js');
385
+ const runtimeManager = new EmbeddingRuntimeManager();
386
+ if (!runtimeManager.isReady()) {
387
+ log.info('Downloading embedding runtime in background...');
388
+ await runtimeManager.ensureInstalled();
389
+ // Reset the localBackendBroken flag so auto mode retries local embeddings
390
+ const { clearEmbeddingBackendCache } = await import('../memory/embedding-backend.js');
391
+ clearEmbeddingBackendCache();
392
+ log.info('Embedding runtime download complete');
393
+ }
394
+ } catch (err) {
395
+ log.warn({ err }, 'Embedding runtime download failed — local embeddings will use cloud fallback');
396
+ }
397
+ })();
398
+
380
399
  if (config.auditLog.retentionDays > 0) {
381
400
  try {
382
401
  rotateToolInvocations(config.auditLog.retentionDays);
@@ -1,14 +1,21 @@
1
1
  /**
2
- * In-memory pairing request store with TTL.
2
+ * Pairing request store with TTL and disk persistence.
3
3
  *
4
4
  * Each pairing request lives for at most TTL_MS (5 minutes) before
5
5
  * being swept as expired. Status transitions:
6
6
  * registered → pending → approved | denied | expired
7
+ *
8
+ * Entries are persisted to ~/.vellum/protected/pairing-requests.json
9
+ * using the same atomic-write pattern as approved-devices-store.ts
10
+ * so that device bindings survive daemon restarts.
7
11
  */
8
12
 
9
13
  import { createHash, timingSafeEqual } from 'node:crypto';
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
10
16
 
11
17
  import { getLogger } from '../util/logger.js';
18
+ import { getRootDir } from '../util/platform.js';
12
19
 
13
20
  const log = getLogger('pairing-store');
14
21
 
@@ -40,11 +47,60 @@ function timingSafeCompare(a: string, b: string): boolean {
40
47
  return timingSafeEqual(bufA, bufB);
41
48
  }
42
49
 
50
+ interface PairingStoreFile {
51
+ version: 1;
52
+ requests: PairingRequest[];
53
+ }
54
+
55
+ function getStorePath(): string {
56
+ return join(getRootDir(), 'protected', 'pairing-requests.json');
57
+ }
58
+
59
+ function loadFromDisk(): Map<string, PairingRequest> {
60
+ const path = getStorePath();
61
+ if (!existsSync(path)) {
62
+ return new Map();
63
+ }
64
+ try {
65
+ const raw = readFileSync(path, 'utf-8');
66
+ const data = JSON.parse(raw) as PairingStoreFile;
67
+ if (data.version !== 1 || !Array.isArray(data.requests)) {
68
+ log.warn('Invalid pairing-requests.json format, starting fresh');
69
+ return new Map();
70
+ }
71
+ const map = new Map<string, PairingRequest>();
72
+ for (const entry of data.requests) {
73
+ map.set(entry.pairingRequestId, entry);
74
+ }
75
+ return map;
76
+ } catch (err) {
77
+ log.error({ err }, 'Failed to load pairing-requests.json');
78
+ return new Map();
79
+ }
80
+ }
81
+
82
+ function saveToDisk(requests: Map<string, PairingRequest>): void {
83
+ const path = getStorePath();
84
+ const dir = dirname(path);
85
+ if (!existsSync(dir)) {
86
+ mkdirSync(dir, { recursive: true });
87
+ }
88
+ const data: PairingStoreFile = {
89
+ version: 1,
90
+ requests: Array.from(requests.values()),
91
+ };
92
+ const tmpPath = path + '.tmp.' + process.pid;
93
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 });
94
+ renameSync(tmpPath, path);
95
+ chmodSync(path, 0o600);
96
+ }
97
+
43
98
  export class PairingStore {
44
99
  private requests = new Map<string, PairingRequest>();
45
100
  private sweepTimer: ReturnType<typeof setInterval> | null = null;
46
101
 
47
102
  start(): void {
103
+ this.requests = loadFromDisk();
48
104
  this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
49
105
  }
50
106
 
@@ -84,6 +140,7 @@ export class PairingStore {
84
140
  localLanUrl: params.localLanUrl ?? null,
85
141
  createdAt: Date.now(),
86
142
  });
143
+ this.persist();
87
144
 
88
145
  log.info({ pairingRequestId: params.pairingRequestId }, 'Pairing request registered');
89
146
  return { ok: true };
@@ -98,7 +155,7 @@ export class PairingStore {
98
155
  pairingSecret: string;
99
156
  deviceId: string;
100
157
  deviceName: string;
101
- }): { ok: true; entry: PairingRequest } | { ok: false; reason: 'not_found' | 'invalid_secret' | 'expired' } {
158
+ }): { ok: true; entry: PairingRequest } | { ok: false; reason: 'not_found' | 'invalid_secret' | 'expired' | 'already_paired' } {
102
159
  const entry = this.requests.get(params.pairingRequestId);
103
160
  if (!entry) {
104
161
  return { ok: false, reason: 'not_found' };
@@ -113,11 +170,21 @@ export class PairingStore {
113
170
  return { ok: false, reason: 'invalid_secret' };
114
171
  }
115
172
 
116
- entry.hashedDeviceId = hashValue(params.deviceId);
173
+ const hashedDeviceId = hashValue(params.deviceId);
174
+
175
+ // If a device has already been bound to this pairing request, reject
176
+ // attempts from a different device to prevent hijacking.
177
+ if (entry.hashedDeviceId && !timingSafeCompare(entry.hashedDeviceId, hashedDeviceId)) {
178
+ log.warn({ pairingRequestId: params.pairingRequestId }, 'Pairing request already bound to a different device');
179
+ return { ok: false, reason: 'already_paired' };
180
+ }
181
+
182
+ entry.hashedDeviceId = hashedDeviceId;
117
183
  entry.deviceName = params.deviceName;
118
184
  if (entry.status === 'registered') {
119
185
  entry.status = 'pending';
120
186
  }
187
+ this.persist();
121
188
 
122
189
  return { ok: true, entry };
123
190
  }
@@ -130,6 +197,7 @@ export class PairingStore {
130
197
  if (!entry) return null;
131
198
  entry.status = 'approved';
132
199
  entry.bearerToken = bearerToken;
200
+ this.persist();
133
201
  return entry;
134
202
  }
135
203
 
@@ -140,6 +208,7 @@ export class PairingStore {
140
208
  const entry = this.requests.get(pairingRequestId);
141
209
  if (!entry) return null;
142
210
  entry.status = 'denied';
211
+ this.persist();
143
212
  return entry;
144
213
  }
145
214
 
@@ -160,19 +229,33 @@ export class PairingStore {
160
229
  return timingSafeCompare(entry.hashedPairingSecret, hashedSecret);
161
230
  }
162
231
 
232
+ private persist(): void {
233
+ try {
234
+ saveToDisk(this.requests);
235
+ } catch (err) {
236
+ log.error({ err }, 'Failed to persist pairing requests to disk');
237
+ }
238
+ }
239
+
163
240
  private sweep(): void {
164
241
  const now = Date.now();
242
+ let changed = false;
165
243
  for (const [id, entry] of this.requests) {
166
244
  if (now - entry.createdAt > TTL_MS) {
167
245
  if (entry.status !== 'approved') {
168
246
  entry.status = 'expired';
247
+ changed = true;
169
248
  }
170
249
  // Remove entries older than 2x TTL regardless of status
171
250
  if (now - entry.createdAt > TTL_MS * 2) {
172
251
  this.requests.delete(id);
252
+ changed = true;
173
253
  log.debug({ pairingRequestId: id }, 'Pairing request swept');
174
254
  }
175
255
  }
176
256
  }
257
+ if (changed) {
258
+ this.persist();
259
+ }
177
260
  }
178
261
  }
@@ -145,15 +145,16 @@ const TIER_SYSTEM_PROMPT =
145
145
 
146
146
  /**
147
147
  * Fire-and-forget Haiku call to classify the conversation trajectory.
148
- * Returns the classified tier or null on any failure.
148
+ * Returns the classified tier, or undefined when no provider is configured
149
+ * or on any failure.
149
150
  */
150
151
  export async function classifyResponseTierAsync(
151
152
  recentUserTexts: string[],
152
- ): Promise<ResponseTier | null> {
153
+ ): Promise<ResponseTier | undefined> {
153
154
  const provider = getConfiguredProvider();
154
155
  if (!provider) {
155
156
  log.debug('No provider available for async tier classification');
156
- return null;
157
+ return undefined;
157
158
  }
158
159
 
159
160
  const combined = recentUserTexts
@@ -186,14 +187,14 @@ export async function classifyResponseTierAsync(
186
187
  }
187
188
 
188
189
  log.debug({ raw }, 'Async tier classification returned unexpected value');
189
- return null;
190
+ return undefined;
190
191
  } finally {
191
192
  cleanup();
192
193
  }
193
194
  } catch (err) {
194
195
  const message = err instanceof Error ? err.message : String(err);
195
196
  log.debug({ err: message }, 'Async tier classification failed');
196
- return null;
197
+ return undefined;
197
198
  }
198
199
  }
199
200
 
@@ -258,7 +258,7 @@ export async function runAgentLoopImpl(
258
258
  conflictGate: ctx.conflictGate,
259
259
  scopeId: ctx.memoryPolicy.scopeId,
260
260
  includeDefaultFallback: ctx.memoryPolicy.includeDefaultFallback,
261
- guardianActorRole: ctx.guardianContext?.actorRole,
261
+ guardianTrustClass: ctx.guardianContext?.trustClass,
262
262
  isInteractive: options?.isInteractive ?? (!ctx.hasNoClient && !ctx.headlessLock),
263
263
  },
264
264
  content,
@@ -355,10 +355,9 @@ export async function runAgentLoopImpl(
355
355
 
356
356
  // Resolve the inbound actor context for the model's <inbound_actor_context>
357
357
  // block. When the session carries enough identity info, use the unified
358
- // actor trust resolver so trusted_contact classifications propagate
359
- // correctly (the legacy guardian-context path collapses non-guardian to
360
- // 'unknown'). The guardian context is still used for policy gating — only
361
- // the model context block uses the trust-resolved output.
358
+ // actor trust resolver so member status/policy and guardian binding details
359
+ // are fresh for this turn. The session runtime context remains the source
360
+ // for policy gating; this block is model-facing grounding metadata.
362
361
  let resolvedInboundActorContext: InboundActorContext | null = null;
363
362
  if (ctx.guardianContext) {
364
363
  const gc = ctx.guardianContext;
@@ -368,6 +367,7 @@ export async function runAgentLoopImpl(
368
367
  sourceChannel: gc.sourceChannel,
369
368
  externalChatId: gc.requesterChatId,
370
369
  senderExternalUserId: gc.requesterExternalUserId,
370
+ senderDisplayName: gc.requesterSenderDisplayName,
371
371
  });
372
372
  resolvedInboundActorContext = inboundActorContextFromTrust(actorTrust);
373
373
  } else {
@@ -24,30 +24,38 @@ import { resetSkillToolProjection } from './session-skill-tools.js';
24
24
 
25
25
  const log = getLogger('session-lifecycle');
26
26
 
27
- type GuardianActorRole = GuardianRuntimeContext['actorRole'];
27
+ type GuardianTrustClass = GuardianRuntimeContext['trustClass'];
28
28
 
29
- function parseProvenanceActorRole(metadata: string | null): GuardianActorRole | undefined {
29
+ function parseProvenanceTrustClass(metadata: string | null): GuardianTrustClass | undefined {
30
30
  if (!metadata) return undefined;
31
31
  try {
32
- const parsed = JSON.parse(metadata) as { provenanceActorRole?: unknown };
33
- const role = parsed?.provenanceActorRole;
34
- if (role === 'guardian' || role === 'non-guardian' || role === 'unverified_channel') {
35
- return role;
32
+ const parsed = JSON.parse(metadata) as {
33
+ provenanceTrustClass?: unknown;
34
+ provenanceActorRole?: unknown;
35
+ };
36
+ const trustClass = parsed?.provenanceTrustClass;
37
+ if (trustClass === 'guardian' || trustClass === 'trusted_contact' || trustClass === 'unknown') {
38
+ return trustClass;
36
39
  }
40
+ // Legacy fallback for rows persisted before provenanceTrustClass existed.
41
+ const legacyRole = parsed?.provenanceActorRole;
42
+ if (legacyRole === 'guardian') return 'guardian';
43
+ if (legacyRole === 'non-guardian') return 'trusted_contact';
44
+ if (legacyRole === 'unverified_channel') return 'unknown';
37
45
  } catch {
38
46
  // Ignore malformed metadata and treat as unknown provenance.
39
47
  }
40
48
  return undefined;
41
49
  }
42
50
 
43
- function isUntrustedActorRole(role: GuardianActorRole | undefined): boolean {
44
- return role === 'non-guardian' || role === 'unverified_channel';
51
+ function isUntrustedTrustClass(trustClass: GuardianTrustClass | undefined): boolean {
52
+ return trustClass === 'trusted_contact' || trustClass === 'unknown';
45
53
  }
46
54
 
47
55
  function filterMessagesForUntrustedActor(messages: conversationStore.MessageRow[]): conversationStore.MessageRow[] {
48
56
  return messages.filter((m) => {
49
- const provenanceRole = parseProvenanceActorRole(m.metadata);
50
- return provenanceRole === 'non-guardian' || provenanceRole === 'unverified_channel';
57
+ const provenanceTrustClass = parseProvenanceTrustClass(m.metadata);
58
+ return provenanceTrustClass === 'trusted_contact' || provenanceTrustClass === 'unknown';
51
59
  });
52
60
  }
53
61
 
@@ -59,8 +67,8 @@ export interface LoadFromDbContext {
59
67
  usageStats: UsageStats;
60
68
  contextCompactedMessageCount: number;
61
69
  contextCompactedAt: number | null;
62
- guardianContext?: { actorRole: GuardianActorRole };
63
- loadedHistoryActorRole?: GuardianActorRole;
70
+ guardianContext?: { trustClass: GuardianTrustClass };
71
+ loadedHistoryTrustClass?: GuardianTrustClass;
64
72
  }
65
73
 
66
74
  export interface AbortContext {
@@ -89,17 +97,17 @@ export interface DisposeContext extends AbortContext {
89
97
  // ── loadFromDb ───────────────────────────────────────────────────────
90
98
 
91
99
  export async function loadFromDb(ctx: LoadFromDbContext): Promise<void> {
92
- const actorRole = ctx.guardianContext?.actorRole;
100
+ const trustClass = ctx.guardianContext?.trustClass;
93
101
  const allDbMessages = conversationStore.getMessages(ctx.conversationId);
94
- const dbMessages = isUntrustedActorRole(actorRole)
102
+ const dbMessages = isUntrustedTrustClass(trustClass)
95
103
  ? filterMessagesForUntrustedActor(allDbMessages)
96
104
  : allDbMessages;
97
105
 
98
106
  const conv = conversationStore.getConversation(ctx.conversationId);
99
- const contextSummary = !isUntrustedActorRole(actorRole)
107
+ const contextSummary = !isUntrustedTrustClass(trustClass)
100
108
  ? conv?.contextSummary?.trim() || null
101
109
  : null;
102
- if (isUntrustedActorRole(actorRole)) {
110
+ if (isUntrustedTrustClass(trustClass)) {
103
111
  // Compacted summaries may include trusted/guardian-only details, so we
104
112
  // disable summary-based context for untrusted actor views.
105
113
  ctx.contextCompactedMessageCount = 0;
@@ -145,7 +153,7 @@ export async function loadFromDb(ctx: LoadFromDbContext): Promise<void> {
145
153
  };
146
154
  }
147
155
 
148
- ctx.loadedHistoryActorRole = actorRole;
156
+ ctx.loadedHistoryTrustClass = trustClass;
149
157
 
150
158
  log.info({ conversationId: ctx.conversationId, count: ctx.messages.length }, 'Loaded messages from DB');
151
159
  }
@@ -34,7 +34,7 @@ export interface MemoryPrepareContext {
34
34
  conflictGate: ConflictGate;
35
35
  scopeId: string;
36
36
  includeDefaultFallback: boolean;
37
- guardianActorRole?: 'guardian' | 'non-guardian' | 'unverified_channel';
37
+ guardianTrustClass?: 'guardian' | 'trusted_contact' | 'unknown';
38
38
  /** When false (e.g. scheduled tasks), skip conflict clarification prompts. */
39
39
  isInteractive?: boolean;
40
40
  }
@@ -64,7 +64,7 @@ export async function prepareMemoryContext(
64
64
  // Provenance-based trust gating: untrusted actors skip all memory operations
65
65
  // (recall, dynamic profile, conflict gate) to prevent untrusted content from
66
66
  // influencing memory-augmented responses.
67
- const isTrustedActor = ctx.guardianActorRole === 'guardian' || ctx.guardianActorRole === undefined;
67
+ const isTrustedActor = ctx.guardianTrustClass === 'guardian' || ctx.guardianTrustClass === undefined;
68
68
 
69
69
  if (!isTrustedActor) {
70
70
  return {
@@ -21,7 +21,6 @@ import { createPreference } from '../notifications/preferences-store.js';
21
21
  import type { Message } from '../providers/types.js';
22
22
  import { routeGuardianReply } from '../runtime/guardian-reply-router.js';
23
23
  import { getLogger } from '../util/logger.js';
24
- import { resolveGuardianInviteIntent } from './guardian-invite-intent.js';
25
24
  import { resolveGuardianVerificationIntent } from './guardian-verification-intent.js';
26
25
  import type { UsageStats } from './ipc-contract.js';
27
26
  import type { ServerMessage, UserMessageAttachment } from './ipc-protocol.js';
@@ -266,15 +265,6 @@ export async function drainQueue(session: ProcessSessionContext, reason: QueueDr
266
265
  log.info({ conversationId: session.conversationId, channelHint: guardianIntent.channelHint }, 'Guardian verification intent intercepted in queue — forcing skill flow');
267
266
  agentLoopContent = guardianIntent.rewrittenContent;
268
267
  session.preactivatedSkillIds = ['guardian-verify-setup'];
269
- } else {
270
- // Guardian invite intent interception — force invite management
271
- // requests into the trusted-contacts skill flow.
272
- const inviteIntent = resolveGuardianInviteIntent(resolvedContent);
273
- if (inviteIntent.kind === 'invite_management') {
274
- log.info({ conversationId: session.conversationId, action: inviteIntent.action }, 'Guardian invite intent intercepted in queue — forcing skill flow');
275
- agentLoopContent = inviteIntent.rewrittenContent;
276
- session.preactivatedSkillIds = ['trusted-contacts'];
277
- }
278
268
  }
279
269
  }
280
270
 
@@ -402,7 +392,7 @@ export async function processMessage(
402
392
  assistantMessageChannel: 'vellum' as const,
403
393
  userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum',
404
394
  assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum',
405
- provenanceActorRole: 'guardian' as const,
395
+ provenanceTrustClass: 'guardian' as const,
406
396
  };
407
397
 
408
398
  const userMsg = createUserMessage(content, attachments);
@@ -520,15 +510,6 @@ export async function processMessage(
520
510
  log.info({ conversationId: session.conversationId, channelHint: guardianIntent.channelHint }, 'Guardian verification intent intercepted — forcing skill flow');
521
511
  agentLoopContent = guardianIntent.rewrittenContent;
522
512
  session.preactivatedSkillIds = ['guardian-verify-setup'];
523
- } else {
524
- // Guardian invite intent interception — force invite management
525
- // requests into the trusted-contacts skill flow.
526
- const inviteIntent = resolveGuardianInviteIntent(resolvedContent);
527
- if (inviteIntent.kind === 'invite_management') {
528
- log.info({ conversationId: session.conversationId, action: inviteIntent.action }, 'Guardian invite intent intercepted — forcing skill flow');
529
- agentLoopContent = inviteIntent.rewrittenContent;
530
- session.preactivatedSkillIds = ['trusted-contacts'];
531
- }
532
513
  }
533
514
  }
534
515
 
@@ -35,10 +35,13 @@ export interface ChannelCapabilities {
35
35
  /** Guardian identity/trust context for external chat channels. */
36
36
  export interface GuardianRuntimeContext {
37
37
  sourceChannel: ChannelId;
38
- actorRole: 'guardian' | 'non-guardian' | 'unverified_channel';
38
+ trustClass: 'guardian' | 'trusted_contact' | 'unknown';
39
39
  guardianChatId?: string;
40
40
  guardianExternalUserId?: string;
41
41
  requesterIdentifier?: string;
42
+ requesterDisplayName?: string;
43
+ requesterSenderDisplayName?: string;
44
+ requesterMemberDisplayName?: string;
42
45
  requesterExternalUserId?: string;
43
46
  requesterChatId?: string;
44
47
  denialReason?: 'no_binding' | 'no_identity';
@@ -58,6 +61,12 @@ export interface InboundActorContext {
58
61
  canonicalActorIdentity: string | null;
59
62
  /** Human-readable actor identifier (e.g. @username or phone). */
60
63
  actorIdentifier?: string;
64
+ /** Human-readable actor display name (e.g. "Jeff"). */
65
+ actorDisplayName?: string;
66
+ /** Raw sender display name as provided by the channel transport. */
67
+ actorSenderDisplayName?: string;
68
+ /** Guardian-managed member display name from ingress membership. */
69
+ actorMemberDisplayName?: string;
61
70
  /** Trust classification: guardian, trusted_contact, or unknown. */
62
71
  trustClass: 'guardian' | 'trusted_contact' | 'unknown';
63
72
  /** Guardian identity for this (assistant, channel) binding. */
@@ -73,33 +82,17 @@ export interface InboundActorContext {
73
82
  /**
74
83
  * Construct an InboundActorContext from a legacy GuardianRuntimeContext.
75
84
  *
76
- * Maps the legacy actor role to the new trust classification:
77
- * - guardian -> guardian
78
- * - non-guardian -> unknown (the legacy context carries no membership
79
- * evidence, so we cannot distinguish known members from arbitrary
80
- * non-guardian senders; default to unknown for safety)
81
- * - unverified_channel -> unknown
82
- *
83
- * The new ActorTrustContext path (via `inboundActorContextFromTrust`)
84
- * resolves `trusted_contact` correctly using ingress member records.
85
+ * Maps the runtime trust class into the model-facing inbound actor context.
85
86
  */
86
87
  export function inboundActorContextFromGuardian(ctx: GuardianRuntimeContext): InboundActorContext {
87
- let trustClass: InboundActorContext['trustClass'];
88
- switch (ctx.actorRole) {
89
- case 'guardian':
90
- trustClass = 'guardian';
91
- break;
92
- case 'non-guardian':
93
- case 'unverified_channel':
94
- trustClass = 'unknown';
95
- break;
96
- }
97
-
98
88
  return {
99
89
  sourceChannel: ctx.sourceChannel,
100
90
  canonicalActorIdentity: ctx.requesterExternalUserId ?? null,
101
91
  actorIdentifier: ctx.requesterIdentifier,
102
- trustClass,
92
+ actorDisplayName: ctx.requesterDisplayName,
93
+ actorSenderDisplayName: ctx.requesterSenderDisplayName,
94
+ actorMemberDisplayName: ctx.requesterMemberDisplayName,
95
+ trustClass: ctx.trustClass,
103
96
  guardianIdentity: ctx.guardianExternalUserId,
104
97
  denialReason: ctx.denialReason,
105
98
  };
@@ -114,6 +107,9 @@ export function inboundActorContextFromTrust(ctx: ActorTrustContext): InboundAct
114
107
  sourceChannel: ctx.actorMetadata.channel,
115
108
  canonicalActorIdentity: ctx.canonicalSenderId,
116
109
  actorIdentifier: ctx.actorMetadata.identifier,
110
+ actorDisplayName: ctx.actorMetadata.displayName,
111
+ actorSenderDisplayName: ctx.actorMetadata.senderDisplayName,
112
+ actorMemberDisplayName: ctx.actorMetadata.memberDisplayName,
117
113
  trustClass: ctx.trustClass,
118
114
  guardianIdentity: ctx.guardianBindingMatch?.guardianExternalUserId,
119
115
  memberStatus: ctx.memberRecord?.status ?? undefined,
@@ -552,6 +548,9 @@ export function buildInboundActorContextBlock(ctx: InboundActorContext): string
552
548
  lines.push(`source_channel: ${ctx.sourceChannel}`);
553
549
  lines.push(`canonical_actor_identity: ${ctx.canonicalActorIdentity ?? 'unknown'}`);
554
550
  lines.push(`actor_identifier: ${ctx.actorIdentifier ?? 'unknown'}`);
551
+ lines.push(`actor_display_name: ${ctx.actorDisplayName ?? 'unknown'}`);
552
+ lines.push(`actor_sender_display_name: ${ctx.actorSenderDisplayName ?? 'unknown'}`);
553
+ lines.push(`actor_member_display_name: ${ctx.actorMemberDisplayName ?? 'unknown'}`);
555
554
  lines.push(`trust_class: ${ctx.trustClass}`);
556
555
  lines.push(`guardian_identity: ${ctx.guardianIdentity ?? 'unknown'}`);
557
556
  if (ctx.memberStatus) {
@@ -561,6 +560,13 @@ export function buildInboundActorContextBlock(ctx: InboundActorContext): string
561
560
  lines.push(`member_policy: ${ctx.memberPolicy}`);
562
561
  }
563
562
  lines.push(`denial_reason: ${ctx.denialReason ?? 'none'}`);
563
+ if (
564
+ ctx.actorMemberDisplayName
565
+ && ctx.actorSenderDisplayName
566
+ && ctx.actorMemberDisplayName !== ctx.actorSenderDisplayName
567
+ ) {
568
+ lines.push('name_preference_note: actor_member_display_name is the guardian-preferred nickname for this person; actor_sender_display_name is the channel-provided display name.');
569
+ }
564
570
 
565
571
  // Behavioral guidance — injected per-turn so it only appears when relevant.
566
572
  lines.push('');
@@ -56,7 +56,7 @@ export interface ToolSetupContext extends SurfaceSessionContext {
56
56
  headlessLock?: boolean;
57
57
  /** When set, this session is executing a task run. Used to retrieve ephemeral permission rules. */
58
58
  taskRunId?: string;
59
- /** Guardian runtime context for the session — actorRole is propagated into ToolContext for control-plane policy enforcement. */
59
+ /** Guardian runtime context for the session — trustClass is propagated into ToolContext for control-plane policy enforcement. */
60
60
  guardianContext?: GuardianRuntimeContext;
61
61
  /** Voice/call session ID, if the session originates from a call. Propagated into ToolContext for scoped grant consumption. */
62
62
  callSessionId?: string;
@@ -110,7 +110,7 @@ export function createToolExecutor(
110
110
  assistantId: ctx.assistantId,
111
111
  requestId: ctx.currentRequestId,
112
112
  taskRunId: ctx.taskRunId,
113
- guardianActorRole: ctx.guardianContext?.actorRole,
113
+ guardianTrustClass: ctx.guardianContext?.trustClass,
114
114
  executionChannel: ctx.guardianContext?.sourceChannel,
115
115
  callSessionId: ctx.callSessionId,
116
116
  requesterExternalUserId: ctx.guardianContext?.requesterExternalUserId,
@@ -140,7 +140,7 @@ export class Session {
140
140
  /** @internal */ currentPage?: string;
141
141
  /** @internal */ channelCapabilities?: ChannelCapabilities;
142
142
  /** @internal */ guardianContext?: GuardianRuntimeContext;
143
- /** @internal */ loadedHistoryActorRole?: GuardianRuntimeContext['actorRole'];
143
+ /** @internal */ loadedHistoryTrustClass?: GuardianRuntimeContext['trustClass'];
144
144
  /** @internal */ voiceCallControlPrompt?: string;
145
145
  /** @internal */ assistantId?: string;
146
146
  /** @internal */ commandIntent?: { type: string; payload?: string; languageCode?: string };
@@ -338,8 +338,8 @@ export class Session {
338
338
  }
339
339
 
340
340
  async ensureActorScopedHistory(): Promise<void> {
341
- const currentRole = this.guardianContext?.actorRole;
342
- if (this.loadedHistoryActorRole === currentRole) return;
341
+ const currentTrustClass = this.guardianContext?.trustClass;
342
+ if (this.loadedHistoryTrustClass === currentTrustClass) return;
343
343
  await this.loadFromDb();
344
344
  }
345
345
 
@@ -7,7 +7,7 @@
7
7
  * request from the expected status wins.
8
8
  */
9
9
 
10
- import { and, eq } from 'drizzle-orm';
10
+ import { and, desc, eq } from 'drizzle-orm';
11
11
  import { v4 as uuid } from 'uuid';
12
12
 
13
13
  import { getDb, rawChanges } from './db.js';
@@ -498,6 +498,68 @@ export interface UpdateCanonicalGuardianDeliveryParams {
498
498
  destinationMessageId?: string;
499
499
  }
500
500
 
501
+ // ---------------------------------------------------------------------------
502
+ // Call-controller convenience functions
503
+ // ---------------------------------------------------------------------------
504
+
505
+ /**
506
+ * Find the most recent pending canonical guardian request for a given call session.
507
+ * Used by the call-controller's consultation timeout handler.
508
+ */
509
+ export function getPendingCanonicalRequestByCallSessionId(callSessionId: string): CanonicalGuardianRequest | null {
510
+ const db = getDb();
511
+ const row = db
512
+ .select()
513
+ .from(canonicalGuardianRequests)
514
+ .where(
515
+ and(
516
+ eq(canonicalGuardianRequests.callSessionId, callSessionId),
517
+ eq(canonicalGuardianRequests.status, 'pending'),
518
+ ),
519
+ )
520
+ .orderBy(desc(canonicalGuardianRequests.createdAt))
521
+ .get();
522
+ return row ? rowToRequest(row) : null;
523
+ }
524
+
525
+ /**
526
+ * Find a canonical guardian request by its linked pending question ID.
527
+ * Used after async dispatch completes to locate the newly created request.
528
+ */
529
+ export function getCanonicalRequestByPendingQuestionId(questionId: string): CanonicalGuardianRequest | null {
530
+ const db = getDb();
531
+ const row = db
532
+ .select()
533
+ .from(canonicalGuardianRequests)
534
+ .where(eq(canonicalGuardianRequests.pendingQuestionId, questionId))
535
+ .get();
536
+ return row ? rowToRequest(row) : null;
537
+ }
538
+
539
+ /**
540
+ * Expire a canonical guardian request and all its deliveries.
541
+ * Atomically transitions the request from 'pending' to 'expired'.
542
+ */
543
+ export function expireCanonicalGuardianRequest(id: string): void {
544
+ const db = getDb();
545
+ const now = new Date().toISOString();
546
+
547
+ db.update(canonicalGuardianRequests)
548
+ .set({ status: 'expired', updatedAt: now })
549
+ .where(
550
+ and(
551
+ eq(canonicalGuardianRequests.id, id),
552
+ eq(canonicalGuardianRequests.status, 'pending'),
553
+ ),
554
+ )
555
+ .run();
556
+
557
+ db.update(canonicalGuardianDeliveries)
558
+ .set({ status: 'expired', updatedAt: now })
559
+ .where(eq(canonicalGuardianDeliveries.requestId, id))
560
+ .run();
561
+ }
562
+
501
563
  export function updateCanonicalGuardianDelivery(
502
564
  id: string,
503
565
  updates: UpdateCanonicalGuardianDeliveryParams,
@@ -37,6 +37,7 @@ export {
37
37
  createBinding,
38
38
  getActiveBinding,
39
39
  type GuardianBinding,
40
+ listActiveBindingsByAssistant,
40
41
  revokeBinding,
41
42
  } from './guardian-bindings.js';
42
43
  export {