@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
@@ -5,8 +5,9 @@ export interface OAuthConnectionRequest {
5
5
  headers?: Record<string, string>;
6
6
  /**
7
7
  * A string is forwarded to the provider verbatim under the caller's own
8
- * `Content-Type` (multipart, XML, form-encoded). Anything else is
9
- * JSON-serialized and sent as `application/json`.
8
+ * `Content-Type` (multipart, XML, form-encoded). A Buffer or Uint8Array
9
+ * is forwarded as raw bytes. Anything else is JSON-serialized and sent
10
+ * as `application/json`.
10
11
  */
11
12
  body?: unknown;
12
13
  /**
@@ -102,6 +102,41 @@ describe("PlatformOAuthConnection", () => {
102
102
  expect(result.body).toEqual(upstreamBody);
103
103
  });
104
104
 
105
+ test("encodes a Buffer request body as base64 in the proxy envelope", async () => {
106
+ const binary = Buffer.from([
107
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00,
108
+ ]);
109
+
110
+ const client = makeMockClient(
111
+ mock(async (_url: string | URL | Request, init?: RequestInit) => {
112
+ const parsed = JSON.parse(init?.body as string);
113
+ expect(parsed.request.body).toBe(binary.toString("base64"));
114
+ expect(parsed.request.body_encoding).toBe("base64");
115
+ expect(parsed.request.headers["Content-Type"]).toBe("application/pdf");
116
+
117
+ return new Response(
118
+ JSON.stringify({
119
+ status: 200,
120
+ headers: { "content-type": "application/json" },
121
+ body: { id: "file-123" },
122
+ }),
123
+ { status: 200 },
124
+ );
125
+ }) as unknown as typeof globalThis.fetch,
126
+ );
127
+
128
+ const conn = new PlatformOAuthConnection({
129
+ ...DEFAULT_OPTIONS,
130
+ client,
131
+ });
132
+ await conn.request({
133
+ method: "POST",
134
+ path: "/upload/drive/v3/files",
135
+ headers: { "Content-Type": "application/pdf" },
136
+ body: binary,
137
+ });
138
+ });
139
+
105
140
  test("decodes base64 binary proxy bodies into a Buffer", async () => {
106
141
  const binary = Buffer.from([
107
142
  0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00,
@@ -7,6 +7,7 @@ import type {
7
7
  OAuthConnectionRequest,
8
8
  OAuthConnectionResponse,
9
9
  } from "./connection.js";
10
+ import { isBinaryOAuthBody } from "./connection.js";
10
11
 
11
12
  const log = getLogger("platform-oauth-connection");
12
13
  const MAX_RETRIES = 3;
@@ -84,20 +85,24 @@ export class PlatformOAuthConnection implements OAuthConnection {
84
85
 
85
86
  // The envelope carries the caller's headers and body side by side. A
86
87
  // string body is placed in the envelope as a string, so the proxy forwards
87
- // those bytes verbatim under the caller's Content-Type; an object body
88
- // travels as JSON and the proxy serializes it.
89
- const body: Record<string, unknown> = {
90
- request: {
91
- method: req.method,
92
- path: req.path,
93
- query: req.query ?? {},
94
- headers: req.headers ?? {},
95
- body: req.body ?? null,
96
- ...((req.baseUrl ?? this.baseUrl)
97
- ? { base_url: req.baseUrl ?? this.baseUrl }
98
- : {}),
99
- },
88
+ // those bytes verbatim under the caller's Content-Type. A Buffer is
89
+ // base64-encoded so binary uploads survive JSON. An object body travels
90
+ // as JSON and the proxy serializes it.
91
+ const request: Record<string, unknown> = {
92
+ method: req.method,
93
+ path: req.path,
94
+ query: req.query ?? {},
95
+ headers: req.headers ?? {},
96
+ body: req.body ?? null,
97
+ ...((req.baseUrl ?? this.baseUrl)
98
+ ? { base_url: req.baseUrl ?? this.baseUrl }
99
+ : {}),
100
100
  };
101
+ if (isBinaryOAuthBody(req.body)) {
102
+ request.body = Buffer.from(req.body).toString("base64");
103
+ request.body_encoding = "base64";
104
+ }
105
+ const body: Record<string, unknown> = { request };
101
106
 
102
107
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
103
108
  const response = await this.client.fetch(proxyPath, {
@@ -24,6 +24,54 @@ export function scopeDifference(
24
24
  );
25
25
  }
26
26
 
27
+ /**
28
+ * Split a scope list the way the provider writes it.
29
+ *
30
+ * Providers (GitHub, Slack) may return comma-separated scopes regardless of
31
+ * the separator used to join outbound authorize URLs, so the default tolerates
32
+ * both spaces and commas. A provider that explicitly configures a non-default
33
+ * separator (Linear uses ",") is honored, keeping configured scopes
34
+ * round-tripping symmetrically.
35
+ *
36
+ * The one parser for both directions: token responses and authorize
37
+ * parameters. Two copies would let a provider-format fix land on one and not
38
+ * the other.
39
+ */
40
+ export function parseScopeList(
41
+ raw: string,
42
+ scopeSeparator: string = " ",
43
+ ): string[] {
44
+ const splitPattern = scopeSeparator === " " ? /[ ,]/ : scopeSeparator;
45
+ return raw
46
+ .split(splitPattern)
47
+ .map((scope) => scope.trim())
48
+ .filter(Boolean);
49
+ }
50
+
51
+ /**
52
+ * The scopes the stored access token is expected to carry.
53
+ *
54
+ * A provider that asks for user scopes alongside bot scopes issues two grants
55
+ * from one authorization. `exchangeCodeForTokens` stores the `authed_user`
56
+ * token in that case and records that token's own grant as `grantedScopes`, so
57
+ * coverage has to be measured against the request that produced the stored
58
+ * token: `user_scope` where the provider sends one, the `scope` parameter
59
+ * otherwise. Measuring a user grant against the bot request reports scopes the
60
+ * authorization could never place on that token.
61
+ */
62
+ export function expectedScopesForStoredToken(
63
+ defaultScopes: string[],
64
+ authorizeParams: Record<string, string> | undefined,
65
+ scopeSeparator?: string,
66
+ ): string[] {
67
+ const userScope = authorizeParams?.user_scope;
68
+ if (typeof userScope !== "string") {
69
+ return defaultScopes;
70
+ }
71
+ const parsed = parseScopeList(userScope, scopeSeparator);
72
+ return parsed.length > 0 ? parsed : defaultScopes;
73
+ }
74
+
27
75
  const GMAIL_FULL_ACCESS_SCOPE = "https://mail.google.com/";
28
76
  const GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly";
29
77
 
@@ -183,6 +183,14 @@ export const PROVIDER_SEED_DATA: Record<
183
183
  clientIdPlaceholder: null,
184
184
  logoUrl:
185
185
  "https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/slack/default.svg",
186
+ // Sent as the bot `scope` parameter, while this flow persists the
187
+ // `authed_user` token and stores `authed_user.scope` as `grantedScopes`.
188
+ // Credential health compares this list against that grant, so a scope
189
+ // listed here that `user_scope` does not also request reports as
190
+ // `missing_scopes`, a hard failure that disables the provider's tools.
191
+ // The wider bot set belongs to Socket Mode installs, which verify the live
192
+ // `x-oauth-scopes` header against SLACK_REQUIRED_BOT_SCOPES in
193
+ // channel-readiness-service.ts.
186
194
  defaultScopes: [
187
195
  "channels:join",
188
196
  "channels:read",
@@ -200,6 +208,10 @@ export const PROVIDER_SEED_DATA: Record<
200
208
  "reactions:write",
201
209
  ],
202
210
  availableScopes: "https://api.slack.com/scopes",
211
+ // Carries write scopes because the persisted `authed_user` token is the
212
+ // only credential this flow stores, so it acts as the installer for every
213
+ // call including `chat.postMessage`. The manifest's user list is read-only
214
+ // instead, since a Socket Mode install keeps a bot token beside it.
203
215
  authorizeParams: {
204
216
  user_scope:
205
217
  "channels:read,channels:history,groups:read,groups:history,im:read,im:history,im:write,mpim:read,mpim:history,users:read,chat:write,search:read,reactions:write",
@@ -49,6 +49,7 @@ export async function ensureLocalCA(dataDir: string): Promise<void> {
49
49
  const keyProc = Bun.spawn(["openssl", "genrsa", "-out", keyPath, "2048"], {
50
50
  stdout: "pipe",
51
51
  stderr: "pipe",
52
+ windowsHide: true,
52
53
  });
53
54
  const keyExit = await keyProc.exited;
54
55
  if (keyExit !== 0) {
@@ -76,7 +77,7 @@ export async function ensureLocalCA(dataDir: string): Promise<void> {
76
77
  "-addext",
77
78
  "keyUsage=critical,keyCertSign,cRLSign",
78
79
  ],
79
- { stdout: "pipe", stderr: "pipe" },
80
+ { stdout: "pipe", stderr: "pipe", windowsHide: true },
80
81
  );
81
82
  const certExit = await certProc.exited;
82
83
  if (certExit !== 0) {
@@ -144,7 +145,7 @@ export async function issueLeafCert(
144
145
  // Generate leaf key
145
146
  const keyProc = Bun.spawn(
146
147
  ["openssl", "genrsa", "-out", leafKeyPath, "2048"],
147
- { stdout: "pipe", stderr: "pipe" },
148
+ { stdout: "pipe", stderr: "pipe", windowsHide: true },
148
149
  );
149
150
  const keyExit = await keyProc.exited;
150
151
  if (keyExit !== 0) {
@@ -166,7 +167,7 @@ export async function issueLeafCert(
166
167
  "-subj",
167
168
  `/CN=${hostname}`,
168
169
  ],
169
- { stdout: "pipe", stderr: "pipe" },
170
+ { stdout: "pipe", stderr: "pipe", windowsHide: true },
170
171
  );
171
172
  const csrExit = await csrProc.exited;
172
173
  if (csrExit !== 0) {
@@ -198,7 +199,7 @@ export async function issueLeafCert(
198
199
  "-extfile",
199
200
  extPath,
200
201
  ],
201
- { stdout: "pipe", stderr: "pipe" },
202
+ { stdout: "pipe", stderr: "pipe", windowsHide: true },
202
203
  );
203
204
  const signExit = await signProc.exited;
204
205
  if (signExit !== 0) {
@@ -21,7 +21,7 @@ const bridgeMock = mock((_params: Record<string, unknown>) =>
21
21
  Promise.resolve({ bridged: true as const, signalId: "req-1" }),
22
22
  );
23
23
  const withdrawCardsMock = mock((_params: Record<string, unknown>) =>
24
- Promise.resolve(),
24
+ Promise.resolve({ complete: true }),
25
25
  );
26
26
 
27
27
  let trustContext: TrustContext | undefined;
@@ -37,7 +37,13 @@ mock.module("../daemon/conversation-registry.js", () => ({
37
37
  })
38
38
  : undefined,
39
39
  }));
40
+ // Spread the actual module so transitive importers of names this
41
+ // factory does not stub keep resolving (a partial factory breaks at
42
+ // import time when the graph gains a new named import).
43
+ const actualGatewayGuardianRequests =
44
+ await import("../channels/gateway-guardian-requests.js");
40
45
  mock.module("../channels/gateway-guardian-requests.js", () => ({
46
+ ...actualGatewayGuardianRequests,
41
47
  createGuardianRequest: (params: Record<string, unknown>) =>
42
48
  createGuardianRequestMock(params),
43
49
  expireGuardianRequest: (id: string) => expireGuardianRequestMock(id),
@@ -273,36 +273,52 @@ function materializeAttachmentIntoConversation(
273
273
  const resolvedName = resolveUniqueFilename(attachDir, row.originalFilename);
274
274
  const targetPath = join(attachDir, resolvedName);
275
275
 
276
- let sourcePath = row.sourcePath;
277
- if (row.dataBase64) {
278
- writeFileSync(targetPath, Buffer.from(row.dataBase64, "base64"));
279
- } else {
280
- const readablePath = [row.filePath, row.sourcePath].find(
281
- (path): path is string => !!path && existsSync(path),
282
- );
283
- if (!readablePath) {
284
- return;
285
- }
286
-
287
- if (!sourcePath && readablePath !== row.filePath) {
288
- sourcePath = readablePath;
289
- } else if (
290
- !sourcePath &&
291
- readablePath === row.filePath &&
292
- dirname(readablePath) !== attachDir
293
- ) {
294
- sourcePath = readablePath;
295
- }
296
-
297
- copyFileSync(readablePath, targetPath);
298
- }
299
-
300
276
  // Remember the old file path before updating the DB row, so we can
301
277
  // clean up the staging copy (e.g. in data/attachments/) after the
302
278
  // canonical path moves to the conversation directory.
303
279
  const previousFilePath = row.filePath;
304
280
 
305
- persistAttachmentFilePath(row.id, targetPath, sourcePath);
281
+ let sourcePath = row.sourcePath;
282
+ try {
283
+ if (row.dataBase64) {
284
+ writeFileSync(targetPath, Buffer.from(row.dataBase64, "base64"));
285
+ } else {
286
+ const readablePath = [row.filePath, row.sourcePath].find(
287
+ (path): path is string => !!path && existsSync(path),
288
+ );
289
+ if (!readablePath) {
290
+ return;
291
+ }
292
+
293
+ if (!sourcePath && readablePath !== row.filePath) {
294
+ sourcePath = readablePath;
295
+ } else if (
296
+ !sourcePath &&
297
+ readablePath === row.filePath &&
298
+ dirname(readablePath) !== attachDir
299
+ ) {
300
+ sourcePath = readablePath;
301
+ }
302
+
303
+ copyFileSync(readablePath, targetPath);
304
+ }
305
+
306
+ persistAttachmentFilePath(row.id, targetPath, sourcePath);
307
+ } catch (err) {
308
+ // Only the copy this call was making. `resolveUniqueFilename` picked a name
309
+ // nothing occupied, so whatever sits at `targetPath` now is this attempt's
310
+ // own work. The row's `filePath` is deliberately left alone: until the
311
+ // update above lands it can still name the file the bytes came FROM, and
312
+ // for a fresh clone that file belongs to another conversation.
313
+ if (existsSync(targetPath)) {
314
+ try {
315
+ unlinkSync(targetPath);
316
+ } catch {
317
+ /* leave the copy rather than fail the failure */
318
+ }
319
+ }
320
+ throw err;
321
+ }
306
322
 
307
323
  // Remove the old staging file now that the canonical copy lives in
308
324
  // the conversation directory. Only delete files that live in the
@@ -344,18 +360,58 @@ function scopeAttachmentToConversation(
344
360
  }
345
361
 
346
362
  const linkedConversationIds = listLinkedConversationIds(attachmentId);
347
- if (linkedConversationIds.some((id) => id !== conversationId)) {
363
+ const cloned = linkedConversationIds.some((id) => id !== conversationId);
364
+ if (cloned) {
348
365
  row = cloneAttachmentRow(row);
349
366
  }
350
367
 
351
- materializeAttachmentIntoConversation(
352
- row,
353
- conversationId,
354
- conversationCreatedAt,
355
- );
368
+ try {
369
+ materializeAttachmentIntoConversation(
370
+ row,
371
+ conversationId,
372
+ conversationCreatedAt,
373
+ );
374
+ } catch (err) {
375
+ if (cloned) {
376
+ discardFailedCloneRow(row.id);
377
+ }
378
+ throw err;
379
+ }
356
380
  return row.id;
357
381
  }
358
382
 
383
+ /**
384
+ * Drop the row a failed clone left behind, touching no file at all.
385
+ *
386
+ * A clone is inserted carrying the SOURCE row's `filePath`, and only a
387
+ * successful materialization repoints it at a copy of its own. At the moment
388
+ * materialization fails the row can therefore still name the original's file,
389
+ * and deleting that would take the source conversation's data with it. This is
390
+ * the hazard the staging-dir guard in
391
+ * {@link materializeAttachmentIntoConversation} exists for, applied to the
392
+ * other end of the same window.
393
+ *
394
+ * The file this attempt was creating is cleaned up by materialization itself,
395
+ * which is the only code that knows the path it chose.
396
+ *
397
+ * Best effort. Rethrowing the original failure matters more than the row, and
398
+ * the caller converts that failure into an inline fallback either way.
399
+ */
400
+ function discardFailedCloneRow(clonedId: string): void {
401
+ try {
402
+ rawRun(
403
+ "attachments:discardFailedCloneRow",
404
+ `DELETE FROM attachments WHERE id = ?`,
405
+ clonedId,
406
+ );
407
+ } catch (err) {
408
+ getLogger("attachments-store").warn(
409
+ { err, attachmentId: clonedId },
410
+ "Could not discard the row left by a failed attachment clone",
411
+ );
412
+ }
413
+ }
414
+
359
415
  /**
360
416
  * Scope a pre-uploaded attachment into a conversation WITHOUT linking it to a
361
417
  * message, returning the scoped row's metadata. Mirrors {@link createInlineAttachment}
@@ -1164,6 +1220,32 @@ export function getAttachmentsByIds(
1164
1220
  return results;
1165
1221
  }
1166
1222
 
1223
+ /**
1224
+ * Hydrate attachment ids into the shape a persisted user message stores:
1225
+ * base64 data plus the on-disk source path where one is known. Ids with no
1226
+ * attachment row are dropped, so a caller that needs to know an id was bad
1227
+ * compares the returned length against what it asked for.
1228
+ */
1229
+ export function resolveAttachmentsForPersist(attachmentIds: string[]): Array<{
1230
+ id: string;
1231
+ filename: string;
1232
+ mimeType: string;
1233
+ data: string;
1234
+ filePath?: string;
1235
+ }> {
1236
+ const resolved = getAttachmentsByIds(attachmentIds, {
1237
+ hydrateFileData: true,
1238
+ });
1239
+ const sourcePaths = getSourcePathsForAttachments(attachmentIds);
1240
+ return resolved.map((a) => ({
1241
+ id: a.id,
1242
+ filename: a.originalFilename,
1243
+ mimeType: a.mimeType,
1244
+ data: a.dataBase64,
1245
+ ...(sourcePaths.has(a.id) ? { filePath: sourcePaths.get(a.id) } : {}),
1246
+ }));
1247
+ }
1248
+
1167
1249
  export function linkAttachmentToMessage(
1168
1250
  messageId: string,
1169
1251
  attachmentId: string,
@@ -1332,6 +1414,16 @@ export function deleteOrphanAttachments(candidateIds: string[]): number {
1332
1414
 
1333
1415
  // Clean up on-disk files only after the DB rows have been removed
1334
1416
  for (const filePath of orphanFilePaths) {
1417
+ // A path is not private to one row. A clone carries the source's
1418
+ // `filePath` until materialization repoints it, and an attachment already
1419
+ // sitting in the destination's directory is scoped without being copied at
1420
+ // all, so a surviving row can still name this file. Unlinking it then
1421
+ // destroys another conversation's data, which is what the staging-dir
1422
+ // guard in `materializeAttachmentIntoConversation` avoids at the other end
1423
+ // of the same window.
1424
+ if (attachmentFilePathStillReferenced(filePath)) {
1425
+ continue;
1426
+ }
1335
1427
  try {
1336
1428
  unlinkSync(filePath);
1337
1429
  } catch {
@@ -1341,3 +1433,14 @@ export function deleteOrphanAttachments(candidateIds: string[]): number {
1341
1433
 
1342
1434
  return deletedCount;
1343
1435
  }
1436
+
1437
+ /** True while any surviving attachment row still names this file. */
1438
+ function attachmentFilePathStillReferenced(filePath: string): boolean {
1439
+ return (
1440
+ rawGet<{ id: string }>(
1441
+ "attachments:filePathStillReferenced",
1442
+ `SELECT id FROM attachments WHERE file_path = ? LIMIT 1`,
1443
+ filePath,
1444
+ ) !== null
1445
+ );
1446
+ }
@@ -91,8 +91,12 @@ import {
91
91
  type ConversationCreateType,
92
92
  type ConversationOrigin,
93
93
  isHiddenMessageMetadata,
94
+ isNoResponseMetadata,
95
+ isReactionMessageMetadata,
94
96
  isSystemCardMetadata,
95
97
  PINNED_GROUP_ID,
98
+ SIGHT_FRAME_ATTACHMENT_IDS_KEY,
99
+ sightFrameAttachmentIdsFromMetadata,
96
100
  UNGROUPED_GROUP_ID,
97
101
  } from "./conversation-types.js";
98
102
  import { runAsyncSqlite } from "./db-async-query.js";
@@ -458,7 +462,10 @@ export {
458
462
  * alongside the schema that carries them.
459
463
  */
460
464
  export {
465
+ isNoResponseMetadata,
461
466
  isSystemCardMetadata,
467
+ NO_RESPONSE_MESSAGE_KIND,
468
+ REACTION_MESSAGE_KIND,
462
469
  SYSTEM_CARD_MESSAGE_KIND,
463
470
  } from "./conversation-types.js";
464
471
 
@@ -485,7 +492,7 @@ export function isProviderErrorMetadata(
485
492
 
486
493
  /**
487
494
  * True when an assistant row is a standalone display turn: a system card or
488
- * a provider-error notice. Standalone rows never merge with adjacent
495
+ * a provider-error notice, or a deliberate-silence marker. Standalone rows never merge with adjacent
489
496
  * assistant rows, and turn grouping closes on them, so display merging and
490
497
  * the turn resolver agree on boundaries. Takes the raw persisted `metadata`
491
498
  * JSON string; malformed JSON and non-assistant roles are never standalone.
@@ -499,7 +506,12 @@ export function isStandaloneAssistantMessage(
499
506
  }
500
507
  try {
501
508
  const parsed = JSON.parse(metadata) as Record<string, unknown>;
502
- return isSystemCardMetadata(parsed) || isProviderErrorMetadata(parsed);
509
+ return (
510
+ isSystemCardMetadata(parsed) ||
511
+ isProviderErrorMetadata(parsed) ||
512
+ isNoResponseMetadata(parsed) ||
513
+ isReactionMessageMetadata(parsed)
514
+ );
503
515
  } catch {
504
516
  return false;
505
517
  }
@@ -1675,6 +1687,8 @@ function populateForkContentsInProcess(args: PopulateForkContentsArgs): void {
1675
1687
  });
1676
1688
  }
1677
1689
 
1690
+ widenForkSightFrameTags(messagesToCopy, forkedMessageIds, attachmentIdMap);
1691
+
1678
1692
  // Set lastMessageAt to the max createdAt of copied messages so the
1679
1693
  // forked conversation sorts correctly by message recency.
1680
1694
  const lastCopiedMessage = messagesToCopy.at(-1);
@@ -1718,6 +1732,75 @@ function populateForkContentsInProcess(args: PopulateForkContentsArgs): void {
1718
1732
  }
1719
1733
  }
1720
1734
 
1735
+ /**
1736
+ * Extend the copied rows' camera-frame tags to name the fork's cloned
1737
+ * attachment ids alongside the source ids they were written with.
1738
+ *
1739
+ * A fork leaves its rows describing their attachments two different ways:
1740
+ * `messages.content` is copied byte for byte and still names the SOURCE
1741
+ * attachment ids, while `message_attachments` is re-linked to freshly CLONED
1742
+ * rows under new ids. Readers split along that seam. Camera-frame retention
1743
+ * matches the tag against the ids in the content blocks (source ids), and the
1744
+ * compactor builds its image manifest from the links (cloned ids) and stamps
1745
+ * those onto the frames it rebuilds. A tag naming only one vocabulary goes
1746
+ * blind on the other, so it names both.
1747
+ *
1748
+ * Widening rather than remapping is deliberate: replacing the source ids would
1749
+ * fix the compactor's frames by breaking every frame the fork holds directly,
1750
+ * which is the common case. Extra ids are inert, since an id no block carries
1751
+ * simply never matches.
1752
+ *
1753
+ * Runs after the attachment loop because that loop is what produces the id map.
1754
+ * Only rows that actually carry a tag are rewritten, so an ordinary fork does
1755
+ * no extra writes.
1756
+ */
1757
+ function widenForkSightFrameTags(
1758
+ messagesToCopy: MessageRow[],
1759
+ forkedMessageIds: Map<string, string>,
1760
+ attachmentIdMap: Map<string, string>,
1761
+ ): void {
1762
+ if (attachmentIdMap.size === 0) {
1763
+ return;
1764
+ }
1765
+ const db = getDb();
1766
+ for (const message of messagesToCopy) {
1767
+ const forkedMessageId = forkedMessageIds.get(message.id);
1768
+ if (!forkedMessageId) {
1769
+ continue;
1770
+ }
1771
+ const sourceMetadata = parseMessageMetadata(message.metadata);
1772
+ const sourceIds = sightFrameAttachmentIdsFromMetadata(sourceMetadata);
1773
+ if (sourceIds.length === 0) {
1774
+ continue;
1775
+ }
1776
+ const widened = new Set(sourceIds);
1777
+ for (const sourceId of sourceIds) {
1778
+ const clonedId = attachmentIdMap.get(sourceId);
1779
+ if (clonedId) {
1780
+ widened.add(clonedId);
1781
+ }
1782
+ }
1783
+ if (widened.size === sourceIds.length) {
1784
+ continue;
1785
+ }
1786
+ const forkedRow = db
1787
+ .select({ metadata: messages.metadata })
1788
+ .from(messages)
1789
+ .where(eq(messages.id, forkedMessageId))
1790
+ .get();
1791
+ const forkedMetadata = parseMessageMetadata(forkedRow?.metadata ?? null);
1792
+ db.update(messages)
1793
+ .set({
1794
+ metadata: JSON.stringify({
1795
+ ...(forkedMetadata ?? {}),
1796
+ [SIGHT_FRAME_ATTACHMENT_IDS_KEY]: [...widened],
1797
+ }),
1798
+ })
1799
+ .where(eq(messages.id, forkedMessageId))
1800
+ .run();
1801
+ }
1802
+ }
1803
+
1721
1804
  /**
1722
1805
  * How many leading rows of the inherited window sit behind the source's
1723
1806
  * compaction boundary, counted in `getMessages` load order (`createdAt`
@@ -2494,6 +2577,41 @@ export function selectProviderMetaCandidateMetadata(
2494
2577
  return out;
2495
2578
  }
2496
2579
 
2580
+ /**
2581
+ * Every attachment the conversation's rows tagged as an ambient camera frame,
2582
+ * mapped to the `createdAt` of the row that carried it.
2583
+ *
2584
+ * Like the Slack prefilter above, the `LIKE` is an indexable narrowing only:
2585
+ * each candidate row is parsed and validated before an id is taken, so a row
2586
+ * that merely mentions the key contributes nothing. Any row state qualifies
2587
+ * because the tag is written with the insert, and the lineage filter is what
2588
+ * lets a fork see the frames it inherited.
2589
+ */
2590
+ export function selectSightFrameCaptureTimes(
2591
+ conversationId: string,
2592
+ ): Map<string, number> {
2593
+ const db = getDb();
2594
+ const rows = db
2595
+ .select({ metadata: messages.metadata, createdAt: messages.createdAt })
2596
+ .from(messages)
2597
+ .where(
2598
+ and(
2599
+ lineageFilter(conversationId),
2600
+ like(messages.metadata, `%"${SIGHT_FRAME_ATTACHMENT_IDS_KEY}"%`),
2601
+ ),
2602
+ )
2603
+ .orderBy(asc(messages.createdAt))
2604
+ .all();
2605
+ const captureTimes = new Map<string, number>();
2606
+ for (const row of rows) {
2607
+ const metadata = parseMessageMetadata(row.metadata);
2608
+ for (const id of sightFrameAttachmentIdsFromMetadata(metadata)) {
2609
+ captureTimes.set(id, row.createdAt);
2610
+ }
2611
+ }
2612
+ return captureTimes;
2613
+ }
2614
+
2497
2615
  /**
2498
2616
  * Count messages in a conversation that were created strictly after the
2499
2617
  * `afterMessageId` reference message. If `afterMessageId` is `null` or empty,
@@ -4082,8 +4200,18 @@ export function relinkAttachments(
4082
4200
  *
4083
4201
  * Returns segment IDs so the caller can clean up the corresponding
4084
4202
  * Qdrant vector entries.
4203
+ *
4204
+ * `retainAttachmentIds` exempts attachments from the orphan cleanup below, for
4205
+ * a caller rolling a message back rather than deleting it: the attachment
4206
+ * returns to the uploaded-but-unlinked state it was in before the row existed,
4207
+ * which is a live state (that is where every attachment sits between upload and
4208
+ * send), not a leak. Callers deleting a message for good pass nothing and the
4209
+ * attachments it alone referenced are collected.
4085
4210
  */
4086
- export function deleteMessageById(messageId: string): DeletedMemoryIds {
4211
+ export function deleteMessageById(
4212
+ messageId: string,
4213
+ options?: { retainAttachmentIds?: readonly string[] },
4214
+ ): DeletedMemoryIds {
4087
4215
  const db = getDb();
4088
4216
  const result: DeletedMemoryIds = {
4089
4217
  segmentIds: [],
@@ -4145,7 +4273,12 @@ export function deleteMessageById(messageId: string): DeletedMemoryIds {
4145
4273
  ...purgeMessageSegments([messageId], "deleteMessageById:post-delete"),
4146
4274
  );
4147
4275
 
4148
- deleteOrphanAttachments(candidateAttachmentIds);
4276
+ const retainAttachmentIds = options?.retainAttachmentIds;
4277
+ deleteOrphanAttachments(
4278
+ retainAttachmentIds && retainAttachmentIds.length > 0
4279
+ ? candidateAttachmentIds.filter((id) => !retainAttachmentIds.includes(id))
4280
+ : candidateAttachmentIds,
4281
+ );
4149
4282
 
4150
4283
  // Remove the deleted message's point from the lexical index. Enqueued only
4151
4284
  // when the row actually existed (`msgRow` set), after the transaction and