@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
@@ -37,6 +37,7 @@ import {
37
37
  attachmentExists,
38
38
  AttachmentUploadError,
39
39
  createInlineAttachment,
40
+ deleteOrphanAttachments,
40
41
  getAttachmentContent,
41
42
  getFilePathForAttachment,
42
43
  linkAttachmentToMessage,
@@ -59,7 +60,12 @@ import {
59
60
  syncMessageToDisk,
60
61
  updateMetaFile,
61
62
  } from "../persistence/conversation-disk-view.js";
62
- import type { ContentBlock, Message } from "../providers/types.js";
63
+ import { SIGHT_FRAME_ATTACHMENT_IDS_KEY } from "../persistence/conversation-types.js";
64
+ import {
65
+ attachmentIdFragment,
66
+ type ContentBlock,
67
+ type Message,
68
+ } from "../providers/types.js";
63
69
  import type { AuthContext } from "../runtime/auth/types.js";
64
70
  import { getLogger } from "../util/logger.js";
65
71
  import { withSqliteRetry } from "../util/sqlite-retry.js";
@@ -430,37 +436,232 @@ async function prepareUserAttachmentReferences(
430
436
  attachments: MessageAttachmentInput[],
431
437
  ): Promise<PreparedUserAttachment[]> {
432
438
  const prepared: PreparedUserAttachment[] = [];
433
- for (let i = 0; i < attachments.length; i++) {
434
- const a = attachments[i];
435
- const outcome = await materializeUserAttachment(
436
- conversationId,
437
- conversationCreatedAt,
438
- a,
439
+ // Rows this call brought into being, tracked as they are made rather than
440
+ // read back off `prepared`: building a reference block is awaited between
441
+ // creating the row and recording it, so a throw there leaves a row nothing
442
+ // else knows about. The caller only ever sees a returned array, so a throw
443
+ // out of here makes this the one place that can still give them up.
444
+ const created: string[] = [];
445
+ try {
446
+ for (let i = 0; i < attachments.length; i++) {
447
+ const a = attachments[i];
448
+ const outcome = await materializeUserAttachment(
449
+ conversationId,
450
+ conversationCreatedAt,
451
+ a,
452
+ );
453
+ if (outcome.kind === "stored") {
454
+ if (outcome.stored.id !== a.id) {
455
+ created.push(outcome.stored.id);
456
+ }
457
+ prepared.push({
458
+ position: i,
459
+ block: await referenceBlockForAttachment(a, outcome.stored),
460
+ link: { attachmentId: outcome.stored.id },
461
+ });
462
+ continue;
463
+ }
464
+ if (outcome.kind === "rejected") {
465
+ continue;
466
+ }
467
+ // transient: keep the upload by inlining its bytes (dropped only when the
468
+ // recoverable failure left us with no bytes to inline).
469
+ const inline = await inlineBlockForAttachment(a);
470
+ if (inline) {
471
+ prepared.push({ position: i, block: inline });
472
+ } else {
473
+ log.warn(
474
+ { filename: a.filename },
475
+ "Dropping user attachment: store write failed and no inline bytes",
476
+ );
477
+ }
478
+ }
479
+ } catch (err) {
480
+ discardAttemptAttachments(created);
481
+ throw err;
482
+ }
483
+ return prepared;
484
+ }
485
+
486
+ /**
487
+ * Give up attachment rows a failed persist attempt created for itself.
488
+ *
489
+ * Conversation scoping CLONES an attachment already linked to another
490
+ * conversation, and an inline upload creates a fresh row under an id of its
491
+ * own. Either way the row is an artifact of this attempt that nothing else can
492
+ * reach: no message names it, and the caller still holds the id it sent or the
493
+ * bytes behind it. An attempt that dies before its message row exists has to
494
+ * take those with it, or the row and its copied file stay for good.
495
+ *
496
+ * Never the id the caller handed in. When materialization stored the
497
+ * attachment under that same id, the row IS the caller's upload, and deleting
498
+ * it would break the retry they are about to make.
499
+ *
500
+ * Routed through {@link deleteOrphanAttachments} so link-awareness stays the
501
+ * backstop, and swallowed on failure: cleaning up must never mask the error
502
+ * that caused it.
503
+ */
504
+ function discardAttemptAttachments(attachmentIds: string[]): void {
505
+ if (attachmentIds.length === 0) {
506
+ return;
507
+ }
508
+ try {
509
+ deleteOrphanAttachments(attachmentIds);
510
+ } catch (err) {
511
+ log.warn(
512
+ { err, attachmentIds },
513
+ "Could not discard the attachments of a failed persist attempt",
439
514
  );
440
- if (outcome.kind === "stored") {
441
- prepared.push({
442
- position: i,
443
- block: await referenceBlockForAttachment(a, outcome.stored),
444
- link: { attachmentId: outcome.stored.id },
445
- });
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Materialized ids that differ from the id their input arrived with, which is
520
+ * what separates a row this attempt made from the caller's own upload.
521
+ */
522
+ function attemptCreatedAttachmentIds(
523
+ attachmentInputs: MessageAttachmentInput[],
524
+ prepared: PreparedUserAttachment[],
525
+ ): string[] {
526
+ const created: string[] = [];
527
+ for (const p of prepared) {
528
+ if (!p.link) {
446
529
  continue;
447
530
  }
448
- if (outcome.kind === "rejected") {
449
- continue;
531
+ if (p.link.attachmentId !== attachmentInputs[p.position]?.id) {
532
+ created.push(p.link.attachmentId);
450
533
  }
451
- // transient: keep the upload by inlining its bytes (dropped only when the
452
- // recoverable failure left us with no bytes to inline).
453
- const inline = await inlineBlockForAttachment(a);
454
- if (inline) {
455
- prepared.push({ position: i, block: inline });
456
- } else {
457
- log.warn(
458
- { filename: a.filename },
459
- "Dropping user attachment: store write failed and no inline bytes",
460
- );
534
+ }
535
+ return created;
536
+ }
537
+
538
+ /** One requested camera frame: the id the caller held, and what was stored. */
539
+ interface SightFrameEntry {
540
+ inputId: string;
541
+ storedId: string;
542
+ }
543
+
544
+ /**
545
+ * Pair each requested ambient camera frame with the id its persisted block is
546
+ * attributable by, walking every materialized attachment back to the input it
547
+ * came from.
548
+ *
549
+ * Which id that is depends on how the attachment landed. A stored one is named
550
+ * by its linked row, which is also what its `workspace_ref` block carries. One
551
+ * that fell back to inline base64 has no row, and its block carries the
552
+ * caller's own id on `_attachmentId`, so that is the id to name: retention
553
+ * reads both shapes through `mediaBlockAttachmentId`, and an inline frame is
554
+ * the heaviest thing in every later request precisely because its bytes are in
555
+ * the row.
556
+ */
557
+ function requestedSightFrameEntries(
558
+ requestedIds: readonly string[] | undefined,
559
+ attachmentInputs: MessageAttachmentInput[],
560
+ prepared: PreparedUserAttachment[],
561
+ ): SightFrameEntry[] {
562
+ if (!requestedIds || requestedIds.length === 0) {
563
+ return [];
564
+ }
565
+ const requested = new Set(requestedIds);
566
+ const entries: SightFrameEntry[] = [];
567
+ for (const p of prepared) {
568
+ const inputId = attachmentInputs[p.position]?.id;
569
+ if (inputId === undefined || !requested.has(inputId)) {
570
+ continue;
461
571
  }
572
+ entries.push({
573
+ inputId,
574
+ storedId: p.link ? p.link.attachmentId : inputId,
575
+ });
462
576
  }
463
- return prepared;
577
+ return entries;
578
+ }
579
+
580
+ /**
581
+ * The ids to tag the row with.
582
+ *
583
+ * Exactly one id per frame, never both. A stored attachment's pre-clone id
584
+ * belongs to a DIFFERENT conversation's row, so naming it as well would mark
585
+ * that attachment as an ambient frame if it ever entered this lineage and stub
586
+ * an image someone deliberately attached. That is what separates this from a
587
+ * fork's widened tag (`widenForkSightFrameTags`), where the two ids are two
588
+ * names for the same image.
589
+ */
590
+ function sightFrameTagIds(entries: SightFrameEntry[]): string[] {
591
+ return [...new Set(entries.map((entry) => entry.storedId))];
592
+ }
593
+
594
+ /**
595
+ * Frames whose stored id differs from the one the caller handed in, by the
596
+ * caller's id. Empty unless conversation scoping cloned a row.
597
+ */
598
+ function sightFrameBlockRenames(
599
+ entries: SightFrameEntry[],
600
+ ): Map<string, string> {
601
+ return new Map(
602
+ entries
603
+ .filter((entry) => entry.storedId !== entry.inputId)
604
+ .map((entry) => [entry.inputId, entry.storedId]),
605
+ );
606
+ }
607
+
608
+ /**
609
+ * Point an inline media block at the id its attachment was stored under.
610
+ *
611
+ * Only the inline shape is restamped. A `workspace_ref` names its row inside
612
+ * `source`, which materialization already filled with the stored id, so there
613
+ * is nothing to correct and rewriting `_attachmentId` alone would leave the two
614
+ * halves disagreeing.
615
+ */
616
+ function restampInlineAttachmentId(
617
+ block: ContentBlock,
618
+ renames: ReadonlyMap<string, string>,
619
+ ): ContentBlock {
620
+ if (block.type !== "image" && block.type !== "file") {
621
+ return block;
622
+ }
623
+ if (block.source.type === "workspace_ref") {
624
+ return block;
625
+ }
626
+ const renamed =
627
+ block._attachmentId === undefined
628
+ ? undefined
629
+ : renames.get(block._attachmentId);
630
+ if (renamed === undefined) {
631
+ return block;
632
+ }
633
+ return { ...block, ...attachmentIdFragment(renamed) };
634
+ }
635
+
636
+ /**
637
+ * Rewrite the live message's camera-frame blocks to the ids their rows were
638
+ * stored under.
639
+ *
640
+ * The in-memory message is built from the attachments the caller handed in, so
641
+ * its blocks name the ids the caller held while the tag names what
642
+ * materialization stored. Those differ whenever conversation scoping cloned a
643
+ * row, and retention walking the live history would then find no frame to age:
644
+ * the tag matches nothing until a reload rebuilds the blocks from the persisted
645
+ * content. Restamping is what makes the array a turn actually sends agree with
646
+ * the tag, so a cloned frame is bounded on the turn that created it rather than
647
+ * only after a restart.
648
+ *
649
+ * Matched by id, never by position: `attachmentsToContentBlocks` emits nothing
650
+ * for an attachment it cannot turn into a block, so positions do not line up.
651
+ */
652
+ function restampInlineAttachmentIds(
653
+ message: Message,
654
+ renames: ReadonlyMap<string, string>,
655
+ ): Message {
656
+ if (renames.size === 0) {
657
+ return message;
658
+ }
659
+ return {
660
+ ...message,
661
+ content: message.content.map((block) =>
662
+ restampInlineAttachmentId(block, renames),
663
+ ),
664
+ };
464
665
  }
465
666
 
466
667
  function extractTurnChannelContext(
@@ -736,6 +937,30 @@ export interface PersistMessageOptions {
736
937
  * what a consumer that must not misattribute a turn to a surface needs.
737
938
  */
738
939
  requestClientOs?: string;
940
+ /**
941
+ * Which of `attachments`, by the id the caller holds, arrived as ambient
942
+ * camera frames rather than files the user picked. Stamps
943
+ * `SIGHT_FRAME_ATTACHMENT_IDS_KEY` on the persisted row, which is what the
944
+ * retention pass reads to decide which images a later turn still sends in
945
+ * full and which become timestamped stubs.
946
+ *
947
+ * Named through this option rather than written into `metadata` by the
948
+ * caller because only the persist knows the id the row ends up linked to.
949
+ * A pre-uploaded attachment already linked to another conversation is
950
+ * CLONED into this one under a fresh id, and both the persisted content
951
+ * block and the `message_attachments` row carry the clone. A tag composed
952
+ * ahead of that names a row the retention pass can never match, and the
953
+ * frame then rides every later request forever.
954
+ *
955
+ * The tag names whatever id the persisted block is attributable by: the
956
+ * linked row's id for a materialized attachment, and the caller's own id
957
+ * for one a recoverable store failure left as inline base64, which is the
958
+ * id `attachmentsToContentBlocks` stamps on that block. The inline case is
959
+ * the one that matters most, because such a block carries its full bytes in
960
+ * `messages.content`, so the frame heaviest in every later request is
961
+ * exactly the one retention has to be able to stub.
962
+ */
963
+ sightFrameAttachmentIds?: readonly string[];
739
964
  }
740
965
 
741
966
  // ── persistUserMessage ───────────────────────────────────────────────
@@ -857,6 +1082,13 @@ export async function persistQueuedMessageBody(
857
1082
  }),
858
1083
  );
859
1084
  let pushedToHistory = false;
1085
+ // Hoisted so the failure path can tell which rows this attempt made, and
1086
+ // whether its message ever landed. From the insert onward the persisted
1087
+ // content references those rows while no link yet protects them, which is
1088
+ // exactly the shape a link-aware delete reads as collectible, so the cleanup
1089
+ // must stop at the insert.
1090
+ let preparedAttachments: PreparedUserAttachment[] = [];
1091
+ let messageInserted = false;
860
1092
 
861
1093
  try {
862
1094
  const turnCtx =
@@ -1009,7 +1241,7 @@ export async function persistQueuedMessageBody(
1009
1241
  // once the message id exists.
1010
1242
  const conversationCreatedAt =
1011
1243
  getConversation(ctx.conversationId)?.createdAt ?? Date.now();
1012
- const preparedAttachments = await prepareUserAttachmentReferences(
1244
+ preparedAttachments = await prepareUserAttachmentReferences(
1013
1245
  ctx.conversationId,
1014
1246
  conversationCreatedAt,
1015
1247
  attachmentInputs,
@@ -1021,7 +1253,18 @@ export async function persistQueuedMessageBody(
1021
1253
  const sentAttachments = preparedAttachments.map(
1022
1254
  (p) => attachmentInputs[p.position],
1023
1255
  );
1024
- const cleanMessage = await createUserMessage(content, sentAttachments);
1256
+ const sightFrameEntries = requestedSightFrameEntries(
1257
+ options.sightFrameAttachmentIds,
1258
+ attachmentInputs,
1259
+ preparedAttachments,
1260
+ );
1261
+ const sightFrameRenames = sightFrameBlockRenames(sightFrameEntries);
1262
+ // The blocks are built from the ids the caller held, so a cloned frame is
1263
+ // pointed at its stored row before the message reaches the live history.
1264
+ const cleanMessage = restampInlineAttachmentIds(
1265
+ await createUserMessage(content, sentAttachments),
1266
+ sightFrameRenames,
1267
+ );
1025
1268
 
1026
1269
  // When displayContent is provided (e.g. original text before recording
1027
1270
  // intent stripping), persist that to DB so users see the full message
@@ -1032,17 +1275,26 @@ export async function persistQueuedMessageBody(
1032
1275
  displayContent,
1033
1276
  preparedAttachments.map((p) => p.block),
1034
1277
  );
1278
+ // Composed here rather than with the rest of the metadata above, because
1279
+ // materialization is what decides the id each frame is stored under.
1280
+ const sightFrameIds = sightFrameTagIds(sightFrameEntries);
1281
+ const metadataToPersist =
1282
+ sightFrameIds.length > 0
1283
+ ? { ...mergedMetadata, [SIGHT_FRAME_ATTACHMENT_IDS_KEY]: sightFrameIds }
1284
+ : mergedMetadata;
1285
+
1035
1286
  const persistedUserMessage = await addMessage(
1036
1287
  ctx.conversationId,
1037
1288
  "user",
1038
1289
  contentToPersist,
1039
1290
  {
1040
- metadata: mergedMetadata,
1291
+ metadata: metadataToPersist,
1041
1292
  clientMessageId,
1042
1293
  id: requestId,
1043
1294
  ...(skipIndexing ? { skipIndexing: true } : {}),
1044
1295
  },
1045
1296
  );
1297
+ messageInserted = true;
1046
1298
 
1047
1299
  if (persistedUserMessage.deduplicated) {
1048
1300
  return { id: persistedUserMessage.id, deduplicated: true };
@@ -1106,7 +1358,13 @@ export async function persistQueuedMessageBody(
1106
1358
  );
1107
1359
  if (inline) {
1108
1360
  repairedBlocks ??= preparedAttachments.map((pp) => pp.block);
1109
- repairedBlocks[idx] = inline;
1361
+ // Rebuilt from the caller's attachment, so it names the id the
1362
+ // caller held while the tag written above names the stored row.
1363
+ // Restamping keeps a repaired frame matchable by retention.
1364
+ repairedBlocks[idx] = restampInlineAttachmentId(
1365
+ inline,
1366
+ sightFrameRenames,
1367
+ );
1110
1368
  }
1111
1369
  }
1112
1370
  }
@@ -1169,6 +1427,11 @@ export async function persistQueuedMessageBody(
1169
1427
  if (pushedToHistory) {
1170
1428
  ctx.messages.pop();
1171
1429
  }
1430
+ if (!messageInserted) {
1431
+ discardAttemptAttachments(
1432
+ attemptCreatedAttachmentIds(attachmentInputs, preparedAttachments),
1433
+ );
1434
+ }
1172
1435
  throw err;
1173
1436
  }
1174
1437
  }
@@ -48,6 +48,7 @@ import { isGuardianCardRow } from "../notifications/approval-card-data.js";
48
48
  import {
49
49
  getMessages as defaultGetMessages,
50
50
  type MessageRow,
51
+ selectSightFrameCaptureTimes,
51
52
  } from "../persistence/conversation-crud.js";
52
53
  import { isBackgroundConversationType } from "../persistence/conversation-types.js";
53
54
  import { createContextSummaryMessage } from "../plugins/defaults/compaction/window-manager.js";
@@ -76,8 +77,14 @@ import { resolveCapabilities } from "../runtime/capabilities.js";
76
77
  import type { SubagentState } from "../subagent/types.js";
77
78
  import { TERMINAL_STATUSES } from "../subagent/types.js";
78
79
  import { canonicalizeInboundIdentity } from "../util/canonicalize-identity.js";
80
+ import { getLogger } from "../util/logger.js";
79
81
  import { channelSupportsInlineOptions } from "./channel-ui-capability.js";
80
82
  import { findConversationOrSubagent } from "./conversation-registry.js";
83
+ import {
84
+ hasAttributableImages,
85
+ resolveSightKeepLatestFrames,
86
+ stripAgedSightFrames,
87
+ } from "./conversation-sight-frames.js";
81
88
  import type { SurfaceShowPair } from "./conversation-surfaces.js";
82
89
  import { canonicalizeTimeZone, formatTurnTimestamp } from "./date-context.js";
83
90
  import type {} from "./message-protocol.js";
@@ -89,6 +96,8 @@ import { timeLatencySubSpan } from "./turn-latency-sub-spans.js";
89
96
  // loop can own it; re-exported here for this module's existing consumers.
90
97
  export { stripInjectionsForCompaction } from "../context/strip-injections.js";
91
98
 
99
+ const log = getLogger("conversation-runtime-assembly");
100
+
92
101
  /**
93
102
  * Describes the capabilities of the channel through which the user is
94
103
  * interacting. Used to gate UI-specific references and permission asks.
@@ -737,6 +746,64 @@ export function stripNowScratchpad(messages: Message[]): Message[] {
737
746
  return stripUserTextBlocksByPrefix(messages, NOW_SCRATCHPAD_STRIP_PREFIXES);
738
747
  }
739
748
 
749
+ // ---------------------------------------------------------------------------
750
+ // Camera-frame retention
751
+ // ---------------------------------------------------------------------------
752
+
753
+ /**
754
+ * Trim the conversation's ambient camera frames down to the newest
755
+ * `sight.keepLatestFrames`, replacing the rest with text stubs.
756
+ *
757
+ * A camera call samples a frame every few seconds, and history resends
758
+ * every inline image on every later request, so an hour-long call otherwise
759
+ * grows its own context until the provider rejects it. Trimming here, on the
760
+ * assembled copy the turn is about to send, keeps the stored transcript and its
761
+ * attachments intact.
762
+ *
763
+ * The tag that names the frames is per-row metadata, and the assembled
764
+ * `Message[]` omits row metadata, so the ids are read back from the rows and
765
+ * matched against the attachment id each image block carries: `workspace_ref`
766
+ * on a reloaded block, `_attachmentId` on the live copy a turn pushed. One
767
+ * uninterrupted call and a reloaded conversation therefore trim from the same
768
+ * pool. A conversation with no tagged rows returns the input array unchanged.
769
+ *
770
+ * Called twice over a turn that compacts: once before the run, and again on the
771
+ * rebuilt history the agent loop installs, since compaction reads the stored
772
+ * rows and can retain frames this pass had already stubbed.
773
+ *
774
+ * Best-effort: this trims cost, it does not decide what the turn means, so a
775
+ * failed read degrades to the untrimmed history rather than failing the turn.
776
+ */
777
+ export function applySightFrameRetention(
778
+ messages: Message[],
779
+ conversationId: string,
780
+ ): Message[] {
781
+ // A frame always traces back to an attachment row, so a history holding no
782
+ // attributable image can be answered without reading any rows. That is the
783
+ // overwhelming majority of turns, and this pass sits on the per-turn critical
784
+ // path.
785
+ if (!hasAttributableImages(messages)) {
786
+ return messages;
787
+ }
788
+ try {
789
+ const captureTimes = selectSightFrameCaptureTimes(conversationId);
790
+ if (captureTimes.size === 0) {
791
+ return messages;
792
+ }
793
+ return stripAgedSightFrames(
794
+ messages,
795
+ captureTimes,
796
+ resolveSightKeepLatestFrames(),
797
+ ).messages;
798
+ } catch (err) {
799
+ log.warn(
800
+ { conversationId, err },
801
+ "Camera-frame retention failed (non-fatal)",
802
+ );
803
+ return messages;
804
+ }
805
+ }
806
+
740
807
  /**
741
808
  * Build the `<channel_capabilities>` block text for the given capabilities, or
742
809
  * `null` on the happy path (desktop, full capabilities, no special context)
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Retention for ambient camera frames, the images a call samples on its own
3
+ * while the camera is up.
4
+ *
5
+ * Frames arrive every few seconds for as long as the camera is up, and history
6
+ * resends every inline image on every later request, so a long call grows its
7
+ * own context until the provider rejects it. {@link stripAgedSightFrames} keeps
8
+ * only the newest `sight.keepLatestFrames` as real images and replaces the rest
9
+ * with text stubs, on the copy of the history a turn is about to send.
10
+ *
11
+ * This is the proactive counterpart to `stripMediaPayloadsForRetry`
12
+ * (`conversation-media-retry.ts`), which fires only after a provider has
13
+ * already rejected a request and which governs every kind of media. The two
14
+ * live apart because that module reaches the compaction layer for its
15
+ * summary-message predicate and this one must stay a leaf the assembly path can
16
+ * import. They agree on the shape of what they leave behind: a `text` block
17
+ * naming the media that was dropped, so a frame stubbed here is plain text by
18
+ * the time the retry path walks the history and is neither counted as media nor
19
+ * stubbed a second time. Both describe that media through
20
+ * `mediaSourceDescriptor`, which is what holds the two wordings together
21
+ * without either module importing the other.
22
+ */
23
+
24
+ import { getConfig } from "../config/loader.js";
25
+ import {
26
+ KEEP_LATEST_SIGHT_FRAMES,
27
+ MAX_SIGHT_KEEP_LATEST_FRAMES,
28
+ MIN_SIGHT_KEEP_LATEST_FRAMES,
29
+ } from "../config/schemas/sight.js";
30
+ import { mediaSourceDescriptor } from "../providers/media-resolve.js";
31
+ import {
32
+ type ContentBlock,
33
+ mediaBlockAttachmentId,
34
+ type Message,
35
+ } from "../providers/types.js";
36
+
37
+ /**
38
+ * The workspace's `sight.keepLatestFrames`, clamped to
39
+ * [{@link MIN_SIGHT_KEEP_LATEST_FRAMES}, {@link MAX_SIGHT_KEEP_LATEST_FRAMES}].
40
+ *
41
+ * Clamped at read rather than rejected by the schema because an out-of-range
42
+ * value states an intent the guardrail can honor part of the way: capping it
43
+ * shows the most frames the budget allows, while failing validation would fall
44
+ * back to the default and show a number neither the config nor the guardrail
45
+ * asked for.
46
+ */
47
+ export function resolveSightKeepLatestFrames(): number {
48
+ const configured = getConfig().sight.keepLatestFrames;
49
+ if (!Number.isFinite(configured)) {
50
+ return KEEP_LATEST_SIGHT_FRAMES;
51
+ }
52
+ return Math.min(
53
+ MAX_SIGHT_KEEP_LATEST_FRAMES,
54
+ Math.max(MIN_SIGHT_KEEP_LATEST_FRAMES, Math.trunc(configured)),
55
+ );
56
+ }
57
+
58
+ /**
59
+ * True when any image in the history can be traced back to an attachment row.
60
+ *
61
+ * The retention pass reads rows to learn which attachments were camera frames;
62
+ * a history holding no attributable image has nothing those rows could match,
63
+ * so callers use this to skip the read entirely. Shares
64
+ * {@link mediaBlockAttachmentId} with the matcher so the two cannot disagree
65
+ * about what is skippable.
66
+ */
67
+ export function hasAttributableImages(messages: Message[]): boolean {
68
+ return messages.some((message) =>
69
+ message.content.some(
70
+ (block) =>
71
+ block.type === "image" && mediaBlockAttachmentId(block) !== undefined,
72
+ ),
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Replace every camera frame in `messages` except the newest
78
+ * `keepLatestFrames` with a text stub.
79
+ *
80
+ * `keepLatestFrames` defaults to {@link KEEP_LATEST_SIGHT_FRAMES}, the config
81
+ * default. The assembly path passes {@link resolveSightKeepLatestFrames}
82
+ * instead, so what a turn sends follows the workspace's own setting.
83
+ *
84
+ * `frameCaptureTimes` maps an attachment id the conversation tagged as a camera
85
+ * frame to when the row carrying it was written; an image block counts as a
86
+ * frame only when {@link mediaBlockAttachmentId} resolves it to one of those
87
+ * ids. Every other attachment (picked files, pasted images, shutter photos) is
88
+ * left alone here.
89
+ *
90
+ * Frames are ranked by capture time, not by where they sit in the history:
91
+ * compaction rebuilds the frames it keeps into one synthetic message in the
92
+ * order the model listed them, so position stops tracking recency the moment a
93
+ * conversation has been compacted. The times come from the rows themselves
94
+ * (`createdAt`, which `monotonicNow` keeps strictly increasing), so they order
95
+ * frames the way they were captured and no client-supplied timestamp can perturb
96
+ * that. Frames sharing a row share a time and fall back to history order, which
97
+ * is the order they were attached. Returns the input array itself when nothing
98
+ * is replaced.
99
+ */
100
+ export function stripAgedSightFrames(
101
+ messages: Message[],
102
+ frameCaptureTimes: ReadonlyMap<string, number>,
103
+ keepLatestFrames: number = KEEP_LATEST_SIGHT_FRAMES,
104
+ ): { messages: Message[]; modified: boolean; replacedBlocks: number } {
105
+ if (frameCaptureTimes.size === 0) {
106
+ return { messages, modified: false, replacedBlocks: 0 };
107
+ }
108
+
109
+ const frames: Array<{
110
+ messageIndex: number;
111
+ blockIndex: number;
112
+ capturedAt: number;
113
+ }> = [];
114
+ for (const [messageIndex, message] of messages.entries()) {
115
+ for (const [blockIndex, block] of message.content.entries()) {
116
+ if (block.type !== "image") {
117
+ continue;
118
+ }
119
+ const attachmentId = mediaBlockAttachmentId(block);
120
+ if (attachmentId === undefined) {
121
+ continue;
122
+ }
123
+ const capturedAt = frameCaptureTimes.get(attachmentId);
124
+ if (capturedAt === undefined) {
125
+ continue;
126
+ }
127
+ frames.push({ messageIndex, blockIndex, capturedAt });
128
+ }
129
+ }
130
+ if (frames.length <= keepLatestFrames) {
131
+ return { messages, modified: false, replacedBlocks: 0 };
132
+ }
133
+
134
+ // Oldest first by capture time, so dropping the tail keeps the newest. The
135
+ // sort is stable and `frames` was collected in history order, which is what
136
+ // separates frames that share a row's timestamp.
137
+ const byCaptureTime = [...frames].sort((a, b) => a.capturedAt - b.capturedAt);
138
+ const aged = new Map<number, Map<number, number>>();
139
+ for (const frame of byCaptureTime.slice(
140
+ 0,
141
+ byCaptureTime.length - keepLatestFrames,
142
+ )) {
143
+ const blocks = aged.get(frame.messageIndex) ?? new Map<number, number>();
144
+ blocks.set(frame.blockIndex, frame.capturedAt);
145
+ aged.set(frame.messageIndex, blocks);
146
+ }
147
+
148
+ let replacedBlocks = 0;
149
+ const nextMessages = messages.map((message, messageIndex) => {
150
+ const blocks = aged.get(messageIndex);
151
+ if (!blocks) {
152
+ return message;
153
+ }
154
+ return {
155
+ ...message,
156
+ content: message.content.map((block, blockIndex) => {
157
+ const capturedAt = blocks.get(blockIndex);
158
+ if (capturedAt === undefined || block.type !== "image") {
159
+ return block;
160
+ }
161
+ replacedBlocks += 1;
162
+ return sightFrameBlockToStub(block, capturedAt);
163
+ }),
164
+ };
165
+ });
166
+
167
+ return { messages: nextMessages, modified: true, replacedBlocks };
168
+ }
169
+
170
+ /**
171
+ * Stub for a frame that has aged out of the context. Describes the dropped
172
+ * media through `mediaSourceDescriptor`, the same way the retry path's stubs
173
+ * do, and adds the capture time, which is the only thing telling one ambient
174
+ * frame from another once the pixels are gone. A frame is always an image, so
175
+ * the extracted-text folding the retry path does for file blocks has nothing to
176
+ * act on here.
177
+ */
178
+ function sightFrameBlockToStub(
179
+ block: Extract<ContentBlock, { type: "image" }>,
180
+ capturedAt: number,
181
+ ): Extract<ContentBlock, { type: "text" }> {
182
+ const capturedAtIso = new Date(capturedAt).toISOString();
183
+ return {
184
+ type: "text",
185
+ text: `[Camera frame omitted from context: captured ${capturedAtIso}, ${mediaSourceDescriptor(block.source)}]`,
186
+ };
187
+ }