@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -267,6 +267,7 @@
267
267
  "message_dequeued",
268
268
  "message_queued",
269
269
  "message_queued_deleted",
270
+ "message_request_complete",
270
271
  "model_info",
271
272
  "navigate_settings",
272
273
  "notification_intent",
@@ -59,7 +59,6 @@ import type { ServerMessage } from './ipc-protocol.js';
59
59
  import { initializeProvidersAndTools, registerMessagingProviders,registerWatcherProviders } from './providers-setup.js';
60
60
  import { seedInterfaceFiles } from './seed-files.js';
61
61
  import { DaemonServer } from './server.js';
62
- import { setApprovalConversationGenerator, setGuardianActionCopyGenerator, setGuardianFollowUpConversationGenerator } from './session-process.js';
63
62
  import { initSlashPairingContext } from './session-slash.js';
64
63
  import { installShutdownHandlers } from './shutdown-handlers.js';
65
64
 
@@ -320,21 +319,9 @@ export async function runDaemon(): Promise<void> {
320
319
  server.persistAndProcessMessage(conversationId, content, attachmentIds, options, sourceChannel, sourceInterface),
321
320
  interfacesDir: getInterfacesDir(),
322
321
  approvalCopyGenerator: createApprovalCopyGenerator(),
323
- approvalConversationGenerator: (() => {
324
- const gen = createApprovalConversationGenerator();
325
- setApprovalConversationGenerator(gen);
326
- return gen;
327
- })(),
328
- guardianActionCopyGenerator: (() => {
329
- const gen = createGuardianActionCopyGenerator();
330
- setGuardianActionCopyGenerator(gen);
331
- return gen;
332
- })(),
333
- guardianFollowUpConversationGenerator: (() => {
334
- const gen = createGuardianFollowUpConversationGenerator();
335
- setGuardianFollowUpConversationGenerator(gen);
336
- return gen;
337
- })(),
322
+ approvalConversationGenerator: createApprovalConversationGenerator(),
323
+ guardianActionCopyGenerator: createGuardianActionCopyGenerator(),
324
+ guardianFollowUpConversationGenerator: createGuardianFollowUpConversationGenerator(),
338
325
  sendMessageDeps: {
339
326
  getOrCreateSession: (conversationId) =>
340
327
  server.getSessionForMessages(conversationId),
@@ -390,6 +377,25 @@ export async function runDaemon(): Promise<void> {
390
377
  socketPath: getSocketPath(),
391
378
  });
392
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
+
393
399
  if (config.auditLog.retentionDays > 0) {
394
400
  try {
395
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
  }
@@ -10,6 +10,10 @@ import { buildSystemPrompt } from '../config/system-prompt.js';
10
10
  import type { HeartbeatService } from '../heartbeat/heartbeat-service.js';
11
11
  import { bootstrapHomeBaseAppLink } from '../home-base/bootstrap.js';
12
12
  import * as attachmentsStore from '../memory/attachments-store.js';
13
+ import {
14
+ createCanonicalGuardianRequest,
15
+ generateCanonicalRequestCode,
16
+ } from '../memory/canonical-guardian-store.js';
13
17
  import * as conversationStore from '../memory/conversation-store.js';
14
18
  import { provenanceFromGuardianContext } from '../memory/conversation-store.js';
15
19
  import { RateLimitProvider } from '../providers/ratelimit.js';
@@ -114,6 +118,20 @@ function makePendingInteractionRegistrar(
114
118
  persistentDecisionsAllowed: msg.persistentDecisionsAllowed,
115
119
  },
116
120
  });
121
+
122
+ // Create a canonical guardian request so IPC/HTTP handlers can find it
123
+ // via applyCanonicalGuardianDecision.
124
+ createCanonicalGuardianRequest({
125
+ id: msg.requestId,
126
+ kind: 'tool_approval',
127
+ sourceType: 'desktop',
128
+ sourceChannel: 'vellum',
129
+ conversationId,
130
+ toolName: msg.toolName,
131
+ status: 'pending',
132
+ requestCode: generateCanonicalRequestCode(),
133
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
134
+ });
117
135
  } else if (msg.type === 'secret_request') {
118
136
  pendingInteractions.register(msg.requestId, {
119
137
  session,
@@ -330,6 +330,7 @@ export async function handleMessageComplete(
330
330
  // Clean assistant content and accumulate directives
331
331
  const { cleanedContent, directives: msgDirectives, warnings: msgWarnings } =
332
332
  cleanAssistantContent(event.message.content);
333
+ const cleanedBlocks = cleanedContent as ContentBlock[];
333
334
  state.accumulatedDirectives.push(...msgDirectives);
334
335
  state.directiveWarnings.push(...msgWarnings);
335
336
  if (msgDirectives.length > 0) {
@@ -340,7 +341,7 @@ export async function handleMessageComplete(
340
341
  }
341
342
 
342
343
  // Build content with UI surfaces
343
- const contentWithSurfaces: ContentBlock[] = [...cleanedContent as ContentBlock[]];
344
+ const contentWithSurfaces: ContentBlock[] = [...cleanedBlocks];
344
345
  for (const surface of deps.ctx.currentTurnSurfaces) {
345
346
  contentWithSurfaces.push({
346
347
  type: 'ui_surface',
@@ -371,9 +372,9 @@ export async function handleMessageComplete(
371
372
  deps.ctx.currentTurnSurfaces = [];
372
373
 
373
374
  // Emit trace event
374
- const charCount = cleanedContent
375
- .filter((b) => (b as Record<string, unknown>).type === 'text')
376
- .reduce((sum: number, b) => sum + ((b as { text?: string }).text?.length ?? 0), 0);
375
+ const charCount = cleanedBlocks
376
+ .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
377
+ .reduce((sum, b) => sum + b.text.length, 0);
377
378
  const toolUseCount = event.message.content
378
379
  .filter((b) => b.type === 'tool_use')
379
380
  .length;
@@ -25,6 +25,7 @@ import { stripMemoryRecallMessages } from '../memory/retriever.js';
25
25
  import type { PermissionPrompter } from '../permissions/prompter.js';
26
26
  import type { ContentBlock,Message } from '../providers/types.js';
27
27
  import type { Provider } from '../providers/types.js';
28
+ import { resolveActorTrust } from '../runtime/actor-trust-resolver.js';
28
29
  import type { UsageActor } from '../usage/actors.js';
29
30
  import { getLogger } from '../util/logger.js';
30
31
  import { truncate } from '../util/truncate.js';
@@ -55,9 +56,11 @@ import { raceWithTimeout,stripMediaPayloadsForRetry } from './session-media-retr
55
56
  import { prepareMemoryContext } from './session-memory.js';
56
57
  import type { MessageQueue } from './session-queue-manager.js';
57
58
  import type { QueueDrainReason } from './session-queue-manager.js';
58
- import type { ActiveSurfaceContext, ChannelCapabilities, ChannelTurnContextParams, GuardianRuntimeContext,InterfaceTurnContextParams } from './session-runtime-assembly.js';
59
+ import type { ActiveSurfaceContext, ChannelCapabilities, ChannelTurnContextParams, GuardianRuntimeContext, InboundActorContext, InterfaceTurnContextParams } from './session-runtime-assembly.js';
59
60
  import {
60
61
  applyRuntimeInjections,
62
+ inboundActorContextFromGuardian,
63
+ inboundActorContextFromTrust,
61
64
  stripInjectedContext,
62
65
  } from './session-runtime-assembly.js';
63
66
  import type { SkillProjectionCache } from './session-skill-tools.js';
@@ -102,6 +105,7 @@ export interface AgentLoopSessionContext {
102
105
  channelCapabilities?: ChannelCapabilities;
103
106
  commandIntent?: { type: string; payload?: string; languageCode?: string };
104
107
  guardianContext?: GuardianRuntimeContext;
108
+ assistantId?: string;
105
109
  voiceCallControlPrompt?: string;
106
110
 
107
111
  readonly coreToolNames: Set<string>;
@@ -254,7 +258,7 @@ export async function runAgentLoopImpl(
254
258
  conflictGate: ctx.conflictGate,
255
259
  scopeId: ctx.memoryPolicy.scopeId,
256
260
  includeDefaultFallback: ctx.memoryPolicy.includeDefaultFallback,
257
- guardianActorRole: ctx.guardianContext?.actorRole,
261
+ guardianTrustClass: ctx.guardianContext?.trustClass,
258
262
  isInteractive: options?.isInteractive ?? (!ctx.hasNoClient && !ctx.headlessLock),
259
263
  },
260
264
  content,
@@ -349,6 +353,28 @@ export async function runAgentLoopImpl(
349
353
  conversationOriginInterface: getConversationOriginInterface(ctx.conversationId),
350
354
  };
351
355
 
356
+ // Resolve the inbound actor context for the model's <inbound_actor_context>
357
+ // block. When the session carries enough identity info, use the unified
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.
361
+ let resolvedInboundActorContext: InboundActorContext | null = null;
362
+ if (ctx.guardianContext) {
363
+ const gc = ctx.guardianContext;
364
+ if (gc.requesterExternalUserId && gc.requesterChatId) {
365
+ const actorTrust = resolveActorTrust({
366
+ assistantId: ctx.assistantId ?? 'self',
367
+ sourceChannel: gc.sourceChannel,
368
+ externalChatId: gc.requesterChatId,
369
+ senderExternalUserId: gc.requesterExternalUserId,
370
+ senderDisplayName: gc.requesterSenderDisplayName,
371
+ });
372
+ resolvedInboundActorContext = inboundActorContextFromTrust(actorTrust);
373
+ } else {
374
+ resolvedInboundActorContext = inboundActorContextFromGuardian(gc);
375
+ }
376
+ }
377
+
352
378
  const isInteractiveResolved = options?.isInteractive ?? (!ctx.hasNoClient && !ctx.headlessLock);
353
379
  runMessages = applyRuntimeInjections(runMessages, {
354
380
  softConflictInstruction,
@@ -358,7 +384,7 @@ export async function runAgentLoopImpl(
358
384
  channelCommandContext: ctx.commandIntent ?? null,
359
385
  channelTurnContext,
360
386
  interfaceTurnContext,
361
- guardianContext: ctx.guardianContext ?? null,
387
+ inboundActorContext: resolvedInboundActorContext,
362
388
  temporalContext,
363
389
  voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
364
390
  isNonInteractive: !isInteractiveResolved,
@@ -477,7 +503,7 @@ export async function runAgentLoopImpl(
477
503
  channelCommandContext: ctx.commandIntent ?? null,
478
504
  channelTurnContext,
479
505
  interfaceTurnContext,
480
- guardianContext: ctx.guardianContext ?? null,
506
+ inboundActorContext: resolvedInboundActorContext,
481
507
  temporalContext,
482
508
  voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
483
509
  isNonInteractive: !isInteractiveResolved,
@@ -515,7 +541,7 @@ export async function runAgentLoopImpl(
515
541
  channelCommandContext: ctx.commandIntent ?? null,
516
542
  channelTurnContext,
517
543
  interfaceTurnContext,
518
- guardianContext: ctx.guardianContext ?? null,
544
+ inboundActorContext: resolvedInboundActorContext,
519
545
  temporalContext,
520
546
  voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
521
547
  isNonInteractive: !isInteractiveResolved,
@@ -604,7 +630,8 @@ export async function runAgentLoopImpl(
604
630
  const newMessages = updatedHistory.slice(preRunHistoryLength).map((msg) => {
605
631
  if (msg.role !== 'assistant') return msg;
606
632
  const { cleanedContent } = cleanAssistantContent(msg.content);
607
- return { ...msg, content: cleanedContent as ContentBlock[] };
633
+ const cleanedBlocks = cleanedContent as ContentBlock[];
634
+ return { ...msg, content: cleanedBlocks };
608
635
  });
609
636
 
610
637
  const hasAssistantResponse = newMessages.some((msg) => msg.role === 'assistant');
@@ -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 {