@vellumai/assistant 0.11.8 → 0.11.9-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 (318) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/Dockerfile +8 -48
  3. package/docker-entrypoint.sh +5 -1
  4. package/docker-kata-apt-env.sh +3 -0
  5. package/docker-kata-apt-shims.sh +127 -0
  6. package/docker-kata-apt-wrapper.sh +45 -0
  7. package/docker-kata-chroot-exec.sh +35 -0
  8. package/docker-kata-pip.sh +8 -2
  9. package/docs/architecture/memory.md +8 -4
  10. package/docs/guardian-request-flow.md +18 -14
  11. package/docs/trusted-contact-access.md +1 -7
  12. package/node_modules/@vellumai/avatar-catalog/src/catalog.ts +7 -1
  13. package/node_modules/@vellumai/avatar-catalog/src/index.ts +1 -1
  14. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  15. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -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/platform-credential.ts +41 -0
  18. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  19. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  20. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  21. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  22. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  23. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  24. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
  25. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +110 -5
  26. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +5 -33
  27. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +3 -0
  28. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +100 -9
  29. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -2
  30. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +9 -0
  31. package/node_modules/@vellumai/service-contracts/package.json +4 -1
  32. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  33. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  34. package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  35. package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  36. package/openapi.yaml +193 -4
  37. package/package.json +2 -2
  38. package/src/__tests__/access-request-card-view.test.ts +6 -5
  39. package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
  40. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +43 -82
  41. package/src/__tests__/always-loaded-tools-guard.test.ts +8 -2
  42. package/src/__tests__/attachment-stored-path-annotation.test.ts +80 -0
  43. package/src/__tests__/attachments-store.test.ts +27 -0
  44. package/src/__tests__/attachments.test.ts +43 -0
  45. package/src/__tests__/canned-reply-release.test.ts +48 -5
  46. package/src/__tests__/channel-delivery-store.test.ts +129 -18
  47. package/src/__tests__/channel-reply-delivery.test.ts +553 -93
  48. package/src/__tests__/channel-retry-sweep.test.ts +199 -0
  49. package/src/__tests__/client-os-metadata-persistence.test.ts +32 -3
  50. package/src/__tests__/conversation-error.test.ts +15 -0
  51. package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
  52. package/src/__tests__/conversation-pairing.test.ts +141 -1
  53. package/src/__tests__/conversation-routes-disk-view.test.ts +28 -1
  54. package/src/__tests__/conversation-routes-slash-commands.test.ts +125 -0
  55. package/src/__tests__/conversation-runtime-assembly.test.ts +79 -0
  56. package/src/__tests__/conversation-store-ephemeral.test.ts +323 -10
  57. package/src/__tests__/conversation-sync-tags.test.ts +2 -37
  58. package/src/__tests__/credential-health-service.test.ts +98 -10
  59. package/src/__tests__/delete-propagation.test.ts +469 -0
  60. package/src/__tests__/dm-persistence.test.ts +16 -0
  61. package/src/__tests__/docker-kata-apt-shims.test.ts +135 -0
  62. package/src/__tests__/forbidden-legacy-symbols.test.ts +12 -0
  63. package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
  64. package/src/__tests__/guardian-gateway-sim.ts +0 -23
  65. package/src/__tests__/guardian-question-mode.test.ts +108 -0
  66. package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
  67. package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
  68. package/src/__tests__/host-proxy-interface.test.ts +10 -0
  69. package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
  70. package/src/__tests__/injector-v3-suppression.test.ts +43 -9
  71. package/src/__tests__/list-messages-page-latest.test.ts +127 -0
  72. package/src/__tests__/list-messages-system-card.test.ts +103 -0
  73. package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
  74. package/src/__tests__/notification-decision-fallback.test.ts +199 -77
  75. package/src/__tests__/notification-decision-strategy.test.ts +198 -213
  76. package/src/__tests__/notification-discord-adapter.test.ts +25 -0
  77. package/src/__tests__/notification-slack-adapter.test.ts +133 -0
  78. package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
  79. package/src/__tests__/openai-provider.test.ts +5 -5
  80. package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
  81. package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
  82. package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
  83. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
  84. package/src/__tests__/process-message-display-content.test.ts +20 -9
  85. package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
  86. package/src/__tests__/provider-meta-persistence.test.ts +67 -0
  87. package/src/__tests__/reaction-persistence.test.ts +22 -198
  88. package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
  89. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
  90. package/src/__tests__/skill-load-tool.test.ts +27 -0
  91. package/src/__tests__/skills.test.ts +34 -1
  92. package/src/__tests__/strip-memory-injections.test.ts +3 -4
  93. package/src/__tests__/terminal-tools.test.ts +9 -0
  94. package/src/__tests__/thread-backfill.test.ts +5 -3
  95. package/src/__tests__/unified-turn-context-location.test.ts +76 -0
  96. package/src/__tests__/voice-session-bridge.test.ts +18 -0
  97. package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
  98. package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
  99. package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
  100. package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
  101. package/src/agent/attachments.ts +13 -2
  102. package/src/agent/loop.ts +3 -19
  103. package/src/api/README.md +9 -5
  104. package/src/api/index.ts +9 -8
  105. package/src/api/package.json +1 -0
  106. package/src/api/responses/conversation-message.ts +8 -0
  107. package/src/api/responses/home.ts +7 -18
  108. package/src/api/surfaces.ts +114 -7
  109. package/src/approvals/AGENTS.md +1 -1
  110. package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
  111. package/src/channels/__tests__/types.test.ts +22 -1
  112. package/src/channels/gateway-guardian-requests.ts +0 -22
  113. package/src/channels/types.ts +8 -6
  114. package/src/cli/__tests__/catalog-search-help.test.ts +12 -0
  115. package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
  116. package/src/cli/commands/channels/index.help.ts +15 -1
  117. package/src/cli/commands/channels/index.ts +6 -2
  118. package/src/cli/commands/db/__tests__/status.test.ts +22 -0
  119. package/src/cli/commands/db/index.help.ts +1 -1
  120. package/src/cli/commands/db/status.ts +172 -1
  121. package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
  122. package/src/cli/commands/platform/connect.ts +14 -5
  123. package/src/cli/commands/plugins.help.ts +6 -5
  124. package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
  125. package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
  126. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
  127. package/src/cli/lib/bundled-marketplace.json +14 -0
  128. package/src/cli/lib/install-from-github.ts +9 -5
  129. package/src/cli/lib/install-from-platform.ts +12 -1
  130. package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
  131. package/src/config/assistant-initiated-threads-gate.ts +50 -0
  132. package/src/config/bundled-skills/acp/SKILL.md +10 -3
  133. package/src/config/bundled-skills/schedule/SKILL.md +25 -11
  134. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
  135. package/src/config/call-site-defaults.ts +4 -1
  136. package/src/config/feature-flag-registry.json +21 -4
  137. package/src/config/schemas/memory-v3.ts +4 -3
  138. package/src/context/strip-injections.ts +17 -56
  139. package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
  140. package/src/conversations/message-consolidation.ts +4 -3
  141. package/src/credential-health/credential-health-service.ts +130 -0
  142. package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
  143. package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
  144. package/src/daemon/conversation-error.ts +2 -0
  145. package/src/daemon/conversation-messaging.ts +195 -47
  146. package/src/daemon/conversation-process.ts +20 -5
  147. package/src/daemon/conversation-runtime-assembly.ts +48 -39
  148. package/src/daemon/conversation-store.ts +159 -11
  149. package/src/daemon/conversation-tool-setup.ts +9 -2
  150. package/src/daemon/conversation.ts +290 -19
  151. package/src/daemon/dictation-text-processing.ts +2 -7
  152. package/src/daemon/handlers/config-channels.ts +33 -3
  153. package/src/daemon/handlers/shared.ts +9 -0
  154. package/src/daemon/port-oversized-content.test.ts +117 -0
  155. package/src/daemon/port-oversized-content.ts +110 -0
  156. package/src/daemon/process-message.ts +87 -16
  157. package/src/daemon/reaction-record.test.ts +23 -11
  158. package/src/daemon/reaction-record.ts +6 -9
  159. package/src/documents/document-store.ts +1 -5
  160. package/src/home/conversation-starter-validation.ts +1 -5
  161. package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
  162. package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
  163. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
  164. package/src/live-voice/live-voice-photo.ts +544 -61
  165. package/src/live-voice/live-voice-session.ts +6 -2
  166. package/src/live-voice/protocol.ts +20 -1
  167. package/src/messaging/provider-message-metadata.ts +40 -1
  168. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
  169. package/src/messaging/providers/channel-transport.ts +28 -0
  170. package/src/messaging/providers/discord/send.ts +3 -2
  171. package/src/messaging/providers/slack/api.ts +11 -1
  172. package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
  173. package/src/messaging/providers/slack/message-metadata.ts +141 -1
  174. package/src/messaging/providers/slack/send.test.ts +58 -0
  175. package/src/messaging/providers/slack/send.ts +4 -4
  176. package/src/messaging/providers/slack/transport.ts +28 -2
  177. package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
  178. package/src/messaging/providers/telegram-bot/send.ts +93 -0
  179. package/src/messaging/providers/telegram-bot/transport.ts +71 -0
  180. package/src/messaging/reaction-envelopes.test.ts +73 -0
  181. package/src/messaging/reaction-envelopes.ts +33 -5
  182. package/src/messaging/read-provider-metadata.ts +4 -3
  183. package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
  184. package/src/monitoring/recovery/db.ts +35 -0
  185. package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
  186. package/src/monitoring/recovery/run-recovery.ts +6 -2
  187. package/src/monitoring/recovery/stale-processing.ts +3 -20
  188. package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
  189. package/src/notifications/AGENTS.md +2 -2
  190. package/src/notifications/README.md +2 -2
  191. package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
  192. package/src/notifications/__tests__/broadcaster.test.ts +35 -4
  193. package/src/notifications/__tests__/notification-utils.test.ts +31 -0
  194. package/src/notifications/access-request-copy.ts +126 -114
  195. package/src/notifications/adapters/discord.ts +9 -5
  196. package/src/notifications/adapters/shared.ts +17 -4
  197. package/src/notifications/adapters/slack.ts +14 -4
  198. package/src/notifications/adapters/telegram.ts +8 -4
  199. package/src/notifications/approval-card-data.ts +5 -2
  200. package/src/notifications/assistant-reply-producer.ts +7 -7
  201. package/src/notifications/broadcaster.ts +57 -25
  202. package/src/notifications/conversation-pairing.ts +68 -2
  203. package/src/notifications/copy-composer.ts +13 -32
  204. package/src/notifications/decision-engine.ts +56 -237
  205. package/src/notifications/guardian-delivery-recorder.ts +5 -9
  206. package/src/notifications/guardian-feed-projection.ts +0 -15
  207. package/src/notifications/guardian-question-mode.ts +147 -6
  208. package/src/notifications/notification-utils.ts +117 -1
  209. package/src/permissions/confirmation-guardian-request.ts +2 -2
  210. package/src/permissions/prompter.ts +1 -1
  211. package/src/persistence/conversation-crud.ts +93 -14
  212. package/src/persistence/conversation-queries.ts +144 -17
  213. package/src/persistence/conversation-types.ts +39 -5
  214. package/src/persistence/delivery-crud.ts +224 -28
  215. package/src/persistence/delivery-status.ts +21 -2
  216. package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
  217. package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
  218. package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
  219. package/src/persistence/schema/conversations.ts +71 -2
  220. package/src/persistence/schema-contract.test.ts +78 -0
  221. package/src/persistence/schema-contract.ts +96 -0
  222. package/src/persistence/steps.ts +4 -0
  223. package/src/platform/client.ts +55 -0
  224. package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
  225. package/src/plugins/defaults/injector-order.ts +2 -1
  226. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
  227. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
  228. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
  229. package/src/plugins/defaults/memory/injectors.ts +1 -1
  230. package/src/plugins/defaults/memory/memory-marker.ts +17 -7
  231. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
  232. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
  233. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
  234. package/src/plugins/defaults/memory/v3/injector.ts +14 -15
  235. package/src/plugins/defaults/memory/v3/types.ts +15 -4
  236. package/src/plugins/defaults/turn-context/injectors.ts +3 -0
  237. package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
  238. package/src/plugins/mcp-servers.ts +14 -2
  239. package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
  240. package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
  241. package/src/providers/anthropic/client.ts +21 -24
  242. package/src/providers/call-site-routing.ts +5 -4
  243. package/src/providers/connection-resolution.ts +29 -1
  244. package/src/providers/content-block-size.test.ts +82 -0
  245. package/src/providers/content-block-size.ts +99 -0
  246. package/src/providers/file-block-text.test.ts +64 -0
  247. package/src/providers/file-block-text.ts +28 -0
  248. package/src/providers/gemini/client.ts +12 -7
  249. package/src/providers/inference/auth.ts +6 -6
  250. package/src/providers/media-resolve.ts +14 -0
  251. package/src/providers/model-catalog.ts +31 -34
  252. package/src/providers/openai/chat-completions-provider.ts +9 -25
  253. package/src/providers/openai/responses-provider.ts +11 -18
  254. package/src/providers/registry.ts +10 -7
  255. package/src/providers/routing-identity.ts +2 -1
  256. package/src/providers/types.ts +1 -4
  257. package/src/providers/vellum-model-routing.ts +3 -2
  258. package/src/runtime/AGENTS.md +40 -15
  259. package/src/runtime/approval-message-composer.ts +1 -6
  260. package/src/runtime/assistant-event-hub.ts +2 -39
  261. package/src/runtime/channel-approval-types.ts +1 -5
  262. package/src/runtime/channel-reply-delivery.ts +182 -115
  263. package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
  264. package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
  265. package/src/runtime/channel-retry-sweep.ts +102 -1
  266. package/src/runtime/finalize-event-delivery.ts +4 -4
  267. package/src/runtime/guardian-action-message-composer.ts +1 -4
  268. package/src/runtime/guardian-reply-router.ts +4 -75
  269. package/src/runtime/http-router.ts +1 -5
  270. package/src/runtime/question-request-guardian-bridge.ts +2 -3
  271. package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
  272. package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
  273. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
  274. package/src/runtime/routes/acp-routes.ts +1 -1
  275. package/src/runtime/routes/canned-reply-release.ts +19 -9
  276. package/src/runtime/routes/channel-route-shared.ts +0 -39
  277. package/src/runtime/routes/channel-verification-routes.ts +4 -1
  278. package/src/runtime/routes/conversation-list-routes.ts +44 -3
  279. package/src/runtime/routes/conversation-management-routes.ts +2 -1
  280. package/src/runtime/routes/conversation-routes.ts +138 -59
  281. package/src/runtime/routes/diagnostics-routes.ts +111 -60
  282. package/src/runtime/routes/guardian-action-routes.ts +10 -14
  283. package/src/runtime/routes/guardian-approval-interception.ts +0 -5
  284. package/src/runtime/routes/inbound-message-handler.ts +170 -48
  285. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
  286. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
  287. package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
  288. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
  289. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
  290. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
  291. package/src/runtime/routes/index.ts +2 -0
  292. package/src/runtime/routes/platform-routes.ts +99 -10
  293. package/src/runtime/routes/sight-frame-routes.ts +136 -0
  294. package/src/runtime/sync/resource-sync-events.ts +14 -37
  295. package/src/runtime/sync/sync-publisher.test.ts +5 -4
  296. package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
  297. package/src/tools/client-os.ts +11 -1
  298. package/src/tools/host-filesystem/edit.ts +2 -2
  299. package/src/tools/host-filesystem/read.ts +2 -2
  300. package/src/tools/host-filesystem/transfer.ts +2 -2
  301. package/src/tools/host-filesystem/write.ts +2 -2
  302. package/src/tools/host-terminal/host-shell.ts +2 -2
  303. package/src/tools/skills/load.ts +1 -1
  304. package/src/tools/terminal/safe-env.ts +4 -0
  305. package/src/tools/tool-input-schemas.ts +2 -0
  306. package/src/tools/tool-manifest.ts +2 -0
  307. package/src/tools/ui-surface/definitions.ts +4 -3
  308. package/src/tools/watch/watch-retro-report.ts +205 -0
  309. package/src/watch/__tests__/watch-retro.test.ts +268 -40
  310. package/src/watch/watch-retro.ts +190 -77
  311. package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
  312. package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
  313. package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
  314. package/src/workspace/migrations/registry.ts +4 -0
  315. package/docker-kata-pip-chroot.sh +0 -22
  316. package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
  317. package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
  318. package/src/api/events/conversation-list-invalidated.ts +0 -38
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Persisting an image that arrives during a live-voice call, on its own.
2
+ * Persisting a camera image on its own, outside any turn.
3
3
  *
4
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.
5
+ * snapped with the shutter on a call, and an ambient camera frame the client's
6
+ * gate kept, which arrives from a call and from the text-chat composer alike.
6
7
  * Each lands in the conversation as its own user message, the moment it
7
8
  * 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.
9
+ * image and message irrelevant: whatever the user says or types next, before
10
+ * or after, is answered by a model whose history already contains the picture.
10
11
  *
11
12
  * A kept frame carries the sight tag as well, which is what lets retention age
12
13
  * it out of the model's context while the transcript keeps it. A shutter photo
@@ -75,13 +76,19 @@ import {
75
76
  persistQueuedMessageBody,
76
77
  } from "../daemon/conversation-messaging.js";
77
78
  import { findConversation } from "../daemon/conversation-registry.js";
78
- import { getOrCreateConversation } from "../daemon/conversation-store.js";
79
+ import {
80
+ getConversationIfExists,
81
+ isSameIncarnation,
82
+ } from "../daemon/conversation-store.js";
83
+ import type { TrustContext } from "../daemon/trust-context-types.js";
79
84
  import {
80
85
  deleteOrphanAttachments,
81
86
  resolveAttachmentsForPersist,
82
87
  } from "../persistence/attachments-store.js";
83
88
  import {
89
+ getConversation,
84
90
  getMessageById,
91
+ MessageInsertPreconditionError,
85
92
  recordConversationPersistedSeq,
86
93
  } from "../persistence/conversation-crud.js";
87
94
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
@@ -140,7 +147,11 @@ export interface LiveVoicePhotoResult {
140
147
  */
141
148
  interface StandaloneImageQueue {
142
149
  tail: Promise<void>;
143
- queuedSightFrame: { superseded: boolean; attachmentId: string } | null;
150
+ queuedSightFrame: {
151
+ superseded: boolean;
152
+ attachmentId: string;
153
+ content: string;
154
+ } | null;
144
155
  outstanding: number;
145
156
  }
146
157
 
@@ -163,6 +174,7 @@ async function enqueueStandaloneImagePersist(
163
174
  conversationId: string,
164
175
  attachmentId: string,
165
176
  kind: "photo" | "sight_frame",
177
+ content: string,
166
178
  job: () => Promise<LiveVoicePhotoResult>,
167
179
  ): Promise<LiveVoicePhotoResult> {
168
180
  let queue = standaloneImageQueues.get(conversationId);
@@ -176,7 +188,7 @@ async function enqueueStandaloneImagePersist(
176
188
  }
177
189
  const chained = queue;
178
190
 
179
- const ticket = { superseded: false, attachmentId };
191
+ const ticket = { superseded: false, attachmentId, content };
180
192
  if (kind === "sight_frame") {
181
193
  if (chained.queuedSightFrame) {
182
194
  chained.queuedSightFrame.superseded = true;
@@ -195,7 +207,12 @@ async function enqueueStandaloneImagePersist(
195
207
  { conversationId, attachmentId: ticket.attachmentId },
196
208
  "A newer camera frame replaced one still waiting to persist",
197
209
  );
198
- reclaimDroppedFrame(ticket.attachmentId);
210
+ reclaimOrDefer(
211
+ conversationId,
212
+ [ticket.attachmentId],
213
+ ticket.content,
214
+ uuidv7(),
215
+ );
199
216
  return { ok: false };
200
217
  }
201
218
  return job();
@@ -232,10 +249,12 @@ async function enqueueStandaloneImagePersist(
232
249
  * Each caller owes the invariant in the first line. The supersede and timeout
233
250
  * paths have it by construction, neither having reached the persist; the
234
251
  * 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.
252
+ * ({@link insertedMessageState}), because a persist can fail after `addMessage`
253
+ * succeeded, and a path that cannot read that answer waits for it through
254
+ * {@link deferFrameReclaimDecision} rather than guessing.
255
+ * {@link deleteOrphanAttachments} being link-aware is the second backstop
256
+ * rather than the only one: a failure between the insert and the link leaves a
257
+ * row that references the attachment with no link to protect it.
239
258
  *
240
259
  * The reclaim names the id the caller handed in, which is not always the id
241
260
  * the row ended up referencing: conversation scoping clones an attachment
@@ -247,17 +266,41 @@ async function enqueueStandaloneImagePersist(
247
266
  * and one that failed to persist is theirs to retry, not the daemon's to
248
267
  * delete.
249
268
  *
250
- * Best effort. Losing a row's bytes is not worth failing a call over.
269
+ * Never throws, and reports whether the store answered. A delete can fail on
270
+ * the same contention the rest of this module waits out, and the caller owes
271
+ * the upload an outcome rather than a log line.
251
272
  */
252
- function reclaimDroppedFrame(attachmentId: string): void {
273
+ function reclaimDroppedFrame(attachmentIds: readonly string[]): boolean {
253
274
  try {
254
- deleteOrphanAttachments([attachmentId]);
275
+ deleteOrphanAttachments([...attachmentIds]);
276
+ return true;
255
277
  } catch (err) {
256
278
  log.warn(
257
- { err, attachmentId },
258
- "Could not reclaim a dropped live-voice camera frame",
279
+ { err, attachmentIds },
280
+ "Could not reclaim a dropped camera frame",
259
281
  );
282
+ return false;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Give the upload up, and hand it to the recheck when the store refuses.
288
+ *
289
+ * `messageId` is the id the row would carry, so a later pass asks the right
290
+ * question: a caller that attempted an insert passes the id it used, and one
291
+ * that never reached an insert passes a fresh id, which reads absent as soon as
292
+ * the store is readable and so retries exactly this delete.
293
+ */
294
+ function reclaimOrDefer(
295
+ conversationId: string,
296
+ attachmentIds: readonly string[],
297
+ content: string,
298
+ messageId: string,
299
+ ): void {
300
+ if (reclaimDroppedFrame(attachmentIds)) {
301
+ return;
260
302
  }
303
+ deferFrameReclaimDecision(conversationId, messageId, attachmentIds, content);
261
304
  }
262
305
 
263
306
  /** Conversations with a standalone-image persist still in flight. */
@@ -265,6 +308,200 @@ export function _standaloneImageQueueSizeForTests(): number {
265
308
  return standaloneImageQueues.size;
266
309
  }
267
310
 
311
+ /**
312
+ * How long to wait before asking the store again about a frame whose fate it
313
+ * could not report.
314
+ *
315
+ * The quick cadence covers the ordinary outage, a migration or a writer holding
316
+ * the database, which clears in seconds. One that outlasts those passes steps
317
+ * down to a cadence that can wait all day without filling the log. There is no
318
+ * final pass: giving the record up is the one outcome that loses the bytes for
319
+ * good, nothing else in the daemon collecting an attachment no caller names.
320
+ */
321
+ const RECLAIM_RECHECK_MS = 30_000;
322
+ const RECLAIM_RECHECK_SLOW_MS = 300_000;
323
+ /** Passes at the quick cadence before a record steps down to the slow one. */
324
+ const RECLAIM_RECHECK_QUICK_PASSES = 10;
325
+
326
+ interface PendingFrameReclaim {
327
+ conversationId: string;
328
+ messageId: string;
329
+ /**
330
+ * Every id this attempt is answerable for: the one the caller handed in, and
331
+ * any the persist materialized for itself. A pre-uploaded frame already
332
+ * linked to another conversation is cloned into this one under a fresh id,
333
+ * and the caller's id reclaims nothing for it, that row still being linked
334
+ * where it came from.
335
+ */
336
+ attachmentIds: readonly string[];
337
+ /** Row text, so a frame found to have landed can still be announced. */
338
+ content: string;
339
+ attempts: number;
340
+ }
341
+
342
+ /**
343
+ * Frames waiting on an answer, held for as long as this process lives.
344
+ *
345
+ * In memory on purpose: the record exists because the store could not be read,
346
+ * so writing it to that store is circular. A process that dies inside the
347
+ * outage therefore loses it, which leaves the upload in the same position as
348
+ * every other attachment nothing links, since the daemon has no orphan sweep.
349
+ */
350
+ const pendingFrameReclaims: PendingFrameReclaim[] = [];
351
+ let reclaimRecheckTimer: ReturnType<typeof setTimeout> | null = null;
352
+
353
+ /**
354
+ * Hold a frame whose outcome the store would not report, and settle it once
355
+ * the store answers.
356
+ *
357
+ * Both the refusal a caller sees and the upload behind it are this module's to
358
+ * settle: a refused frame is reported as handled, and nothing else in the
359
+ * daemon collects an attachment no caller names, collection being
360
+ * candidate-driven with no sweep. Reclaiming on the spot is the other wrong
361
+ * answer, because "could not read" is not "no row", and a row that landed can
362
+ * reference the bytes through content whose link write failed. So the question
363
+ * is asked again later and only an answer decides it.
364
+ *
365
+ * `messageId` is the id the row would carry. A path that never reached an
366
+ * insert passes the id it would have used, which reads absent the moment the
367
+ * store is readable, and readable is the whole of what that path is waiting
368
+ * for.
369
+ */
370
+ function deferFrameReclaimDecision(
371
+ conversationId: string,
372
+ messageId: string,
373
+ attachmentIds: readonly string[],
374
+ content: string,
375
+ ): void {
376
+ pendingFrameReclaims.push({
377
+ conversationId,
378
+ messageId,
379
+ attachmentIds,
380
+ content,
381
+ attempts: 0,
382
+ });
383
+ scheduleFrameReclaimRecheck();
384
+ }
385
+
386
+ /**
387
+ * The wait before the next pass: quick while any record is still in its early
388
+ * passes, slow once every one of them has outlasted those.
389
+ */
390
+ function nextFrameReclaimRecheckDelay(): number {
391
+ return pendingFrameReclaims.some(
392
+ (pending) => pending.attempts < RECLAIM_RECHECK_QUICK_PASSES,
393
+ )
394
+ ? RECLAIM_RECHECK_MS
395
+ : RECLAIM_RECHECK_SLOW_MS;
396
+ }
397
+
398
+ function scheduleFrameReclaimRecheck(): void {
399
+ if (reclaimRecheckTimer || pendingFrameReclaims.length === 0) {
400
+ return;
401
+ }
402
+ reclaimRecheckTimer = setTimeout(() => {
403
+ reclaimRecheckTimer = null;
404
+ drainFrameReclaimRechecks();
405
+ }, nextFrameReclaimRecheckDelay());
406
+ // A pending reclaim is never a reason to keep the process alive.
407
+ reclaimRecheckTimer.unref?.();
408
+ }
409
+
410
+ /**
411
+ * Deliver a row the client was told had not landed.
412
+ *
413
+ * The refusal it got is not destructive: it retracts a frame the client was
414
+ * showing as pending and does nothing else, so this arrives as an ordinary
415
+ * event for a message the client has never seen and the transcript converges
416
+ * without a reload. A row that reads as existing is proof its conversation is
417
+ * there too, the message being a foreign key into it, so what the announce
418
+ * writes has somewhere to go.
419
+ *
420
+ * The persist unwound its own history push before it threw, so the resident
421
+ * history no longer matches the rows and the next turn reloads rather than
422
+ * reusing what it holds.
423
+ */
424
+ function announceDeferredImage(pending: PendingFrameReclaim): void {
425
+ findConversation(pending.conversationId)?.markHistoryStale();
426
+ try {
427
+ announcePersistedImage(
428
+ pending.conversationId,
429
+ pending.content,
430
+ pending.messageId,
431
+ );
432
+ } catch (err) {
433
+ log.warn(
434
+ {
435
+ err,
436
+ conversationId: pending.conversationId,
437
+ messageId: pending.messageId,
438
+ },
439
+ "Persisted a standalone image but could not announce it",
440
+ );
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Ask the store again about every frame waiting on it.
446
+ *
447
+ * A row that exists means the frame reached the transcript, so its bytes are
448
+ * spoken for and the client is told about the row it was never given. Absent
449
+ * means the write never landed and the upload is this module's to give up. A
450
+ * store that still will not answer, and a delete that answers by failing, are
451
+ * the same situation here: ask again, for as long as that takes.
452
+ */
453
+ function drainFrameReclaimRechecks(): void {
454
+ for (const pending of pendingFrameReclaims.splice(0)) {
455
+ const inserted = insertedMessageState(
456
+ pending.conversationId,
457
+ pending.messageId,
458
+ );
459
+ if (inserted === "exists") {
460
+ announceDeferredImage(pending);
461
+ continue;
462
+ }
463
+ if (inserted === "absent" && reclaimDroppedFrame(pending.attachmentIds)) {
464
+ continue;
465
+ }
466
+ keepWaitingForStore(pending);
467
+ }
468
+ scheduleFrameReclaimRecheck();
469
+ }
470
+
471
+ /**
472
+ * Put a record back for another pass, counting the attempt so the cadence can
473
+ * step down. The record is never dropped: it is the only handle on an upload
474
+ * nothing else in the daemon would collect.
475
+ */
476
+ function keepWaitingForStore(pending: PendingFrameReclaim): void {
477
+ pending.attempts += 1;
478
+ if (pending.attempts === RECLAIM_RECHECK_QUICK_PASSES) {
479
+ log.warn(
480
+ {
481
+ conversationId: pending.conversationId,
482
+ attachmentIds: pending.attachmentIds,
483
+ },
484
+ "Still cannot settle a camera frame; asking less often until the store answers",
485
+ );
486
+ }
487
+ pendingFrameReclaims.push(pending);
488
+ }
489
+
490
+ /** Frames whose outcome the store has not reported yet. */
491
+ export function _pendingFrameReclaimCountForTests(): number {
492
+ return pendingFrameReclaims.length;
493
+ }
494
+
495
+ /** The wait the next recheck pass would use. */
496
+ export function _nextFrameReclaimRecheckDelayForTests(): number {
497
+ return nextFrameReclaimRecheckDelay();
498
+ }
499
+
500
+ /** Ask the store now rather than on the recheck timer. */
501
+ export function _drainFrameReclaimRechecksForTests(): void {
502
+ drainFrameReclaimRechecks();
503
+ }
504
+
268
505
  /**
269
506
  * Take the conversation's processing flag once it is free, or return false on
270
507
  * timeout having taken nothing.
@@ -280,17 +517,28 @@ export function _standaloneImageQueueSizeForTests(): number {
280
517
  * means nothing was taken and nothing needs releasing.
281
518
  */
282
519
  async function acquireProcessingFlag(conversation: {
283
- isProcessing: () => boolean;
284
- setProcessing: (value: boolean) => void;
285
- }): Promise<boolean> {
520
+ acquireProcessingFenced: () => Promise<number | null>;
521
+ }): Promise<number | null> {
286
522
  const deadline = Date.now() + processingWaitMs;
287
523
  for (;;) {
288
- if (!conversation.isProcessing()) {
289
- conversation.setProcessing(true);
290
- return true;
524
+ // Fenced, so a frame never writes into a turn no reader can see. A marker
525
+ // that refuses to persist gives the hold back and drops the frame; a
526
+ // conversation that belongs to someone else is waited out below.
527
+ let owner: number | null;
528
+ try {
529
+ owner = await conversation.acquireProcessingFenced();
530
+ } catch (err) {
531
+ log.warn(
532
+ { err },
533
+ "Standalone image gave up its hold: the processing marker would not persist",
534
+ );
535
+ return null;
536
+ }
537
+ if (owner !== null) {
538
+ return owner;
291
539
  }
292
540
  if (Date.now() >= deadline) {
293
- return false;
541
+ return null;
294
542
  }
295
543
  await new Promise((resolve) => setTimeout(resolve, PROCESSING_POLL_MS));
296
544
  }
@@ -309,76 +557,260 @@ async function acquireProcessingFlag(conversation: {
309
557
  * them ever holds the flag. Never throws. An image that cannot be stored must
310
558
  * not take the call down with it, and the caller reports the failure to the
311
559
  * user instead.
560
+ *
561
+ * Every later check answers for the one incarnation the image was accepted
562
+ * for, which is read before anything is queued. Reading it inside the job
563
+ * instead would read whatever holds the id by the time the job runs: the chain
564
+ * can hold a frame behind another image for as long as a turn runs, and a
565
+ * conversation deleted and recreated in that time would be captured as the
566
+ * incarnation the frame was taken in, leaving every check comparing the
567
+ * replacement against itself.
568
+ *
569
+ * `acceptedIncarnation` is that value read by the caller, for one whose own
570
+ * acceptance is earlier than this call: the HTTP door answers 404 from a row
571
+ * it reads before resolving the request's actor, so the row it accepted is the
572
+ * one it read there rather than whatever survives that resolution. A caller
573
+ * with nothing between its acceptance and this call omits it, and the read
574
+ * below is that same moment.
312
575
  */
313
576
  function persistStandaloneImage(
314
577
  conversationId: string,
315
578
  attachmentId: string,
316
579
  kind: "photo" | "sight_frame",
317
- persistOptions: Omit<PersistMessageOptions, "attachments" | "requestId">,
580
+ persistOptions: Omit<
581
+ PersistMessageOptions,
582
+ | "attachments"
583
+ | "requestId"
584
+ | "insertPrecondition"
585
+ | "onUndiscardedAttachments"
586
+ >,
587
+ acceptedIncarnation?: number,
318
588
  ): Promise<LiveVoicePhotoResult> {
319
- return enqueueStandaloneImagePersist(conversationId, attachmentId, kind, () =>
320
- writeStandaloneImage(conversationId, attachmentId, kind, persistOptions),
589
+ let incarnation: number | null;
590
+ try {
591
+ incarnation =
592
+ acceptedIncarnation ?? getConversation(conversationId)?.createdAt ?? null;
593
+ } catch (err) {
594
+ // This read is the one thing here that runs outside the job's own catch,
595
+ // and the contract above is that an image never takes its caller down: the
596
+ // socket attaches no handler for a rejection. Reported as the refusal it
597
+ // is, and the upload is held rather than deleted, because a store that
598
+ // cannot be read is not evidence the conversation is gone. Nothing was
599
+ // inserted, so the recheck reclaims as soon as the store answers at all.
600
+ log.warn(
601
+ { err, conversationId, attachmentId, kind },
602
+ "Could not read the conversation a standalone image names",
603
+ );
604
+ if (kind === "sight_frame") {
605
+ deferFrameReclaimDecision(
606
+ conversationId,
607
+ uuidv7(),
608
+ [attachmentId],
609
+ persistOptions.content,
610
+ );
611
+ }
612
+ return Promise.resolve({ ok: false });
613
+ }
614
+ if (incarnation === null) {
615
+ log.warn(
616
+ { conversationId, attachmentId, kind },
617
+ "Standalone image dropped: it names no conversation",
618
+ );
619
+ if (kind === "sight_frame") {
620
+ reclaimOrDefer(
621
+ conversationId,
622
+ [attachmentId],
623
+ persistOptions.content,
624
+ uuidv7(),
625
+ );
626
+ }
627
+ return Promise.resolve({ ok: false });
628
+ }
629
+ return enqueueStandaloneImagePersist(
630
+ conversationId,
631
+ attachmentId,
632
+ kind,
633
+ persistOptions.content,
634
+ () =>
635
+ writeStandaloneImage(
636
+ conversationId,
637
+ attachmentId,
638
+ kind,
639
+ incarnation,
640
+ persistOptions,
641
+ ),
321
642
  );
322
643
  }
323
644
 
645
+ /**
646
+ * Report and give up on an image whose conversation is no longer the
647
+ * incarnation it was accepted for.
648
+ *
649
+ * Keeps only, per {@link reclaimDroppedFrame}: a photo that failed to land is
650
+ * the user's to retry rather than the daemon's to delete.
651
+ */
652
+ function dropReplacedImage(
653
+ conversationId: string,
654
+ attachmentId: string,
655
+ kind: "photo" | "sight_frame",
656
+ content: string,
657
+ ): LiveVoicePhotoResult {
658
+ log.warn(
659
+ { conversationId, attachmentId, kind },
660
+ "Standalone image dropped: its conversation was replaced before the write",
661
+ );
662
+ if (kind === "sight_frame") {
663
+ reclaimOrDefer(conversationId, [attachmentId], content, uuidv7());
664
+ }
665
+ return { ok: false };
666
+ }
667
+
324
668
  async function writeStandaloneImage(
325
669
  conversationId: string,
326
670
  attachmentId: string,
327
671
  kind: "photo" | "sight_frame",
328
- persistOptions: Omit<PersistMessageOptions, "attachments" | "requestId">,
672
+ incarnation: number,
673
+ persistOptions: Omit<
674
+ PersistMessageOptions,
675
+ | "attachments"
676
+ | "requestId"
677
+ | "insertPrecondition"
678
+ | "onUndiscardedAttachments"
679
+ >,
329
680
  ): Promise<LiveVoicePhotoResult> {
330
681
  const { content } = persistOptions;
331
682
  // The id the row is inserted under, so a failure can ask whether the insert
332
683
  // landed before deciding the frame is safe to reclaim.
333
684
  const requestId = uuidv7();
685
+ // Ids the persist materialized for this attempt and then could not delete.
686
+ // A frame already linked elsewhere is cloned into this conversation under a
687
+ // fresh id, and nothing but the persist knows it: reclaiming under the id
688
+ // this module holds would leave the clone behind for good.
689
+ const strandedClones: string[] = [];
334
690
  try {
335
691
  const attachments = resolveAttachmentsForPersist([attachmentId]);
336
692
  if (attachments.length === 0) {
337
693
  log.warn(
338
694
  { attachmentId, kind },
339
- "Live-voice image attachment did not resolve",
695
+ "Standalone image attachment did not resolve",
340
696
  );
341
697
  return { ok: false };
342
698
  }
343
699
 
344
- const conversation = await getOrCreateConversation(conversationId);
700
+ // A queued job starts long after its caller checked, so the conversation
701
+ // can be gone by now. The acquire creates nothing, which is what keeps a
702
+ // delete final for everything already queued: the creating acquire would
703
+ // write the row back and bring the conversation home carrying nothing but
704
+ // camera frames. A delete that lands after the acquire is fenced by the
705
+ // messages foreign key instead, and the failure path below reclaims the
706
+ // upload once it reads that no row was inserted.
707
+ const conversation = await getConversationIfExists(conversationId);
708
+ if (!conversation) {
709
+ log.warn(
710
+ { conversationId, attachmentId, kind },
711
+ "Standalone image dropped: its conversation was deleted while it waited",
712
+ );
713
+ if (kind === "sight_frame") {
714
+ reclaimOrDefer(conversationId, [attachmentId], content, uuidv7());
715
+ }
716
+ return { ok: false };
717
+ }
718
+
719
+ // The acquire answers for the row it found, which for a job the chain held
720
+ // behind another image can be a conversation created under this id since.
721
+ // Asked before the idle wait so a replacement is answered now rather than
722
+ // after holding a stranger's lock for the length of a turn.
723
+ if (!isSameIncarnation(conversationId, incarnation)) {
724
+ return dropReplacedImage(conversationId, attachmentId, kind, content);
725
+ }
345
726
 
346
727
  // A turn holds the lock for its whole run. Waiting rather than queueing:
347
728
  // the conversation's queue drains into a turn, which is the one thing this
348
729
  // must not cause.
349
- if (!(await acquireProcessingFlag(conversation))) {
730
+ const owner = await acquireProcessingFlag(conversation);
731
+ if (owner === null) {
350
732
  log.warn(
351
733
  { conversationId, attachmentId, kind },
352
- "Live-voice image timed out waiting for the conversation to go idle",
734
+ "Standalone image timed out waiting for the conversation to go idle",
353
735
  );
354
736
  if (kind === "sight_frame") {
355
- reclaimDroppedFrame(attachmentId);
737
+ reclaimOrDefer(conversationId, [attachmentId], content, uuidv7());
356
738
  }
357
739
  return { ok: false };
358
740
  }
359
741
 
360
742
  try {
743
+ // The wait can outlast the conversation, and this job holds an instance
744
+ // rather than re-reading, so a delete alone would be caught only by the
745
+ // messages foreign key. A delete followed by a recreate under the same
746
+ // id restores that foreign key's target, and the row would land in a
747
+ // conversation created after the deletion. Both kinds check: a photo
748
+ // persisted into a stranger that inherited the name is the same wrong.
749
+ // The same question rides the persist below as its insert precondition,
750
+ // which is what covers a replacement landing while the write runs.
751
+ if (!isSameIncarnation(conversationId, incarnation)) {
752
+ return dropReplacedImage(conversationId, attachmentId, kind, content);
753
+ }
754
+
361
755
  const persisted = await persistQueuedMessageBody(conversation, {
362
756
  ...persistOptions,
363
757
  attachments,
364
758
  requestId,
759
+ // Asked again in the insert's own tick, about both things that can
760
+ // stop being true across the awaits the persist takes to materialize
761
+ // the attachment and build its content.
762
+ //
763
+ // A delete and recreate under the same id leaves a valid foreign-key
764
+ // target, so without the first term the frame joins a conversation it
765
+ // was never taken in. A Stop on this hold force-clears the flag, since
766
+ // no turn owns it, and the next request acquires, so without the
767
+ // second the frame writes under a dead claim alongside that turn. The
768
+ // release afterwards is refused either way, but a refused release
769
+ // cannot undo a row.
770
+ //
771
+ // Refusing is also the right reading of the Stop: the user asked the
772
+ // conversation to stop, and this frame is the camera's, not theirs.
773
+ insertPrecondition: () =>
774
+ isSameIncarnation(conversationId, incarnation) &&
775
+ conversation.holdsProcessingClaim(owner),
776
+ onUndiscardedAttachments: (ids) => {
777
+ strandedClones.push(...ids);
778
+ },
365
779
  });
366
780
 
781
+ // The row just joined the resident history, and this write ran outside
782
+ // any turn, so nothing scoped that history for the actor it names. A
783
+ // frame posted by one actor into a conversation resident under another
784
+ // would otherwise reach the model inside a scope a reload filters it out
785
+ // of.
786
+ conversation.markHistoryStaleForForeignScope(persistOptions.trustContext);
787
+
367
788
  announcePersistedImage(conversationId, content, persisted.id);
368
789
 
369
790
  return { ok: true, messageId: persisted.id };
370
791
  } finally {
371
- conversation.setProcessing(false);
372
- // Anything queued behind the lock we just held still has to run. Without
373
- // this a message queued during the image's write sits until the next
374
- // turn ends.
375
- void conversation.kickDrainQueue("loop_complete", `live_voice_${kind}`);
792
+ // Only this job's own hold is released. A turn that claimed the flag
793
+ // away mid-write owns it now, and clearing there would free a turn that
794
+ // is still running.
795
+ if (conversation.releaseProcessing(owner)) {
796
+ // Anything queued behind the lock we just held still has to run.
797
+ // Without this a message queued during the image's write sits until
798
+ // the next turn ends.
799
+ void conversation.kickDrainQueue("loop_complete", `standalone_${kind}`);
800
+ }
376
801
  }
377
802
  } catch (err) {
378
- log.warn(
379
- { err, conversationId, attachmentId, kind },
380
- "Failed to persist a live-voice image",
381
- );
803
+ if (err instanceof MessageInsertPreconditionError) {
804
+ log.warn(
805
+ { conversationId, attachmentId, kind },
806
+ "Standalone image dropped: its conversation was replaced during the write",
807
+ );
808
+ } else {
809
+ log.warn(
810
+ { err, conversationId, attachmentId, kind },
811
+ "Failed to persist a standalone image",
812
+ );
813
+ }
382
814
  const inserted = insertedMessageState(conversationId, requestId);
383
815
  if (inserted === "exists") {
384
816
  // The row is in the transcript, so the result has to say so whatever
@@ -397,13 +829,31 @@ async function writeStandaloneImage(
397
829
  } catch (announceErr) {
398
830
  log.warn(
399
831
  { err: announceErr, conversationId, messageId: requestId },
400
- "Persisted a live-voice image but could not announce it",
832
+ "Persisted a standalone image but could not announce it",
401
833
  );
402
834
  }
403
835
  return { ok: true, messageId: requestId };
404
836
  }
405
- if (kind === "sight_frame" && inserted === "absent") {
406
- reclaimDroppedFrame(attachmentId);
837
+ if (kind === "sight_frame") {
838
+ if (inserted === "absent") {
839
+ reclaimOrDefer(
840
+ conversationId,
841
+ [attachmentId, ...strandedClones],
842
+ content,
843
+ requestId,
844
+ );
845
+ } else {
846
+ // The store would not say whether the row landed. The refusal below
847
+ // reports the frame as handled, so the upload cannot simply be left,
848
+ // and a row that turns out to have landed was never announced. Both
849
+ // wait on the same answer.
850
+ deferFrameReclaimDecision(
851
+ conversationId,
852
+ requestId,
853
+ [attachmentId, ...strandedClones],
854
+ content,
855
+ );
856
+ }
407
857
  }
408
858
  return { ok: false };
409
859
  }
@@ -461,7 +911,7 @@ function insertedMessageState(
461
911
  } catch (err) {
462
912
  log.warn(
463
913
  { err, conversationId, messageId },
464
- "Could not tell whether a live-voice image persisted; keeping its attachment and reporting failure",
914
+ "Could not tell whether a standalone image persisted; keeping its attachment and reporting failure",
465
915
  );
466
916
  return "unknown";
467
917
  }
@@ -489,6 +939,9 @@ export async function persistLiveVoicePhoto(
489
939
  });
490
940
  }
491
941
 
942
+ /** Which client surface the camera's gate was running on. */
943
+ export type SightFrameSurface = "voice" | "chat";
944
+
492
945
  /**
493
946
  * Persist an ambient camera frame the client's gate kept.
494
947
  *
@@ -501,23 +954,53 @@ export async function persistLiveVoicePhoto(
501
954
  *
502
955
  * `scripted` because the camera's gate sent this, not the user: a keep every
503
956
  * few seconds would otherwise read downstream as that many turns the user
504
- * took, and activation counts turns that claim they were typed.
957
+ * took, and activation counts turns that claim they were typed. The pair of
958
+ * `scripted` and the tag is also the signature the memory-privacy guard
959
+ * (`messageMetadataIsAmbientSightKeep`) reads, so both surfaces stamp both.
960
+ *
961
+ * The surface decides one key and nothing else. `voiceSessionTurn` says a
962
+ * reply to this row is spoken back over a session that is still open, which is
963
+ * true of a keep taken on a call and false of one taken beside the composer,
964
+ * so only the voice caller stamps it.
965
+ *
966
+ * `trustContext` is the requester's own trust, for a caller that resolved one
967
+ * from the actor it verified. The row's provenance is stamped from it, so a
968
+ * conversation whose resting trust names an earlier actor cannot claim this
969
+ * frame. A caller that holds no per-request actor omits it and the persist
970
+ * attributes the row to the conversation, which is the right answer for a
971
+ * session that owns the conversation's trust for its whole life.
972
+ *
973
+ * `acceptedIncarnation` is the `created_at` of the conversation the caller
974
+ * accepted the frame for, for one that resolved that trust between reading the
975
+ * row and calling here: the frame belongs to the row the caller answered on,
976
+ * not to whatever holds the id once the resolution returns. A caller with no
977
+ * such gap omits it. See {@link persistStandaloneImage}.
505
978
  */
506
- export async function persistLiveVoiceSightFrame(
979
+ export async function persistAmbientSightFrame(
507
980
  conversationId: string,
508
981
  attachmentId: string,
982
+ surface: SightFrameSurface,
983
+ trustContext?: TrustContext,
984
+ acceptedIncarnation?: number,
509
985
  ): 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
- });
986
+ return persistStandaloneImage(
987
+ conversationId,
988
+ attachmentId,
989
+ "sight_frame",
990
+ {
991
+ content: SIGHT_FRAME_MESSAGE_CONTENT,
992
+ metadata: surface === "voice" ? { voiceSessionTurn: true } : {},
993
+ ...(trustContext ? { trustContext } : {}),
994
+ scripted: true,
995
+ // The camera sampled this, nobody sent it. Indexing it would feed
996
+ // extraction a frame every few seconds of whatever the room happens to
997
+ // contain, and commit those visuals to long-term memory with no consent
998
+ // surface: the design puts keeps in the TRANSCRIPT, which the user can
999
+ // see and delete, and says nothing about memory. The text half is
1000
+ // worthless to search anyway, every row reading "(camera frame)".
1001
+ skipIndexing: true,
1002
+ sightFrameAttachmentIds: [attachmentId],
1003
+ },
1004
+ acceptedIncarnation,
1005
+ );
523
1006
  }