@vellumai/assistant 0.11.7 → 0.11.8-staging.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 (446) hide show
  1. package/ARCHITECTURE.md +10 -10
  2. package/Dockerfile +1 -0
  3. package/docs/architecture/security.md +1 -1
  4. package/docs/error-handling.md +1 -2
  5. package/docs/guardian-request-flow.md +51 -5
  6. package/docs/trusted-contact-access.md +13 -12
  7. package/knip.json +1 -0
  8. package/node_modules/@vellumai/avatar-catalog/package.json +19 -0
  9. package/{src/avatar/character-components.ts → node_modules/@vellumai/avatar-catalog/src/catalog.ts} +12 -44
  10. package/node_modules/@vellumai/avatar-catalog/src/colors.ts +19 -0
  11. package/node_modules/@vellumai/avatar-catalog/src/index.ts +25 -0
  12. package/node_modules/@vellumai/avatar-catalog/src/types.ts +47 -0
  13. package/node_modules/@vellumai/avatar-catalog/tsconfig.json +20 -0
  14. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  15. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  16. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  17. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  18. package/node_modules/@vellumai/credential-storage/src/static-credentials.ts +43 -12
  19. package/node_modules/@vellumai/environments/package.json +2 -1
  20. package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  21. package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  22. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  23. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  24. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  25. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  26. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +8 -18
  27. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +46 -0
  28. package/node_modules/@vellumai/gateway-client/src/__tests__/invite-contract.test.ts +1 -0
  29. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +46 -0
  30. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +62 -21
  31. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +14 -0
  32. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +53 -0
  33. package/node_modules/@vellumai/gateway-client/src/index.ts +11 -5
  34. package/node_modules/@vellumai/gateway-client/src/invite-contract.ts +1 -0
  35. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  36. package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  37. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  38. package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  39. package/openapi.yaml +481 -9
  40. package/package.json +3 -1
  41. package/scripts/sync-llm-catalog.ts +3 -0
  42. package/src/__tests__/agent-loop-callsite-precedence.test.ts +16 -0
  43. package/src/__tests__/agent-loop-compacted-history-transform.test.ts +304 -0
  44. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +3 -0
  45. package/src/__tests__/agent-wake-override-profile.test.ts +3 -0
  46. package/src/__tests__/anthropic-provider.test.ts +56 -0
  47. package/src/__tests__/approval-cascade.test.ts +0 -13
  48. package/src/__tests__/attachments-store.test.ts +117 -6
  49. package/src/__tests__/attachments.test.ts +122 -0
  50. package/src/__tests__/bundled-asset.test.ts +22 -1
  51. package/src/__tests__/channel-approval.test.ts +14 -7
  52. package/src/__tests__/channel-approvals.test.ts +3 -3
  53. package/src/__tests__/channel-delivery-store.test.ts +58 -0
  54. package/src/__tests__/channel-inbound-disk-pressure.test.ts +1 -0
  55. package/src/__tests__/channel-invite-transport.test.ts +1 -0
  56. package/src/__tests__/channel-readiness-slack-remote.test.ts +3 -3
  57. package/src/__tests__/compaction-events.test.ts +0 -12
  58. package/src/__tests__/compactor-call-site-logging.test.ts +7 -1
  59. package/src/__tests__/compactor-retained-image-validation.test.ts +15 -0
  60. package/src/__tests__/config-schema.test.ts +35 -0
  61. package/src/__tests__/contacts-write.test.ts +141 -3
  62. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -13
  63. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +0 -12
  64. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -13
  65. package/src/__tests__/conversation-agent-loop.test.ts +95 -13
  66. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -13
  67. package/src/__tests__/conversation-load-history-repair.test.ts +418 -0
  68. package/src/__tests__/conversation-media-retry.test.ts +33 -2
  69. package/src/__tests__/conversation-pairing.test.ts +54 -0
  70. package/src/__tests__/conversation-process-callsite.test.ts +0 -12
  71. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -13
  72. package/src/__tests__/conversation-queue.test.ts +4 -13
  73. package/src/__tests__/conversation-retry-route.test.ts +5 -0
  74. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  75. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  76. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  77. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  78. package/src/__tests__/conversation-sight-frames.test.ts +615 -0
  79. package/src/__tests__/conversation-slash-queue.test.ts +0 -13
  80. package/src/__tests__/conversation-slash-unknown.test.ts +0 -13
  81. package/src/__tests__/conversation-speed-override.test.ts +0 -12
  82. package/src/__tests__/conversation-store-ephemeral.test.ts +0 -12
  83. package/src/__tests__/conversation-summarize-route.test.ts +5 -0
  84. package/src/__tests__/conversation-summarize-up-to.test.ts +102 -0
  85. package/src/__tests__/conversation-wait-for-idle.test.ts +0 -12
  86. package/src/__tests__/conversation-workspace-cache-state.test.ts +0 -5
  87. package/src/__tests__/conversation-workspace-injection.test.ts +0 -17
  88. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -17
  89. package/src/__tests__/credential-health-service.test.ts +45 -0
  90. package/src/__tests__/credential-record-retire.test.ts +256 -0
  91. package/src/__tests__/credential-record-write-through.test.ts +8 -4
  92. package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +0 -4
  93. package/src/__tests__/db-conversation-inference-profile-migration.test.ts +0 -4
  94. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +0 -8
  95. package/src/__tests__/delete-propagation.test.ts +121 -3
  96. package/src/__tests__/evict-conversations-for-reload.test.ts +46 -6
  97. package/src/__tests__/file-ops-service.test.ts +8 -3
  98. package/src/__tests__/gemini-provider.test.ts +64 -0
  99. package/src/__tests__/guardian-action-service-scope.test.ts +118 -0
  100. package/src/__tests__/guardian-card-withdrawal.test.ts +299 -5
  101. package/src/__tests__/guardian-expiry-notifier.test.ts +117 -13
  102. package/src/__tests__/guardian-gateway-sim.test.ts +119 -0
  103. package/src/__tests__/guardian-gateway-sim.ts +56 -20
  104. package/src/__tests__/guardian-reply-router-answer-mode.test.ts +6 -0
  105. package/src/__tests__/guardian-request-status-sync.test.ts +162 -0
  106. package/src/__tests__/guardian-wait-controller.test.ts +6 -0
  107. package/src/__tests__/headless-browser-mode.test.ts +27 -0
  108. package/src/__tests__/helpers/mock-conversation.ts +2 -0
  109. package/src/__tests__/list-messages-system-card.test.ts +70 -0
  110. package/src/__tests__/llm-catalog-parity.test.ts +2 -0
  111. package/src/__tests__/managed-profile-guard.test.ts +2 -2
  112. package/src/__tests__/media-resolve-image-validation.test.ts +106 -0
  113. package/src/__tests__/media-stream-server-integration.test.ts +6 -0
  114. package/src/__tests__/non-member-access-request.test.ts +12 -0
  115. package/src/__tests__/notification-discord-adapter.test.ts +76 -4
  116. package/src/__tests__/oauth-commands-routes.test.ts +21 -0
  117. package/src/__tests__/openai-provider.test.ts +63 -0
  118. package/src/__tests__/outbound-slack-persistence.test.ts +114 -2
  119. package/src/__tests__/persist-unsendable-image-downscale.test.ts +63 -1
  120. package/src/__tests__/persist-unsendable-image.test.ts +19 -0
  121. package/src/__tests__/plugin-app-serve-routes.test.ts +2 -2
  122. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  123. package/src/__tests__/process-message-background-slack.test.ts +1 -1
  124. package/src/__tests__/process-message-display-content.test.ts +1 -1
  125. package/src/__tests__/processing-flag-persist-failure.test.ts +0 -5
  126. package/src/__tests__/prompt-cache-cross-turn-stability.test.ts +0 -17
  127. package/src/__tests__/qdrant-manager.test.ts +6 -0
  128. package/src/__tests__/reaction-persistence.test.ts +58 -62
  129. package/src/__tests__/retry-thinking-adaptive-only.test.ts +67 -0
  130. package/src/__tests__/server-history-render.test.ts +22 -0
  131. package/src/__tests__/sight-frame-retention.test.ts +362 -0
  132. package/src/__tests__/slack-reaction-approvals.test.ts +44 -24
  133. package/src/__tests__/slack-reaction-guardian-approval.test.ts +29 -1
  134. package/src/__tests__/subagent-fork-notifications.test.ts +2 -0
  135. package/src/__tests__/subagent-manager-notify.test.ts +2 -0
  136. package/src/__tests__/subagent-notify-parent.test.ts +2 -0
  137. package/src/__tests__/subagent-notify-stale-rebuild.test.ts +85 -0
  138. package/src/__tests__/subagent-tool-gate-mode.test.ts +4 -4
  139. package/src/__tests__/terminal-tools.test.ts +9 -9
  140. package/src/__tests__/tool-result-spool.test.ts +5 -8
  141. package/src/__tests__/usage-attribution.test.ts +26 -0
  142. package/src/acp/__tests__/acp-auth-marker-store.test.ts +191 -0
  143. package/src/acp/__tests__/acp-claude-oauth.test.ts +262 -0
  144. package/src/acp/__tests__/acp-connect-card-state.test.ts +17 -0
  145. package/src/acp/__tests__/helpers/acp-history-db.ts +10 -3
  146. package/src/acp/acp-auth-marker-store.ts +196 -0
  147. package/src/acp/acp-claude-oauth.ts +154 -4
  148. package/src/acp/acp-connect-card-state.ts +49 -8
  149. package/src/acp/agent-process.ts +1 -0
  150. package/src/acp/auto-install.ts +1 -0
  151. package/src/acp/client-handler.ts +1 -0
  152. package/src/acp/prepare-agent-env.ts +79 -1
  153. package/src/acp/resolve-agent.ts +12 -0
  154. package/src/acp/session-manager.test.ts +183 -0
  155. package/src/acp/session-manager.ts +110 -6
  156. package/src/acp/types.ts +17 -0
  157. package/src/agent/attachments.ts +7 -2
  158. package/src/agent/loop.ts +59 -1
  159. package/src/api/events/contact-form-closed.ts +32 -0
  160. package/src/api/events/contact-record-request.ts +63 -0
  161. package/src/api/events/contact-request.ts +10 -3
  162. package/src/api/index.ts +20 -0
  163. package/src/api/responses/conversation-message.ts +20 -0
  164. package/src/api/responses/home.ts +101 -0
  165. package/src/approvals/AGENTS.md +1 -1
  166. package/src/approvals/guardian-card-withdrawal.ts +127 -30
  167. package/src/approvals/guardian-decision-primitive.ts +9 -0
  168. package/src/approvals/guardian-expiry-notifier.ts +22 -16
  169. package/src/approvals/guardian-request-status-sync.ts +98 -0
  170. package/src/avatar/svg-compositor.ts +1 -1
  171. package/src/avatar/traits-png-sync.ts +1 -1
  172. package/src/bundler/app-compiler.ts +1 -0
  173. package/src/bundler/compiler-tools.ts +1 -0
  174. package/src/bundler/package-resolver.ts +1 -0
  175. package/src/calls/__tests__/voice-session-bridge.test.ts +183 -3
  176. package/src/calls/guardian-action-sweep.ts +6 -1
  177. package/src/calls/voice-session-bridge.ts +46 -1
  178. package/src/channels/__tests__/gateway-guardian-requests.test.ts +13 -18
  179. package/src/channels/gateway-guardian-requests.ts +37 -18
  180. package/src/cli/__tests__/contacts-record-prompt-cli.test.ts +329 -0
  181. package/src/cli/commands/__tests__/browser.test.ts +65 -1
  182. package/src/cli/commands/browser.ts +30 -10
  183. package/src/cli/commands/contacts.help.ts +173 -11
  184. package/src/cli/commands/contacts.ts +343 -4
  185. package/src/cli/commands/db/refresh.ts +1 -0
  186. package/src/cli/commands/oauth/index.help.ts +6 -3
  187. package/src/cli/commands/oauth/request.test.ts +37 -0
  188. package/src/cli/commands/oauth/request.ts +7 -6
  189. package/src/cli/lib/bundled-marketplace.json +1 -1
  190. package/src/cli/lib/install-from-github.ts +20 -19
  191. package/src/cli/lib/install-plugin-dependencies.ts +3 -5
  192. package/src/cli/lib/merge-plugin-tree.ts +1 -0
  193. package/src/cli/lib/publish-plugin.ts +4 -1
  194. package/src/config/__tests__/balanced-model-experiment.test.ts +63 -31
  195. package/src/config/__tests__/default-profile-catalog.test.ts +2 -2
  196. package/src/config/balanced-model-experiment.ts +15 -9
  197. package/src/config/bundled-skills/contacts/SKILL.md +61 -18
  198. package/src/config/default-profile-catalog.ts +12 -11
  199. package/src/config/feature-flag-registry.json +22 -13
  200. package/src/config/schema.ts +2 -0
  201. package/src/config/schemas/__tests__/live-voice.test.ts +13 -0
  202. package/src/config/schemas/live-voice.ts +7 -0
  203. package/src/config/schemas/sight.ts +45 -0
  204. package/src/config/skills.ts +2 -1
  205. package/src/contacts/__tests__/mirror-reconciler.test.ts +352 -0
  206. package/src/contacts/contact-store.ts +47 -4
  207. package/src/contacts/contacts-write.ts +5 -2
  208. package/src/contacts/mirror-reconciler.ts +210 -0
  209. package/src/contacts/mirror-tombstones.ts +42 -0
  210. package/src/contacts/notify-contacts-changed.ts +11 -2
  211. package/src/context/compactor.ts +47 -19
  212. package/src/context/tool-result-spool.ts +21 -14
  213. package/src/conversations/message-consolidation.ts +2 -2
  214. package/src/credential-health/credential-health-service.ts +37 -6
  215. package/src/daemon/conversation-agent-loop-handlers.ts +19 -2
  216. package/src/daemon/conversation-agent-loop.ts +100 -1
  217. package/src/daemon/conversation-media-retry.ts +11 -6
  218. package/src/daemon/conversation-messaging.ts +293 -30
  219. package/src/daemon/conversation-runtime-assembly.ts +67 -0
  220. package/src/daemon/conversation-sight-frames.ts +187 -0
  221. package/src/daemon/conversation-store.ts +18 -28
  222. package/src/daemon/conversation-surfaces.ts +12 -2
  223. package/src/daemon/conversation-tool-setup.ts +39 -10
  224. package/src/daemon/conversation.ts +159 -16
  225. package/src/daemon/daemon-control.ts +6 -17
  226. package/src/daemon/handlers/config-model.test.ts +1 -0
  227. package/src/daemon/handlers/conversations.ts +10 -11
  228. package/src/daemon/install-assistant-command.ts +1 -0
  229. package/src/daemon/lifecycle.ts +10 -13
  230. package/src/daemon/message-types/sync.ts +4 -0
  231. package/src/daemon/process-message.ts +2 -17
  232. package/src/daemon/reaction-history-render.test.ts +162 -0
  233. package/src/daemon/reaction-history-render.ts +115 -0
  234. package/src/daemon/reaction-record.test.ts +162 -0
  235. package/src/daemon/reaction-record.ts +83 -0
  236. package/src/daemon/shutdown-handlers.ts +0 -2
  237. package/src/daemon/summarize-boundary.ts +12 -0
  238. package/src/daemon/video-thumbnail.ts +1 -1
  239. package/src/home/feed-types.ts +4 -0
  240. package/src/home/feed-writer.ts +25 -0
  241. package/src/live-voice/__tests__/live-voice-attach-frame.test.ts +679 -0
  242. package/src/live-voice/__tests__/live-voice-attach-image.test.ts +8 -5
  243. package/src/live-voice/__tests__/live-voice-photo.test.ts +637 -1
  244. package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +787 -0
  245. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +347 -0
  246. package/src/live-voice/__tests__/live-voice-vad.test.ts +109 -0
  247. package/src/live-voice/live-voice-photo.ts +420 -65
  248. package/src/live-voice/live-voice-session.ts +311 -27
  249. package/src/live-voice/protocol.ts +147 -11
  250. package/src/messaging/provider-message-metadata.ts +7 -0
  251. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +113 -1
  252. package/src/messaging/providers/channel-transport.ts +37 -0
  253. package/src/messaging/providers/discord/api.ts +4 -4
  254. package/src/messaging/providers/discord/send.test.ts +177 -2
  255. package/src/messaging/providers/discord/send.ts +197 -10
  256. package/src/messaging/providers/discord/transport.ts +10 -0
  257. package/src/messaging/providers/index.ts +42 -0
  258. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +129 -3
  259. package/src/messaging/providers/slack/__tests__/auth.test.ts +35 -0
  260. package/src/messaging/providers/slack/__tests__/message-content.test.ts +152 -0
  261. package/src/messaging/providers/slack/adapter.ts +112 -27
  262. package/src/messaging/providers/slack/api.test.ts +10 -0
  263. package/src/messaging/providers/slack/auth.ts +32 -6
  264. package/src/messaging/providers/slack/client.ts +0 -15
  265. package/src/messaging/providers/slack/message-content.ts +137 -0
  266. package/src/messaging/providers/slack/send.test.ts +43 -2
  267. package/src/messaging/providers/slack/send.ts +18 -5
  268. package/src/messaging/providers/slack/transport.ts +10 -0
  269. package/src/messaging/providers/slack/types.ts +21 -2
  270. package/src/messaging/providers/telegram-bot/adapter.ts +5 -1
  271. package/src/messaging/providers/telegram-bot/send.test.ts +49 -2
  272. package/src/messaging/providers/telegram-bot/send.ts +41 -1
  273. package/src/messaging/providers/telegram-bot/transport.ts +10 -0
  274. package/src/messaging/providers/telegram-bot/withdraw.ts +13 -5
  275. package/src/messaging/reaction-envelopes.ts +79 -0
  276. package/src/monitoring/control.ts +2 -2
  277. package/src/monitoring/page-cache.ts +5 -1
  278. package/src/notifications/AGENTS.md +1 -1
  279. package/src/notifications/__tests__/guardian-feed-projection.test.ts +360 -0
  280. package/src/notifications/__tests__/home-feed-side-effect.test.ts +8 -0
  281. package/src/notifications/adapters/discord.ts +62 -9
  282. package/src/notifications/conversation-pairing.ts +31 -4
  283. package/src/notifications/emit-signal.ts +8 -5
  284. package/src/notifications/guardian-delivery-recorder.ts +47 -6
  285. package/src/notifications/guardian-feed-projection.ts +492 -0
  286. package/src/notifications/home-feed-side-effect.ts +59 -11
  287. package/src/oauth/byo-connection.test.ts +22 -0
  288. package/src/oauth/byo-connection.ts +11 -5
  289. package/src/oauth/connection.ts +3 -2
  290. package/src/oauth/platform-connection.test.ts +35 -0
  291. package/src/oauth/platform-connection.ts +18 -13
  292. package/src/oauth/scope-utils.ts +48 -0
  293. package/src/oauth/seed-providers.ts +12 -0
  294. package/src/outbound-proxy/certs.ts +5 -4
  295. package/src/permissions/question-guardian-request.test.ts +7 -1
  296. package/src/persistence/attachments-store.ts +134 -31
  297. package/src/persistence/conversation-crud.ts +137 -4
  298. package/src/persistence/conversation-types.ts +109 -0
  299. package/src/persistence/db-connection.ts +3 -21
  300. package/src/persistence/delivery-crud.ts +42 -12
  301. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +15 -2
  302. package/src/persistence/embeddings/embedding-local.ts +7 -112
  303. package/src/persistence/embeddings/qdrant-manager.ts +4 -1
  304. package/src/persistence/llm-request-log-store.ts +1 -1
  305. package/src/persistence/migrations/370-acp-session-history-auth-error-code.ts +36 -0
  306. package/src/persistence/migrations/371-acp-session-history-auth-error-credential.ts +40 -0
  307. package/src/persistence/migrations/372-create-acp-refused-credentials.ts +34 -0
  308. package/src/persistence/migrations/373-acp-auth-marker-index.ts +27 -0
  309. package/src/persistence/migrations/__tests__/370-acp-session-history-auth-error-code.test.ts +65 -0
  310. package/src/persistence/migrations/__tests__/371-acp-session-history-auth-error-credential.test.ts +68 -0
  311. package/src/persistence/migrations/__tests__/372-create-acp-refused-credentials.test.ts +64 -0
  312. package/src/persistence/migrations/__tests__/373-acp-auth-marker-index.test.ts +82 -0
  313. package/src/persistence/schema/acp.ts +25 -0
  314. package/src/persistence/steps.ts +8 -0
  315. package/src/platform/platform-patch-queue.test.ts +43 -41
  316. package/src/plugin-api/index.ts +12 -0
  317. package/src/plugins/defaults/compaction/window-manager.ts +2 -0
  318. package/src/plugins/defaults/image-recovery/recover.ts +12 -1
  319. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +166 -1
  320. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +3 -0
  321. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +3 -0
  322. package/src/plugins/defaults/memory/jobs-worker.ts +1 -2
  323. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +53 -9
  324. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +4 -7
  325. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +1 -1
  326. package/src/plugins/defaults/memory/v1/job-handlers/index-maintenance.ts +15 -1
  327. package/src/plugins/defaults/memory/worker-control.ts +1 -2
  328. package/src/providers/content-block-schema.ts +1 -0
  329. package/src/providers/fireworks/client.ts +9 -1
  330. package/src/providers/gemini/client.ts +22 -15
  331. package/src/providers/media-resolve.ts +24 -10
  332. package/src/providers/model-catalog.ts +91 -0
  333. package/src/providers/openai/chat-completions-provider.ts +44 -5
  334. package/src/providers/retry.ts +16 -11
  335. package/src/providers/types.ts +56 -0
  336. package/src/routes/__tests__/control.test.ts +4 -3
  337. package/src/routes/control.ts +1 -1
  338. package/src/routes/route-host-client.ts +1 -1
  339. package/src/runtime/AGENTS.md +30 -12
  340. package/src/runtime/__tests__/agent-wake.test.ts +71 -6
  341. package/src/runtime/__tests__/guardian-form-registry.test.ts +242 -0
  342. package/src/runtime/access-request-helper.ts +15 -7
  343. package/src/runtime/agent-wake.ts +16 -4
  344. package/src/runtime/channel-approval-types.ts +8 -5
  345. package/src/runtime/channel-invite-transport.ts +2 -0
  346. package/src/runtime/channel-invite-transports/discord.ts +27 -0
  347. package/src/runtime/channel-readiness-service.ts +4 -4
  348. package/src/runtime/channel-reply-delivery.ts +72 -28
  349. package/src/runtime/guardian-action-service.ts +9 -5
  350. package/src/runtime/guardian-form-registry.ts +256 -0
  351. package/src/runtime/guardian-reply-router.ts +33 -14
  352. package/src/runtime/invite-instruction-generator.ts +14 -2
  353. package/src/runtime/migrations/vbundle-metadata-merge.ts +4 -7
  354. package/src/runtime/no-response.ts +13 -74
  355. package/src/runtime/routes/__tests__/acp-routes.test.ts +527 -0
  356. package/src/runtime/routes/__tests__/app-management-shared-apps.test.ts +74 -0
  357. package/src/runtime/routes/__tests__/contact-form-kinds.test.ts +22 -0
  358. package/src/runtime/routes/__tests__/contact-record-prompt-route.test.ts +303 -0
  359. package/src/runtime/routes/__tests__/monitoring-routes.test.ts +3 -4
  360. package/src/runtime/routes/__tests__/pending-interactions-route.test.ts +6 -0
  361. package/src/runtime/routes/__tests__/route-host-worker-routes.test.ts +3 -4
  362. package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +5 -6
  363. package/src/runtime/routes/acp-routes-list.test.ts +58 -0
  364. package/src/runtime/routes/acp-routes.ts +253 -47
  365. package/src/runtime/routes/app-management-routes.ts +80 -47
  366. package/src/runtime/routes/approval-routes.ts +9 -0
  367. package/src/runtime/routes/archive-utils.ts +6 -1
  368. package/src/runtime/routes/avatar-routes.ts +1 -1
  369. package/src/runtime/routes/channel-route-shared.ts +18 -20
  370. package/src/runtime/routes/contact-prompt-routes.ts +271 -105
  371. package/src/runtime/routes/conversation-routes.ts +41 -32
  372. package/src/runtime/routes/credential-prompt-routes.ts +10 -1
  373. package/src/runtime/routes/debug-bash-routes.ts +1 -0
  374. package/src/runtime/routes/guardian-expiry-sweep.ts +89 -31
  375. package/src/runtime/routes/guardian-form-routes.ts +115 -0
  376. package/src/runtime/routes/inbound-message-handler.ts +82 -29
  377. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -3
  378. package/src/runtime/routes/inbound-stages/edit-intercept.ts +6 -0
  379. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +6 -0
  380. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +6 -1
  381. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +50 -17
  382. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +97 -94
  383. package/src/runtime/routes/index.ts +2 -0
  384. package/src/runtime/routes/integrations/slack/channel.ts +39 -18
  385. package/src/runtime/routes/migration-routes.ts +1 -3
  386. package/src/runtime/routes/monitoring-routes.ts +0 -3
  387. package/src/runtime/routes/oauth-commands-routes.ts +18 -5
  388. package/src/runtime/routes/route-host-worker-routes.ts +1 -1
  389. package/src/runtime/routes/schedule-worker-routes.ts +1 -1
  390. package/src/runtime/tool-grant-request-helper.ts +4 -0
  391. package/src/schedule/run-script.ts +1 -0
  392. package/src/schedule/scheduler.ts +1 -1
  393. package/src/schedule/worker-control.ts +2 -2
  394. package/src/security/oauth2.ts +2 -11
  395. package/src/security/secure-keys.ts +55 -8
  396. package/src/skills/catalog-install.ts +7 -4
  397. package/src/skills/clawhub.ts +1 -0
  398. package/src/skills/inline-command-runner.ts +1 -0
  399. package/src/stt/__tests__/room-noise-floor.test.ts +108 -0
  400. package/src/stt/room-noise-floor.ts +140 -0
  401. package/src/subagent/notify.ts +49 -3
  402. package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
  403. package/src/tools/acp/spawn.ts +2 -2
  404. package/src/tools/browser/__tests__/browser-status.test.ts +9 -0
  405. package/src/tools/browser/browser-execution.ts +10 -43
  406. package/src/tools/browser/browser-manager.ts +1 -0
  407. package/src/tools/browser/browser-status-constants.ts +6 -5
  408. package/src/tools/browser/runtime-check.ts +2 -0
  409. package/src/tools/channel/react-to-message.test.ts +193 -0
  410. package/src/tools/channel/react-to-message.ts +159 -0
  411. package/src/tools/credentials/metadata-store.ts +115 -19
  412. package/src/tools/credentials/store.ts +0 -2
  413. package/src/tools/filesystem/read.ts +2 -2
  414. package/src/tools/host-filesystem/read.ts +2 -2
  415. package/src/tools/host-terminal/host-shell.test.ts +24 -0
  416. package/src/tools/host-terminal/host-shell.ts +11 -38
  417. package/src/tools/shared/filesystem/file-ops-service.ts +10 -7
  418. package/src/tools/shared/shell-output.test.ts +104 -0
  419. package/src/tools/shared/shell-output.ts +112 -39
  420. package/src/tools/skills/sandbox-runner.ts +1 -0
  421. package/src/tools/system/request-permission.test.ts +34 -0
  422. package/src/tools/system/request-permission.ts +29 -5
  423. package/src/tools/terminal/shell.test.ts +63 -0
  424. package/src/tools/terminal/shell.ts +12 -44
  425. package/src/tools/tool-input-schemas.ts +2 -0
  426. package/src/tools/tool-manifest.ts +2 -0
  427. package/src/usage/attribution.ts +12 -4
  428. package/src/util/__tests__/worker-ownership.test.ts +77 -0
  429. package/src/util/__tests__/worker-process-command.test.ts +246 -1
  430. package/src/util/bun-runtime.ts +44 -13
  431. package/src/util/bundled-asset.ts +9 -1
  432. package/src/util/disk-usage.ts +1 -0
  433. package/src/util/file-use.ts +4 -1
  434. package/src/util/guardian-form-timeouts.ts +50 -0
  435. package/src/util/image-conversion.ts +1 -0
  436. package/src/util/oauth-request-body.test.ts +59 -0
  437. package/src/util/oauth-request-body.ts +56 -5
  438. package/src/util/platform.ts +69 -73
  439. package/src/util/process-table.ts +16 -5
  440. package/src/util/read-stdin.ts +5 -0
  441. package/src/util/spawn.ts +1 -0
  442. package/src/util/sqlite3-runtime.ts +25 -5
  443. package/src/util/worker-ownership.ts +193 -0
  444. package/src/util/worker-process.ts +375 -37
  445. package/src/workspace/git-service.ts +4 -12
  446. package/src/workspace/migrations/010-app-dir-rename.ts +1 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Tests for the pull-based contact-mirror reconciler.
3
+ *
4
+ * The reconciler converges the assistant identity mirror onto the gateway's
5
+ * identity snapshot: it must heal missing rows under the gateway's own ids,
6
+ * hand a divergent channel to the contact the gateway says owns it, and
7
+ * write NOTHING else. Assistant-authored data (notes, classification,
8
+ * curated names) and mirror-only rows (a2a peers) are exactly what a pull
9
+ * from the gateway cannot reconstruct, so preserving them is as load-bearing
10
+ * as the healing.
11
+ *
12
+ * The daemon DB is real; the gateway snapshot arrives through a mocked
13
+ * `ipcCallPersistent`.
14
+ */
15
+
16
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
17
+
18
+ import type { ContactsIdentitySnapshotIpcResponse } from "@vellumai/gateway-client/gateway-ipc-contracts";
19
+
20
+ let snapshot: ContactsIdentitySnapshotIpcResponse = { ok: true, contacts: [] };
21
+ const ipcCallPersistentMock = mock(async (method: string) => {
22
+ if (method !== "contacts_identity_snapshot") {
23
+ throw new Error(`unexpected IPC method: ${method}`);
24
+ }
25
+ return snapshot;
26
+ });
27
+ mock.module("../../ipc/gateway-client.js", () => ({
28
+ ipcCallPersistent: ipcCallPersistentMock,
29
+ }));
30
+
31
+ import { getSqlite } from "../../persistence/db-connection.js";
32
+ import { initializeDb } from "../../persistence/db-init.js";
33
+ import { deleteContact, upsertContact } from "../contact-store.js";
34
+ import { upsertContactChannel } from "../contacts-write.js";
35
+ import { runMirrorReconcile } from "../mirror-reconciler.js";
36
+ await initializeDb();
37
+
38
+ function resetContactTables(): void {
39
+ const sqlite = getSqlite();
40
+ sqlite.run("DELETE FROM contact_channels");
41
+ sqlite.run("DELETE FROM contacts");
42
+ }
43
+
44
+ function contactRow(
45
+ id: string,
46
+ ): { displayName: string; notes: string | null } | null {
47
+ return getSqlite()
48
+ .query(
49
+ "SELECT display_name AS displayName, notes FROM contacts WHERE id = ?",
50
+ )
51
+ .get(id) as { displayName: string; notes: string | null } | null;
52
+ }
53
+
54
+ function channelRow(
55
+ type: string,
56
+ address: string,
57
+ ): { id: string; contactId: string; externalChatId: string | null } | null {
58
+ return getSqlite()
59
+ .query(
60
+ "SELECT id, contact_id AS contactId, external_chat_id AS externalChatId FROM contact_channels WHERE type = ? AND address = ? COLLATE NOCASE",
61
+ )
62
+ .get(type, address) as {
63
+ id: string;
64
+ contactId: string;
65
+ externalChatId: string | null;
66
+ } | null;
67
+ }
68
+
69
+ function gatewayContact(opts: {
70
+ id: string;
71
+ displayName: string;
72
+ channels?: Array<{
73
+ id: string;
74
+ type: string;
75
+ address: string;
76
+ externalChatId?: string | null;
77
+ isPrimary?: boolean;
78
+ }>;
79
+ }): ContactsIdentitySnapshotIpcResponse["contacts"][number] {
80
+ return {
81
+ id: opts.id,
82
+ displayName: opts.displayName,
83
+ channels: (opts.channels ?? []).map((ch) => ({
84
+ id: ch.id,
85
+ contactId: opts.id,
86
+ type: ch.type,
87
+ address: ch.address,
88
+ externalChatId: ch.externalChatId ?? null,
89
+ isPrimary: ch.isPrimary ?? false,
90
+ })),
91
+ };
92
+ }
93
+
94
+ beforeEach(() => {
95
+ resetContactTables();
96
+ snapshot = { ok: true, contacts: [] };
97
+ ipcCallPersistentMock.mockClear();
98
+ });
99
+
100
+ describe("healing toward the gateway snapshot", () => {
101
+ test("recreates a missing contact + channel under the gateway ids", async () => {
102
+ snapshot = {
103
+ ok: true,
104
+ contacts: [
105
+ gatewayContact({
106
+ id: "co-gw",
107
+ displayName: "Alice Example",
108
+ channels: [
109
+ {
110
+ id: "ch-gw",
111
+ type: "slack",
112
+ address: "U123",
113
+ externalChatId: "D123",
114
+ },
115
+ ],
116
+ }),
117
+ ],
118
+ };
119
+
120
+ await runMirrorReconcile();
121
+
122
+ expect(channelRow("slack", "U123")).toEqual({
123
+ id: "ch-gw",
124
+ contactId: "co-gw",
125
+ externalChatId: "D123",
126
+ });
127
+ expect(contactRow("co-gw")?.displayName).toBe("Alice Example");
128
+ });
129
+
130
+ test("hands a divergent channel to the contact the gateway says owns it", async () => {
131
+ // The mirror believes the channel belongs to a stale owner.
132
+ upsertContactChannel({
133
+ sourceChannel: "slack",
134
+ externalUserId: "U123",
135
+ displayName: "Stale Owner",
136
+ contactId: "co-stale",
137
+ channelId: "ch-stale",
138
+ refreshDisplayName: true,
139
+ userFileOnCreate: null,
140
+ reassignConflictingChannels: false,
141
+ });
142
+ snapshot = {
143
+ ok: true,
144
+ contacts: [
145
+ gatewayContact({
146
+ id: "co-truth",
147
+ displayName: "Alice Example",
148
+ channels: [{ id: "ch-gw", type: "slack", address: "U123" }],
149
+ }),
150
+ ],
151
+ };
152
+
153
+ await runMirrorReconcile();
154
+
155
+ expect(channelRow("slack", "U123")?.contactId).toBe("co-truth");
156
+ // The heal adopts the gateway id in the same write.
157
+ expect(channelRow("slack", "U123")?.id).toBe("ch-gw");
158
+ });
159
+
160
+ test("adopts the gateway channel id onto a same-owner legacy row, then reaches a fixed point", async () => {
161
+ upsertContactChannel({
162
+ sourceChannel: "slack",
163
+ externalUserId: "U123",
164
+ displayName: "Alice Example",
165
+ contactId: "co-1",
166
+ channelId: "legacy-ch",
167
+ refreshDisplayName: true,
168
+ userFileOnCreate: null,
169
+ reassignConflictingChannels: false,
170
+ });
171
+ snapshot = {
172
+ ok: true,
173
+ contacts: [
174
+ gatewayContact({
175
+ id: "co-1",
176
+ displayName: "Alice Example",
177
+ channels: [{ id: "ch-gw", type: "slack", address: "U123" }],
178
+ }),
179
+ ],
180
+ };
181
+
182
+ await runMirrorReconcile();
183
+ expect(channelRow("slack", "U123")?.id).toBe("ch-gw");
184
+
185
+ // Converged: a follow-up pass writes nothing.
186
+ const before = getSqlite()
187
+ .query("SELECT updated_at AS u FROM contact_channels WHERE id = 'ch-gw'")
188
+ .get() as { u: number };
189
+ await runMirrorReconcile();
190
+ const after = getSqlite()
191
+ .query("SELECT updated_at AS u FROM contact_channels WHERE id = 'ch-gw'")
192
+ .get() as { u: number };
193
+ expect(after.u).toBe(before.u);
194
+ });
195
+
196
+ test("recreates a channel-less gateway contact as an identity stub", async () => {
197
+ snapshot = {
198
+ ok: true,
199
+ contacts: [gatewayContact({ id: "co-record", displayName: "Mom" })],
200
+ };
201
+
202
+ await runMirrorReconcile();
203
+
204
+ expect(contactRow("co-record")?.displayName).toBe("Mom");
205
+ });
206
+ });
207
+
208
+ describe("what a pull must never touch", () => {
209
+ test("mirror-only rows and assistant-authored fields survive untouched", async () => {
210
+ // An a2a peer exists only in the mirror; the seed contact carries
211
+ // guardian-authored notes and a curated name.
212
+ upsertContact({
213
+ id: "co-a2a",
214
+ displayName: "Peer Assistant",
215
+ contactType: "assistant",
216
+ channels: [{ type: "a2a", address: "peer-assistant-id" }],
217
+ });
218
+ upsertContactChannel({
219
+ sourceChannel: "slack",
220
+ externalUserId: "U123",
221
+ displayName: "Platform Name",
222
+ contactId: "co-known",
223
+ channelId: "ch-known",
224
+ refreshDisplayName: true,
225
+ userFileOnCreate: null,
226
+ reassignConflictingChannels: false,
227
+ });
228
+ upsertContact({
229
+ id: "co-known",
230
+ displayName: "Curated Name",
231
+ notes: "vip",
232
+ });
233
+
234
+ // The gateway snapshot knows co-known (with a different display name,
235
+ // the curated gateway one) and has never heard of the a2a peer.
236
+ snapshot = {
237
+ ok: true,
238
+ contacts: [
239
+ gatewayContact({
240
+ id: "co-known",
241
+ displayName: "Gateway Curated",
242
+ channels: [{ id: "ch-known", type: "slack", address: "U123" }],
243
+ }),
244
+ ],
245
+ };
246
+
247
+ await runMirrorReconcile();
248
+
249
+ expect(channelRow("a2a", "peer-assistant-id")?.contactId).toBe("co-a2a");
250
+ expect(contactRow("co-a2a")?.displayName).toBe("Peer Assistant");
251
+ expect(contactRow("co-known")).toEqual({
252
+ displayName: "Curated Name",
253
+ notes: "vip",
254
+ });
255
+ });
256
+
257
+ test("a converged mirror takes no writes (fixed point)", async () => {
258
+ upsertContactChannel({
259
+ sourceChannel: "slack",
260
+ externalUserId: "U123",
261
+ externalChatId: "D123",
262
+ displayName: "Alice Example",
263
+ contactId: "co-1",
264
+ channelId: "ch-1",
265
+ refreshDisplayName: true,
266
+ userFileOnCreate: null,
267
+ reassignConflictingChannels: false,
268
+ });
269
+ snapshot = {
270
+ ok: true,
271
+ contacts: [
272
+ gatewayContact({
273
+ id: "co-1",
274
+ displayName: "Gateway Name",
275
+ channels: [
276
+ {
277
+ id: "ch-1",
278
+ type: "slack",
279
+ address: "U123",
280
+ externalChatId: "D123",
281
+ },
282
+ ],
283
+ }),
284
+ ],
285
+ };
286
+ const before = getSqlite()
287
+ .query("SELECT updated_at AS u FROM contact_channels WHERE id = 'ch-1'")
288
+ .get() as { u: number };
289
+
290
+ await runMirrorReconcile();
291
+
292
+ const after = getSqlite()
293
+ .query("SELECT updated_at AS u FROM contact_channels WHERE id = 'ch-1'")
294
+ .get() as { u: number };
295
+ expect(after.u).toBe(before.u);
296
+ // Display name still the mirror's own (a no-op pass rewrites nothing).
297
+ expect(contactRow("co-1")?.displayName).toBe("Alice Example");
298
+ });
299
+
300
+ test("a snapshot pulled before a delete cannot resurrect the deleted contact", async () => {
301
+ // The gateway deleted co-gone (and the explicit mirror delete op landed)
302
+ // while a snapshot still listing it was in flight. Healing from that
303
+ // stale snapshot would bring the ghost back, and the additive contract
304
+ // would then keep it forever.
305
+ upsertContactChannel({
306
+ sourceChannel: "slack",
307
+ externalUserId: "UGONE",
308
+ displayName: "Alice Example",
309
+ contactId: "co-gone",
310
+ channelId: "ch-gone",
311
+ refreshDisplayName: true,
312
+ userFileOnCreate: null,
313
+ reassignConflictingChannels: false,
314
+ });
315
+ deleteContact("co-gone");
316
+ snapshot = {
317
+ ok: true,
318
+ contacts: [
319
+ gatewayContact({
320
+ id: "co-gone",
321
+ displayName: "Alice Example",
322
+ channels: [{ id: "ch-gone", type: "slack", address: "UGONE" }],
323
+ }),
324
+ ],
325
+ };
326
+
327
+ await runMirrorReconcile();
328
+
329
+ expect(contactRow("co-gone")).toBeNull();
330
+ expect(channelRow("slack", "UGONE")).toBeNull();
331
+ });
332
+
333
+ test("a failed snapshot pull heals nothing and throws nothing", async () => {
334
+ upsertContactChannel({
335
+ sourceChannel: "slack",
336
+ externalUserId: "U123",
337
+ displayName: "Alice Example",
338
+ contactId: "co-1",
339
+ channelId: "ch-1",
340
+ refreshDisplayName: true,
341
+ userFileOnCreate: null,
342
+ reassignConflictingChannels: false,
343
+ });
344
+ ipcCallPersistentMock.mockImplementationOnce(async () => {
345
+ throw new Error("gateway unreachable");
346
+ });
347
+
348
+ await runMirrorReconcile();
349
+
350
+ expect(channelRow("slack", "U123")?.contactId).toBe("co-1");
351
+ });
352
+ });
@@ -9,6 +9,7 @@ import {
9
9
  contacts,
10
10
  } from "../persistence/schema/index.js";
11
11
  import { canonicalizeInboundIdentity } from "../util/canonicalize-identity.js";
12
+ import { recordContactTombstone } from "./mirror-tombstones.js";
12
13
  import { notifyContactsChanged } from "./notify-contacts-changed.js";
13
14
  import type {
14
15
  AssistantContactMetadata,
@@ -256,6 +257,16 @@ export function upsertContact(params: {
256
257
  * mirror to create faithful null-user_file stubs. `userFile` takes
257
258
  * precedence when both are supplied. */
258
259
  userFileOnCreate?: string | null;
260
+ /** contactType to seed ONLY when inserting a new contact; ignored on update
261
+ * (and on channel-identity adoption) so a guardian-curated classification
262
+ * is never clobbered by an inbound seed. `contactType` takes precedence
263
+ * when both are supplied. */
264
+ contactTypeOnCreate?: ContactType;
265
+ /** notes to seed ONLY when inserting a new contact; ignored on update (and
266
+ * on channel-identity adoption) so guardian-authored notes are never
267
+ * clobbered by an inbound seed. `notes` takes precedence when both are
268
+ * supplied. */
269
+ notesOnCreate?: string | null;
259
270
  channels?: SyncChannelData[];
260
271
  /** When true, conflicting channels on other contacts are reassigned to this
261
272
  * contact instead of being skipped. Used by invite redemption to bind a
@@ -317,8 +328,20 @@ export function upsertContact(params: {
317
328
  }
318
329
  }
319
330
 
320
- // Try to find by channel canonical identity to avoid duplicates
321
- if (!contactId && canonicalChannels && canonicalChannels.length > 0) {
331
+ // Try to find by channel canonical identity to avoid duplicates. This also
332
+ // covers an explicit-id CREATE (the id was not found above) when the caller
333
+ // did not opt into reassignment: syncChannels would skip a channel owned by
334
+ // another contact, so inserting the supplied id would mint a channel-less
335
+ // duplicate of that contact. Adopting the existing owner instead keeps one
336
+ // contact per channel identity; the supplied id is dropped, and the
337
+ // *OnCreate record fields never apply here. Reassigning callers (invite
338
+ // binding, the guardian bootstrap mirror) skip this and keep their
339
+ // bind-to-target semantics.
340
+ if (
341
+ canonicalChannels &&
342
+ canonicalChannels.length > 0 &&
343
+ (!contactId || !params.reassignConflictingChannels)
344
+ ) {
322
345
  for (const ch of canonicalChannels) {
323
346
  const existingChannel = findConflictingChannel(db, ch.type, ch.address);
324
347
 
@@ -362,8 +385,8 @@ export function upsertContact(params: {
362
385
  .values({
363
386
  id: contactId,
364
387
  displayName: params.displayName,
365
- notes: params.notes ?? null,
366
- contactType: params.contactType ?? "human",
388
+ notes: params.notes ?? params.notesOnCreate ?? null,
389
+ contactType: params.contactType ?? params.contactTypeOnCreate ?? "human",
367
390
  userFile: resolvedUserFile,
368
391
  createdAt: now,
369
392
  updatedAt: now,
@@ -390,6 +413,9 @@ export function upsertContact(params: {
390
413
  */
391
414
  export function deleteContact(id: string): void {
392
415
  getDb().delete(contacts).where(eq(contacts.id, id)).run();
416
+ // The mirror reconciler must not resurrect this id from a gateway snapshot
417
+ // pulled before the delete.
418
+ recordContactTombstone(id);
393
419
  notifyContactsChanged();
394
420
  }
395
421
 
@@ -482,6 +508,16 @@ function syncChannels(
482
508
 
483
509
  if (existing) {
484
510
  const updateSet: Record<string, unknown> = {};
511
+ // Adopt a caller-supplied (gateway-minted) id onto a row still keyed
512
+ // by a divergent legacy id: the byId lookup above missed, so the id is
513
+ // free, and aligning it converges the two stores' keys (id-keyed
514
+ // gateway read-backs and client PATCHes then resolve directly).
515
+ // Nothing daemon-side persists a mirror channel id elsewhere, and
516
+ // readers holding the old id already fall back to the (type, address)
517
+ // logical key, so the swap only removes divergence.
518
+ if (ch.id && ch.id !== existing.id) {
519
+ updateSet.id = ch.id;
520
+ }
485
521
  // Self-heal legacy lowercased addresses to canonical form.
486
522
  if (existing.address !== ch.address) {
487
523
  updateSet.address = ch.address;
@@ -514,6 +550,12 @@ function syncChannels(
514
550
  contactId,
515
551
  updatedAt: now,
516
552
  };
553
+ // Adopt the caller-supplied (gateway-minted) id in the same write:
554
+ // the byId lookup above missed, so the id is free (same rationale as
555
+ // the same-contact adopt branch).
556
+ if (ch.id && ch.id !== conflicting.id) {
557
+ reassignSet.id = ch.id;
558
+ }
517
559
  if (ch.externalChatId !== undefined) {
518
560
  reassignSet.externalChatId = ch.externalChatId;
519
561
  }
@@ -773,6 +815,7 @@ export function mergeContactMirror(params: {
773
815
 
774
816
  // Delete the donor (cascade removes remaining duplicate channels).
775
817
  tx.delete(contacts).where(eq(contacts.id, params.mergeContactId)).run();
818
+ recordContactTombstone(params.mergeContactId);
776
819
  return true;
777
820
  });
778
821
 
@@ -82,8 +82,11 @@ export function upsertContactChannel(params: {
82
82
  upsertContact({
83
83
  id: params.contactId,
84
84
  displayName,
85
- contactType: params.contactType,
86
- notes: params.notes,
85
+ // Classification and notes are create-only on this path (see the param
86
+ // docs above): applying them on update or on channel-identity adoption
87
+ // would let an inbound seed clobber guardian-authored record fields.
88
+ contactTypeOnCreate: params.contactType,
89
+ notesOnCreate: params.notes,
87
90
  userFileOnCreate: params.userFileOnCreate,
88
91
  channels: [
89
92
  {
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Pull-based identity reconciliation of the assistant contact mirror against
3
+ * the gateway DB (the source of truth for contact identity and channel
4
+ * ownership).
5
+ *
6
+ * The gateway's per-write mirror ops are best-effort: a failed or lost op
7
+ * leaves the mirror missing a row, or holding a channel under the wrong
8
+ * contact, until something happens to write that identity again. This module
9
+ * converges the drift instead: on the debounced `contacts_changed` signal it
10
+ * pulls the gateway's identity snapshot and heals the mirror toward it.
11
+ *
12
+ * Additive-corrective ONLY, because the mirror holds data the gateway cannot
13
+ * reconstruct:
14
+ *
15
+ * - It never deletes a mirror row. Mirror-only contacts and channels are
16
+ * legitimate (a2a peers are written daemon-side only), and absence from
17
+ * the gateway is indistinguishable from "the gateway never had it".
18
+ * Delete propagation stays on the explicit delete ops.
19
+ * - It never touches assistant-authored fields (notes, contactType,
20
+ * userFile) or an existing contact's displayName (the mirror name tracks
21
+ * the live platform profile, not the gateway's curated name).
22
+ *
23
+ * Convergence is a fixed point: a pass only writes rows that diverge from
24
+ * the snapshot, so the follow-up pass its own writes trigger (every mirror
25
+ * write broadcasts `contacts_changed`) finds nothing to do and stops.
26
+ *
27
+ * TRANSITIONAL: this module exists only while the assistant DB keeps its own
28
+ * copy of identity columns. When identity reads move to the gateway
29
+ * wholesale, the mirror's identity columns and this reconciler go with them;
30
+ * do not grow it into a general sync framework.
31
+ */
32
+
33
+ import { ContactsIdentitySnapshotIpcResponseSchema } from "@vellumai/gateway-client/gateway-ipc-contracts";
34
+
35
+ import { getDbMigrationReadiness } from "../daemon/daemon-readiness.js";
36
+ import { ipcCallPersistent } from "../ipc/gateway-client.js";
37
+ import { getDb } from "../persistence/db-connection.js";
38
+ import { contactChannels, contacts } from "../persistence/schema/index.js";
39
+ import { getLogger } from "../util/logger.js";
40
+ import { upsertContact } from "./contact-store.js";
41
+ import { upsertContactChannel } from "./contacts-write.js";
42
+ import { isContactTombstoned } from "./mirror-tombstones.js";
43
+
44
+ const log = getLogger("mirror-reconciler");
45
+
46
+ /**
47
+ * Collapse bursts of contacts_changed into one pull. Every inbound message
48
+ * from a known sender refreshes its channel row and notifies, so under
49
+ * continuous traffic this is the steady-state floor between snapshot pulls;
50
+ * healing latency is deliberately loose (the next quiet moment is fine).
51
+ */
52
+ const DEBOUNCE_MS = 15_000;
53
+
54
+ let timer: ReturnType<typeof setTimeout> | null = null;
55
+ let running = false;
56
+ let rerunRequested = false;
57
+
58
+ /**
59
+ * Ask for a reconcile pass soon. Called (via dynamic import) from
60
+ * `notifyContactsChanged`, so every contact write, gateway-emitted or
61
+ * daemon-local, requests convergence. Coalesces bursts; a request landing
62
+ * while a pass is running (including the pass's own writes re-notifying)
63
+ * runs one follow-up pass after it, whose no-op result ends the cycle.
64
+ */
65
+ export function scheduleMirrorReconcile(): void {
66
+ if (running) {
67
+ rerunRequested = true;
68
+ return;
69
+ }
70
+ if (timer) {
71
+ return;
72
+ }
73
+ timer = setTimeout(() => {
74
+ timer = null;
75
+ void runMirrorReconcile();
76
+ }, DEBOUNCE_MS);
77
+ // A pending debounce must never hold the process open (daemon shutdown,
78
+ // test teardown); a lost pass is re-requested by the next contact write.
79
+ timer.unref?.();
80
+ }
81
+
82
+ /**
83
+ * Key channels by (type, lower(address)), matching the gateway's
84
+ * UNIQUE(type, address) NOCASE collation. The NUL delimiter cannot appear in
85
+ * either field, so keys never collide.
86
+ */
87
+ function channelKey(type: string, address: string): string {
88
+ return `${type}\u0000${address.toLowerCase()}`;
89
+ }
90
+
91
+ /**
92
+ * One reconcile pass: pull the gateway identity snapshot and heal every
93
+ * diverging mirror row through the identity-mirror primitives (which own the
94
+ * conflict semantics: gateway ids adopted on create, ownership reassigned to
95
+ * the gateway's contact, curated fields preserved). Best-effort: errors are
96
+ * logged, never thrown.
97
+ */
98
+ export async function runMirrorReconcile(): Promise<void> {
99
+ if (running) {
100
+ rerunRequested = true;
101
+ return;
102
+ }
103
+ if (!getDbMigrationReadiness().ready) {
104
+ return;
105
+ }
106
+ running = true;
107
+ try {
108
+ const result = await ipcCallPersistent("contacts_identity_snapshot", {});
109
+ const snapshot = ContactsIdentitySnapshotIpcResponseSchema.parse(result);
110
+
111
+ const db = getDb();
112
+ const localContactIds = new Set(
113
+ db
114
+ .select({ id: contacts.id })
115
+ .from(contacts)
116
+ .all()
117
+ .map((r) => r.id),
118
+ );
119
+ const localByTypeAddress = new Map(
120
+ db
121
+ .select({
122
+ id: contactChannels.id,
123
+ contactId: contactChannels.contactId,
124
+ type: contactChannels.type,
125
+ address: contactChannels.address,
126
+ externalChatId: contactChannels.externalChatId,
127
+ isPrimary: contactChannels.isPrimary,
128
+ })
129
+ .from(contactChannels)
130
+ .all()
131
+ .map((row) => [channelKey(row.type, row.address), row]),
132
+ );
133
+
134
+ let healed = 0;
135
+ for (const contact of snapshot.contacts) {
136
+ // A contact deleted (or merged away) after this snapshot was pulled
137
+ // must not be resurrected from it; the additive contract would keep
138
+ // the ghost forever.
139
+ if (isContactTombstoned(contact.id)) {
140
+ continue;
141
+ }
142
+ if (contact.channels.length === 0) {
143
+ // A channel-less gateway contact (a guardian-authored record) whose
144
+ // mirror row is missing: recreate the identity stub.
145
+ if (!localContactIds.has(contact.id)) {
146
+ upsertContact({
147
+ id: contact.id,
148
+ displayName: contact.displayName,
149
+ userFileOnCreate: null,
150
+ });
151
+ healed += 1;
152
+ }
153
+ continue;
154
+ }
155
+
156
+ for (const channel of contact.channels) {
157
+ const local = localByTypeAddress.get(
158
+ channelKey(channel.type, channel.address),
159
+ );
160
+ // A gateway-null externalChatId means the delivery chat id was
161
+ // never learned (clearing one is off the upsert contract), so the
162
+ // mirror's value is the richer of the two and is not divergence. A
163
+ // divergent channel id IS: the heal adopts the gateway id (see
164
+ // syncChannels), so id-keyed read-backs and client PATCHes resolve
165
+ // directly instead of through the logical-key fallback forever.
166
+ const diverges =
167
+ !local ||
168
+ local.id !== channel.id ||
169
+ local.contactId !== contact.id ||
170
+ (channel.externalChatId != null &&
171
+ local.externalChatId !== channel.externalChatId) ||
172
+ local.isPrimary !== channel.isPrimary;
173
+ if (!diverges) {
174
+ continue;
175
+ }
176
+ // The identity-mirror primitive adopts the gateway ids on create,
177
+ // reassigns a divergent owner (the gateway owns channel ownership),
178
+ // and preserves an existing contact's curated name and record fields.
179
+ upsertContactChannel({
180
+ sourceChannel: channel.type,
181
+ externalUserId: channel.address,
182
+ externalChatId: channel.externalChatId ?? undefined,
183
+ displayName: contact.displayName,
184
+ contactId: contact.id,
185
+ channelId: channel.id,
186
+ refreshDisplayName: false,
187
+ reassignConflictingChannels: true,
188
+ isPrimary: channel.isPrimary,
189
+ userFileOnCreate: null,
190
+ });
191
+ healed += 1;
192
+ }
193
+ }
194
+
195
+ if (healed > 0) {
196
+ log.info(
197
+ { healed },
198
+ "mirror reconciler converged identity rows onto the gateway snapshot",
199
+ );
200
+ }
201
+ } catch (err) {
202
+ log.warn({ err }, "mirror reconcile failed (best-effort)");
203
+ } finally {
204
+ running = false;
205
+ if (rerunRequested) {
206
+ rerunRequested = false;
207
+ scheduleMirrorReconcile();
208
+ }
209
+ }
210
+ }