@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
@@ -1,11 +1,17 @@
1
1
  /**
2
- * Persisting a photo taken during a live-voice call.
2
+ * Persisting an image that arrives during a live-voice call, on its own.
3
3
  *
4
- * A photo lands in the conversation as its own user message, the moment the
5
- * shutter fires, and **runs no turn**. That single choice is what makes the
6
- * order of shutter and speech irrelevant: whatever the user says next, before
7
- * or after the snap, is answered by a model whose history already contains the
8
- * image.
4
+ * Two kinds arrive this way and both take the same route: a photo the user
5
+ * snapped with the shutter, and an ambient camera frame the client's gate kept.
6
+ * Each lands in the conversation as its own user message, the moment it
7
+ * arrives, and **runs no turn**. That single choice is what makes the order of
8
+ * image and speech irrelevant: whatever the user says next, before or after,
9
+ * is answered by a model whose history already contains the picture.
10
+ *
11
+ * A kept frame carries the sight tag as well, which is what lets retention age
12
+ * it out of the model's context while the transcript keeps it. A shutter photo
13
+ * carries no tag: the user chose to take it, so it is not the retention pass's
14
+ * to trim.
9
15
  *
10
16
  * The alternatives were both worse and both were tried. Attaching the photo to
11
17
  * the *next* spoken turn strands it whenever the user speaks first: the
@@ -19,17 +25,65 @@
19
25
  * photo can be stripped where one riding the spoken turn would have survived.
20
26
  * Stranding is the worse failure, and it happens every time rather than only
21
27
  * under overflow.
28
+ *
29
+ * Persists are serialized per conversation ({@link enqueueStandaloneImagePersist}),
30
+ * because the processing flag they take is a boolean rather than a counted
31
+ * lock. Two images in flight at once interleave across the awaits that sit
32
+ * between reading the flag and taking it, so both read idle and both take it.
33
+ * The first to finish then clears it while the second is still writing, which
34
+ * leaves a spoken turn free to launch into a half-written row, and the second
35
+ * finisher goes on to clear THAT turn's flag. Ambient frames make this
36
+ * routine rather than exotic: they arrive every few seconds for as long as
37
+ * the camera is up, and a shutter photo can land between any two of them. The
38
+ * chain holds the flag to one standalone job at a time, which is what makes
39
+ * the set/clear pairing correct rather than merely usually correct.
40
+ *
41
+ * The flag is taken by {@link acquireProcessingFlag}, which reads it free and
42
+ * takes it in one synchronous step. That closes the half of the turn race this
43
+ * module owns: no await sits between the read and the take, so a turn that
44
+ * starts nearby either loses the flag to a keep that already holds it or holds
45
+ * it before the keep looks, and a keep can never write over a turn that got
46
+ * there first. Per `assistant/AGENTS.md`, a resource more than one caller
47
+ * writes has to be serialised per resource; the chain does that between keeps
48
+ * and the atomic take does it against turns.
49
+ *
50
+ * The other half is not this module's: turn startup takes the flag without
51
+ * first asking whether anyone holds it (`persistUserMessage` in
52
+ * `conversation-messaging.ts`), so a turn beginning while a keep is mid-write
53
+ * still overwrites the keep's hold, and the keep's release then clears the
54
+ * turn's. Fixing that means changing how every turn in the daemon acquires,
55
+ * which belongs in `conversation.ts` rather than here.
56
+ *
57
+ * A persist that throws after its row landed is reported as the success it
58
+ * is, because the transcript has the image and the client's view has to match
59
+ * what a reload will show. The persist unwinds its own push before rethrowing,
60
+ * so the resident history is left not matching the rows; rather than reach
61
+ * into an array `persistQueuedMessageBody` owns, the recovery marks the
62
+ * conversation's history stale, and the next turn's
63
+ * `ensureActorScopedHistory` reloads from the DB and sees the frame.
64
+ *
65
+ * One thing is knowingly left behind: when the failure was a link write, the
66
+ * persisted content can reference an attachment row with no link. The row
67
+ * survives (nothing reclaims on a committed persist) and collection only ever
68
+ * considers ids a caller hands it, so the reference keeps resolving.
22
69
  */
23
70
 
24
71
  import { v7 as uuidv7 } from "uuid";
25
72
 
26
- import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js";
73
+ import {
74
+ type PersistMessageOptions,
75
+ persistQueuedMessageBody,
76
+ } from "../daemon/conversation-messaging.js";
77
+ import { findConversation } from "../daemon/conversation-registry.js";
27
78
  import { getOrCreateConversation } from "../daemon/conversation-store.js";
28
79
  import {
29
- getAttachmentsByIds,
30
- getSourcePathsForAttachments,
80
+ deleteOrphanAttachments,
81
+ resolveAttachmentsForPersist,
31
82
  } from "../persistence/attachments-store.js";
32
- import { recordConversationPersistedSeq } from "../persistence/conversation-crud.js";
83
+ import {
84
+ getMessageById,
85
+ recordConversationPersistedSeq,
86
+ } from "../persistence/conversation-crud.js";
33
87
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
34
88
  import { getCurrentSeq } from "../runtime/assistant-stream-state.js";
35
89
  import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
@@ -38,76 +92,252 @@ import { getLogger } from "../util/logger.js";
38
92
  const log = getLogger("live-voice-photo");
39
93
 
40
94
  /**
41
- * How long to wait for an in-flight turn before giving up on a photo.
95
+ * How long to wait for an in-flight turn before giving up on an image.
42
96
  *
43
97
  * Persisting takes the conversation's processing lock, which a running turn
44
98
  * holds for as long as it runs, tools included. The wait is generous because
45
99
  * the alternative is dropping a photo the user watched themselves take, and
46
- * the photo is not urgent: nothing is blocked on it except the next thing they
100
+ * the image is not urgent: nothing is blocked on it except the next thing they
47
101
  * say.
48
102
  */
49
103
  const PROCESSING_WAIT_MS = 30_000;
104
+
105
+ /**
106
+ * The wait actually applied. Mutable only so a test can reach the timeout
107
+ * without holding a conversation busy for the full half minute.
108
+ */
109
+ let processingWaitMs = PROCESSING_WAIT_MS;
110
+
111
+ /** Shorten the idle wait for a test. Returns the value it replaced. */
112
+ export function _setProcessingWaitMsForTests(ms: number): number {
113
+ const previous = processingWaitMs;
114
+ processingWaitMs = ms;
115
+ return previous;
116
+ }
50
117
  const PROCESSING_POLL_MS = 100;
51
118
 
52
119
  const PHOTO_MESSAGE_CONTENT = "here's a photo:";
53
120
 
121
+ /**
122
+ * Text the kept frame's row carries. Neutral rather than first person: nobody
123
+ * spoke this message, the camera simply had something worth showing.
124
+ */
125
+ const SIGHT_FRAME_MESSAGE_CONTENT = "(camera frame)";
126
+
54
127
  export interface LiveVoicePhotoResult {
55
128
  readonly ok: boolean;
56
129
  readonly messageId?: string;
57
130
  }
58
131
 
59
132
  /**
60
- * Hydrate attachment ids into the shape `persistQueuedMessageBody` stores.
61
- * Mirrors the HTTP send path's own `resolveAttachments`, so a photo taken
62
- * mid-call is stored exactly like one attached to a typed message.
133
+ * One conversation's chain of standalone-image persists.
134
+ *
135
+ * `queuedSightFrame` holds the keep that is chained but has not begun, and is
136
+ * what bounds the chain: keeps arrive every few seconds while each job can
137
+ * wait out a turn for {@link PROCESSING_WAIT_MS}, so without replacement the
138
+ * chain grows for as long as a turn runs. A job clears the slot as it starts,
139
+ * so the slot only ever refers to work that can still be called off.
140
+ */
141
+ interface StandaloneImageQueue {
142
+ tail: Promise<void>;
143
+ queuedSightFrame: { superseded: boolean; attachmentId: string } | null;
144
+ outstanding: number;
145
+ }
146
+
147
+ const standaloneImageQueues = new Map<string, StandaloneImageQueue>();
148
+
149
+ /**
150
+ * Run `job` after every standalone-image persist already queued for this
151
+ * conversation has settled.
152
+ *
153
+ * Keeps are latest-wins while they wait: a new one supersedes a queued keep
154
+ * that has not begun, which resolves `ok: false` so its caller reports the one
155
+ * lost frame. The camera is about to shoot another anyway, and what matters is
156
+ * the view at the moment the user speaks. Photos are never superseded: the
157
+ * user watched themselves take each one.
158
+ *
159
+ * The map entry is dropped once the chain drains, so a conversation that ends
160
+ * leaves nothing behind.
63
161
  */
64
- function resolvePhotoAttachments(attachmentIds: string[]) {
65
- const resolved = getAttachmentsByIds(attachmentIds, {
66
- hydrateFileData: true,
162
+ async function enqueueStandaloneImagePersist(
163
+ conversationId: string,
164
+ attachmentId: string,
165
+ kind: "photo" | "sight_frame",
166
+ job: () => Promise<LiveVoicePhotoResult>,
167
+ ): Promise<LiveVoicePhotoResult> {
168
+ let queue = standaloneImageQueues.get(conversationId);
169
+ if (!queue) {
170
+ queue = {
171
+ tail: Promise.resolve(),
172
+ queuedSightFrame: null,
173
+ outstanding: 0,
174
+ };
175
+ standaloneImageQueues.set(conversationId, queue);
176
+ }
177
+ const chained = queue;
178
+
179
+ const ticket = { superseded: false, attachmentId };
180
+ if (kind === "sight_frame") {
181
+ if (chained.queuedSightFrame) {
182
+ chained.queuedSightFrame.superseded = true;
183
+ }
184
+ chained.queuedSightFrame = ticket;
185
+ }
186
+
187
+ chained.outstanding += 1;
188
+ const running = chained.tail.then(() => {
189
+ // Started, so nothing can call it off from here on.
190
+ if (chained.queuedSightFrame === ticket) {
191
+ chained.queuedSightFrame = null;
192
+ }
193
+ if (ticket.superseded) {
194
+ log.debug(
195
+ { conversationId, attachmentId: ticket.attachmentId },
196
+ "A newer camera frame replaced one still waiting to persist",
197
+ );
198
+ reclaimDroppedFrame(ticket.attachmentId);
199
+ return { ok: false };
200
+ }
201
+ return job();
67
202
  });
68
- const sourcePaths = getSourcePathsForAttachments(attachmentIds);
69
- return resolved.map((a) => ({
70
- id: a.id,
71
- filename: a.originalFilename,
72
- mimeType: a.mimeType,
73
- data: a.dataBase64,
74
- ...(sourcePaths.has(a.id) ? { filePath: sourcePaths.get(a.id) } : {}),
75
- }));
203
+ // The tail must survive a failed job, or one rejection would strand every
204
+ // image queued behind it.
205
+ chained.tail = running.then(
206
+ () => undefined,
207
+ () => undefined,
208
+ );
209
+
210
+ try {
211
+ return await running;
212
+ } finally {
213
+ chained.outstanding -= 1;
214
+ if (
215
+ chained.outstanding === 0 &&
216
+ standaloneImageQueues.get(conversationId) === chained
217
+ ) {
218
+ standaloneImageQueues.delete(conversationId);
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Give up the upload behind a keep no message will ever carry: one a newer
225
+ * frame replaced before it started, one whose wait for an idle conversation
226
+ * ran out, and one whose write threw.
227
+ *
228
+ * The client uploaded it and then the daemon dropped it, so nothing else will
229
+ * ever collect it: the client's own abandon-delete fires only when the send
230
+ * fails, and attachment collection is candidate-driven with no sweep.
231
+ *
232
+ * Each caller owes the invariant in the first line. The supersede and timeout
233
+ * paths have it by construction, neither having reached the persist; the
234
+ * thrown path establishes it by checking that no row was inserted
235
+ * ({@link messageMayExist}), because a persist can fail after `addMessage`
236
+ * succeeded. {@link deleteOrphanAttachments} being link-aware is the second
237
+ * backstop rather than the only one: a failure between the insert and the link
238
+ * leaves a row that references the attachment with no link to protect it.
239
+ *
240
+ * The reclaim names the id the caller handed in, which is not always the id
241
+ * the row ended up referencing: conversation scoping clones an attachment
242
+ * already linked elsewhere, and the persisted block then names the clone. The
243
+ * insert check still guards the case that matters, because without a clone the
244
+ * two ids are the same one, and that is the dangling reference this protects.
245
+ *
246
+ * Keeps only. A photo is a deliberate upload the user watched themselves make,
247
+ * and one that failed to persist is theirs to retry, not the daemon's to
248
+ * delete.
249
+ *
250
+ * Best effort. Losing a row's bytes is not worth failing a call over.
251
+ */
252
+ function reclaimDroppedFrame(attachmentId: string): void {
253
+ try {
254
+ deleteOrphanAttachments([attachmentId]);
255
+ } catch (err) {
256
+ log.warn(
257
+ { err, attachmentId },
258
+ "Could not reclaim a dropped live-voice camera frame",
259
+ );
260
+ }
261
+ }
262
+
263
+ /** Conversations with a standalone-image persist still in flight. */
264
+ export function _standaloneImageQueueSizeForTests(): number {
265
+ return standaloneImageQueues.size;
76
266
  }
77
267
 
78
- /** Resolve once the conversation is not mid-turn, or false on timeout. */
79
- async function waitForIdle(conversation: {
268
+ /**
269
+ * Take the conversation's processing flag once it is free, or return false on
270
+ * timeout having taken nothing.
271
+ *
272
+ * The read and the take are one synchronous step on purpose. Run-to-completion
273
+ * means nothing can interleave between them, so a turn cannot claim the flag
274
+ * in the instant after this reads it free. Splitting them, with the read here
275
+ * and the take in the caller, leaves an await between the two and a keep every
276
+ * few seconds to land in it.
277
+ *
278
+ * Throws only if `setProcessing` does, which it does when the flag's persist
279
+ * fails. That reverts the in-memory flag before rethrowing, so a throw here
280
+ * means nothing was taken and nothing needs releasing.
281
+ */
282
+ async function acquireProcessingFlag(conversation: {
80
283
  isProcessing: () => boolean;
284
+ setProcessing: (value: boolean) => void;
81
285
  }): Promise<boolean> {
82
- const deadline = Date.now() + PROCESSING_WAIT_MS;
83
- while (conversation.isProcessing()) {
286
+ const deadline = Date.now() + processingWaitMs;
287
+ for (;;) {
288
+ if (!conversation.isProcessing()) {
289
+ conversation.setProcessing(true);
290
+ return true;
291
+ }
84
292
  if (Date.now() >= deadline) {
85
293
  return false;
86
294
  }
87
295
  await new Promise((resolve) => setTimeout(resolve, PROCESSING_POLL_MS));
88
296
  }
89
- return true;
90
297
  }
91
298
 
92
299
  /**
93
- * Persist a photo into the conversation as a user message, running no turn.
300
+ * Persist one image into the conversation as a user message, running no turn.
94
301
  *
95
302
  * Takes and releases the processing lock the way the built-in slash commands
96
303
  * do: they are the existing precedent for "persist a user message and answer
97
304
  * without the agent loop", and the lock is what keeps this write from
98
- * interleaving with a turn's own persist.
305
+ * interleaving with a turn's own persist. An image that arrives mid-reply
306
+ * therefore lands after that reply's rows rather than between them.
99
307
  *
100
- * Never throws. A photo that cannot be stored must not take the call down with
101
- * it, and the caller reports the failure to the user instead.
308
+ * Queued behind this conversation's other standalone images, so only one of
309
+ * them ever holds the flag. Never throws. An image that cannot be stored must
310
+ * not take the call down with it, and the caller reports the failure to the
311
+ * user instead.
102
312
  */
103
- export async function persistLiveVoicePhoto(
313
+ function persistStandaloneImage(
314
+ conversationId: string,
315
+ attachmentId: string,
316
+ kind: "photo" | "sight_frame",
317
+ persistOptions: Omit<PersistMessageOptions, "attachments" | "requestId">,
318
+ ): Promise<LiveVoicePhotoResult> {
319
+ return enqueueStandaloneImagePersist(conversationId, attachmentId, kind, () =>
320
+ writeStandaloneImage(conversationId, attachmentId, kind, persistOptions),
321
+ );
322
+ }
323
+
324
+ async function writeStandaloneImage(
104
325
  conversationId: string,
105
326
  attachmentId: string,
327
+ kind: "photo" | "sight_frame",
328
+ persistOptions: Omit<PersistMessageOptions, "attachments" | "requestId">,
106
329
  ): Promise<LiveVoicePhotoResult> {
330
+ const { content } = persistOptions;
331
+ // The id the row is inserted under, so a failure can ask whether the insert
332
+ // landed before deciding the frame is safe to reclaim.
333
+ const requestId = uuidv7();
107
334
  try {
108
- const attachments = resolvePhotoAttachments([attachmentId]);
335
+ const attachments = resolveAttachmentsForPersist([attachmentId]);
109
336
  if (attachments.length === 0) {
110
- log.warn({ attachmentId }, "Live-voice photo attachment did not resolve");
337
+ log.warn(
338
+ { attachmentId, kind },
339
+ "Live-voice image attachment did not resolve",
340
+ );
111
341
  return { ok: false };
112
342
  }
113
343
 
@@ -116,53 +346,178 @@ export async function persistLiveVoicePhoto(
116
346
  // A turn holds the lock for its whole run. Waiting rather than queueing:
117
347
  // the conversation's queue drains into a turn, which is the one thing this
118
348
  // must not cause.
119
- if (!(await waitForIdle(conversation))) {
349
+ if (!(await acquireProcessingFlag(conversation))) {
120
350
  log.warn(
121
- { conversationId, attachmentId },
122
- "Live-voice photo timed out waiting for the conversation to go idle",
351
+ { conversationId, attachmentId, kind },
352
+ "Live-voice image timed out waiting for the conversation to go idle",
123
353
  );
354
+ if (kind === "sight_frame") {
355
+ reclaimDroppedFrame(attachmentId);
356
+ }
124
357
  return { ok: false };
125
358
  }
126
359
 
127
- conversation.setProcessing(true);
128
360
  try {
129
361
  const persisted = await persistQueuedMessageBody(conversation, {
130
- content: PHOTO_MESSAGE_CONTENT,
362
+ ...persistOptions,
131
363
  attachments,
132
- requestId: uuidv7(),
133
- metadata: {
134
- // Marks the row as something the user did on a call rather than
135
- // typed, the same way a voice turn's own user message is marked.
136
- voiceSessionTurn: true,
137
- livePhoto: true,
138
- },
364
+ requestId,
139
365
  });
140
366
 
141
- // The row exists but no turn will announce it, so the clients that
142
- // render this conversation have to be told directly, or the photo does
143
- // not appear until something else forces a refetch.
144
- broadcastMessage({
145
- type: "user_message_echo",
146
- text: PHOTO_MESSAGE_CONTENT,
147
- conversationId,
148
- messageId: persisted.id,
149
- });
150
- recordConversationPersistedSeq(conversationId, getCurrentSeq());
151
- publishConversationMessagesChanged(conversationId);
367
+ announcePersistedImage(conversationId, content, persisted.id);
152
368
 
153
369
  return { ok: true, messageId: persisted.id };
154
370
  } finally {
155
371
  conversation.setProcessing(false);
156
372
  // Anything queued behind the lock we just held still has to run. Without
157
- // this a message queued during the photo's write sits until the next
373
+ // this a message queued during the image's write sits until the next
158
374
  // turn ends.
159
- void conversation.kickDrainQueue("loop_complete", "live_voice_photo");
375
+ void conversation.kickDrainQueue("loop_complete", `live_voice_${kind}`);
160
376
  }
161
377
  } catch (err) {
162
378
  log.warn(
163
- { err, conversationId, attachmentId },
164
- "Failed to persist a live-voice photo",
379
+ { err, conversationId, attachmentId, kind },
380
+ "Failed to persist a live-voice image",
165
381
  );
382
+ const inserted = insertedMessageState(conversationId, requestId);
383
+ if (inserted === "exists") {
384
+ // The row is in the transcript, so the result has to say so whatever
385
+ // went wrong afterwards. Reporting failure here loses the frame twice
386
+ // over: the client retracts what it showed and stops treating it as
387
+ // pending, and the row it was told never landed appears on the next
388
+ // reload.
389
+ //
390
+ // The persist unwound its own push before rethrowing, so the resident
391
+ // history no longer matches the rows. Same situation a channel edit or
392
+ // reaction leaves behind, and the same fix: the next turn's
393
+ // `ensureActorScopedHistory` reloads instead of reusing what it holds.
394
+ findConversation(conversationId)?.markHistoryStale();
395
+ try {
396
+ announcePersistedImage(conversationId, content, requestId);
397
+ } catch (announceErr) {
398
+ log.warn(
399
+ { err: announceErr, conversationId, messageId: requestId },
400
+ "Persisted a live-voice image but could not announce it",
401
+ );
402
+ }
403
+ return { ok: true, messageId: requestId };
404
+ }
405
+ if (kind === "sight_frame" && inserted === "absent") {
406
+ reclaimDroppedFrame(attachmentId);
407
+ }
166
408
  return { ok: false };
167
409
  }
168
410
  }
411
+
412
+ /**
413
+ * Tell the clients rendering this conversation that a row landed.
414
+ *
415
+ * No turn will announce it, so without this the image does not appear until
416
+ * something else forces a refetch.
417
+ */
418
+ function announcePersistedImage(
419
+ conversationId: string,
420
+ text: string,
421
+ messageId: string,
422
+ ): void {
423
+ broadcastMessage({
424
+ type: "user_message_echo",
425
+ text,
426
+ conversationId,
427
+ messageId,
428
+ });
429
+ recordConversationPersistedSeq(conversationId, getCurrentSeq());
430
+ publishConversationMessagesChanged(conversationId);
431
+ }
432
+
433
+ /**
434
+ * Whether the persist's own row is in the conversation.
435
+ *
436
+ * A persist can fail well after `addMessage` succeeded: a link write that
437
+ * fails is repaired by rewriting the content, and that rewrite can throw in
438
+ * turn.
439
+ *
440
+ * Three-valued because its two readers need opposite answers under doubt, and
441
+ * a boolean can only serve one of them. Reclaiming an attachment on a guess
442
+ * deletes bytes a message may reference; reporting success on a guess tells
443
+ * the client a frame landed when it may not have, and the client stops showing
444
+ * it as pending and stops retrying on the strength of that word. Neither is
445
+ * recoverable, so `unknown` refuses both: never act on a fact that could not
446
+ * be read.
447
+ *
448
+ * A row found under this id is provably this persist's own. The deduplicating
449
+ * branch of `addMessage` fires only for a `clientMessageId`, which no image
450
+ * here sets, and it returns rather than throws, so it cannot leave a half
451
+ * finished persist behind an id that resolves to someone else's row.
452
+ */
453
+ function insertedMessageState(
454
+ conversationId: string,
455
+ messageId: string,
456
+ ): "exists" | "absent" | "unknown" {
457
+ try {
458
+ return getMessageById(messageId, conversationId) !== null
459
+ ? "exists"
460
+ : "absent";
461
+ } catch (err) {
462
+ log.warn(
463
+ { err, conversationId, messageId },
464
+ "Could not tell whether a live-voice image persisted; keeping its attachment and reporting failure",
465
+ );
466
+ return "unknown";
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Persist a photo the user took mid-call.
472
+ *
473
+ * Carries `livePhoto`, which is what the client reads to show the snap on its
474
+ * receipt strip, and no sight tag: a deliberate photo is not retention's to
475
+ * age out.
476
+ */
477
+ export async function persistLiveVoicePhoto(
478
+ conversationId: string,
479
+ attachmentId: string,
480
+ ): Promise<LiveVoicePhotoResult> {
481
+ return persistStandaloneImage(conversationId, attachmentId, "photo", {
482
+ content: PHOTO_MESSAGE_CONTENT,
483
+ metadata: {
484
+ // Marks the row as something the user did on a call rather than
485
+ // typed, the same way a voice turn's own user message is marked.
486
+ voiceSessionTurn: true,
487
+ livePhoto: true,
488
+ },
489
+ });
490
+ }
491
+
492
+ /**
493
+ * Persist an ambient camera frame the client's gate kept.
494
+ *
495
+ * The sight tag names the attachment on the row that carries it, because an
496
+ * attachment holds no metadata of its own. Retention reads the tag to decide
497
+ * which images a turn still sends in full and which become timestamped stubs,
498
+ * so an untagged frame would sit in every later request forever. The persist
499
+ * stamps it, from the id materialization ended up storing rather than the one
500
+ * asked for here.
501
+ *
502
+ * `scripted` because the camera's gate sent this, not the user: a keep every
503
+ * few seconds would otherwise read downstream as that many turns the user
504
+ * took, and activation counts turns that claim they were typed.
505
+ */
506
+ export async function persistLiveVoiceSightFrame(
507
+ conversationId: string,
508
+ attachmentId: string,
509
+ ): Promise<LiveVoicePhotoResult> {
510
+ return persistStandaloneImage(conversationId, attachmentId, "sight_frame", {
511
+ content: SIGHT_FRAME_MESSAGE_CONTENT,
512
+ metadata: { voiceSessionTurn: true },
513
+ scripted: true,
514
+ // The camera sampled this, nobody sent it. Indexing it would feed
515
+ // extraction a frame every few seconds of whatever the room happens to
516
+ // contain, and commit those visuals to long-term memory with no consent
517
+ // surface: the design puts keeps in the TRANSCRIPT, which the user can see
518
+ // and delete, and says nothing about memory. The text half is worthless to
519
+ // search anyway, every row reading "(camera frame)".
520
+ skipIndexing: true,
521
+ sightFrameAttachmentIds: [attachmentId],
522
+ });
523
+ }