@vellumai/assistant 0.11.8 → 0.11.9-staging.2

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 (326) 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__/gemini-provider.test.ts +46 -0
  64. package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
  65. package/src/__tests__/guardian-gateway-sim.ts +0 -23
  66. package/src/__tests__/guardian-question-mode.test.ts +108 -0
  67. package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
  68. package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
  69. package/src/__tests__/host-proxy-interface.test.ts +10 -0
  70. package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
  71. package/src/__tests__/injector-v3-suppression.test.ts +43 -9
  72. package/src/__tests__/list-messages-page-latest.test.ts +127 -0
  73. package/src/__tests__/list-messages-system-card.test.ts +103 -0
  74. package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
  75. package/src/__tests__/notification-decision-fallback.test.ts +199 -77
  76. package/src/__tests__/notification-decision-strategy.test.ts +198 -213
  77. package/src/__tests__/notification-discord-adapter.test.ts +25 -0
  78. package/src/__tests__/notification-slack-adapter.test.ts +133 -0
  79. package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
  80. package/src/__tests__/openai-provider.test.ts +5 -5
  81. package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
  82. package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
  83. package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
  84. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
  85. package/src/__tests__/pricing.test.ts +11 -0
  86. package/src/__tests__/process-message-display-content.test.ts +20 -9
  87. package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
  88. package/src/__tests__/provider-meta-persistence.test.ts +67 -0
  89. package/src/__tests__/reaction-persistence.test.ts +22 -198
  90. package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
  91. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
  92. package/src/__tests__/skill-load-tool.test.ts +27 -0
  93. package/src/__tests__/skills.test.ts +34 -1
  94. package/src/__tests__/strip-memory-injections.test.ts +3 -4
  95. package/src/__tests__/terminal-tools.test.ts +9 -0
  96. package/src/__tests__/thread-backfill.test.ts +5 -3
  97. package/src/__tests__/unified-turn-context-location.test.ts +76 -0
  98. package/src/__tests__/voice-session-bridge.test.ts +18 -0
  99. package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
  100. package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
  101. package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
  102. package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
  103. package/src/agent/attachments.ts +13 -2
  104. package/src/agent/loop.ts +3 -19
  105. package/src/api/README.md +9 -5
  106. package/src/api/index.ts +9 -8
  107. package/src/api/package.json +1 -0
  108. package/src/api/responses/conversation-message.ts +8 -0
  109. package/src/api/responses/home.ts +7 -18
  110. package/src/api/surfaces.ts +114 -7
  111. package/src/approvals/AGENTS.md +1 -1
  112. package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
  113. package/src/channels/__tests__/types.test.ts +22 -1
  114. package/src/channels/gateway-guardian-requests.ts +0 -22
  115. package/src/channels/types.ts +8 -6
  116. package/src/cli/__tests__/catalog-search-help.test.ts +18 -0
  117. package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
  118. package/src/cli/commands/channels/index.help.ts +19 -6
  119. package/src/cli/commands/channels/index.ts +6 -2
  120. package/src/cli/commands/db/__tests__/status.test.ts +22 -0
  121. package/src/cli/commands/db/index.help.ts +1 -1
  122. package/src/cli/commands/db/status.ts +172 -1
  123. package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
  124. package/src/cli/commands/platform/connect.ts +14 -5
  125. package/src/cli/commands/plugins.help.ts +6 -5
  126. package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
  127. package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
  128. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
  129. package/src/cli/lib/bundled-marketplace.json +14 -0
  130. package/src/cli/lib/install-from-github.ts +9 -5
  131. package/src/cli/lib/install-from-platform.ts +12 -1
  132. package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
  133. package/src/config/assistant-initiated-threads-gate.ts +50 -0
  134. package/src/config/bundled-skills/acp/SKILL.md +10 -3
  135. package/src/config/bundled-skills/schedule/SKILL.md +25 -11
  136. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
  137. package/src/config/call-site-defaults.ts +4 -1
  138. package/src/config/feature-flag-registry.json +21 -4
  139. package/src/config/schemas/memory-v3.ts +4 -3
  140. package/src/context/strip-injections.ts +17 -56
  141. package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
  142. package/src/conversations/message-consolidation.ts +4 -3
  143. package/src/credential-health/credential-health-service.ts +130 -0
  144. package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
  145. package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
  146. package/src/daemon/conversation-error.ts +2 -0
  147. package/src/daemon/conversation-messaging.ts +195 -47
  148. package/src/daemon/conversation-process.ts +20 -5
  149. package/src/daemon/conversation-runtime-assembly.ts +48 -39
  150. package/src/daemon/conversation-store.ts +159 -11
  151. package/src/daemon/conversation-tool-setup.ts +9 -2
  152. package/src/daemon/conversation.ts +290 -19
  153. package/src/daemon/dictation-text-processing.ts +2 -7
  154. package/src/daemon/handlers/config-channels.ts +33 -3
  155. package/src/daemon/handlers/config-model.test.ts +1 -0
  156. package/src/daemon/handlers/shared.ts +9 -0
  157. package/src/daemon/port-oversized-content.test.ts +117 -0
  158. package/src/daemon/port-oversized-content.ts +110 -0
  159. package/src/daemon/process-message.ts +87 -16
  160. package/src/daemon/reaction-record.test.ts +23 -11
  161. package/src/daemon/reaction-record.ts +6 -9
  162. package/src/documents/document-store.ts +1 -5
  163. package/src/home/conversation-starter-validation.ts +1 -5
  164. package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
  165. package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
  166. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
  167. package/src/live-voice/live-voice-photo.ts +544 -61
  168. package/src/live-voice/live-voice-session.ts +6 -2
  169. package/src/live-voice/protocol.ts +20 -1
  170. package/src/messaging/provider-message-metadata.ts +40 -1
  171. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
  172. package/src/messaging/providers/channel-transport.ts +28 -0
  173. package/src/messaging/providers/discord/send.ts +3 -2
  174. package/src/messaging/providers/slack/api.ts +11 -1
  175. package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
  176. package/src/messaging/providers/slack/message-metadata.ts +141 -1
  177. package/src/messaging/providers/slack/send.test.ts +58 -0
  178. package/src/messaging/providers/slack/send.ts +4 -4
  179. package/src/messaging/providers/slack/transport.ts +28 -2
  180. package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
  181. package/src/messaging/providers/telegram-bot/send.ts +93 -0
  182. package/src/messaging/providers/telegram-bot/transport.ts +71 -0
  183. package/src/messaging/reaction-envelopes.test.ts +73 -0
  184. package/src/messaging/reaction-envelopes.ts +33 -5
  185. package/src/messaging/read-provider-metadata.ts +4 -3
  186. package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
  187. package/src/monitoring/recovery/db.ts +35 -0
  188. package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
  189. package/src/monitoring/recovery/run-recovery.ts +6 -2
  190. package/src/monitoring/recovery/stale-processing.ts +3 -20
  191. package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
  192. package/src/notifications/AGENTS.md +2 -2
  193. package/src/notifications/README.md +2 -2
  194. package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
  195. package/src/notifications/__tests__/broadcaster.test.ts +35 -4
  196. package/src/notifications/__tests__/notification-utils.test.ts +31 -0
  197. package/src/notifications/access-request-copy.ts +126 -114
  198. package/src/notifications/adapters/discord.ts +9 -5
  199. package/src/notifications/adapters/shared.ts +17 -4
  200. package/src/notifications/adapters/slack.ts +14 -4
  201. package/src/notifications/adapters/telegram.ts +8 -4
  202. package/src/notifications/approval-card-data.ts +5 -2
  203. package/src/notifications/assistant-reply-producer.ts +7 -7
  204. package/src/notifications/broadcaster.ts +57 -25
  205. package/src/notifications/conversation-pairing.ts +68 -2
  206. package/src/notifications/copy-composer.ts +13 -32
  207. package/src/notifications/decision-engine.ts +56 -237
  208. package/src/notifications/guardian-delivery-recorder.ts +5 -9
  209. package/src/notifications/guardian-feed-projection.ts +0 -15
  210. package/src/notifications/guardian-question-mode.ts +147 -6
  211. package/src/notifications/notification-utils.ts +117 -1
  212. package/src/permissions/confirmation-guardian-request.ts +2 -2
  213. package/src/permissions/prompter.ts +1 -1
  214. package/src/persistence/conversation-crud.ts +93 -14
  215. package/src/persistence/conversation-queries.ts +144 -17
  216. package/src/persistence/conversation-types.ts +39 -5
  217. package/src/persistence/delivery-crud.ts +224 -28
  218. package/src/persistence/delivery-status.ts +21 -2
  219. package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
  220. package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
  221. package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
  222. package/src/persistence/schema/conversations.ts +71 -2
  223. package/src/persistence/schema-contract.test.ts +78 -0
  224. package/src/persistence/schema-contract.ts +96 -0
  225. package/src/persistence/steps.ts +4 -0
  226. package/src/platform/client.ts +55 -0
  227. package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
  228. package/src/plugins/defaults/injector-order.ts +2 -1
  229. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
  230. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
  231. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
  232. package/src/plugins/defaults/memory/injectors.ts +1 -1
  233. package/src/plugins/defaults/memory/memory-marker.ts +17 -7
  234. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
  235. package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +17 -0
  236. package/src/plugins/defaults/memory/substrate/skill-content.ts +7 -0
  237. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
  238. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
  239. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +22 -0
  240. package/src/plugins/defaults/memory/v3/injector.ts +14 -15
  241. package/src/plugins/defaults/memory/v3/prune.test.ts +20 -2
  242. package/src/plugins/defaults/memory/v3/render-injection.ts +10 -1
  243. package/src/plugins/defaults/memory/v3/types.ts +15 -4
  244. package/src/plugins/defaults/turn-context/injectors.ts +3 -0
  245. package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
  246. package/src/plugins/mcp-servers.ts +14 -2
  247. package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
  248. package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
  249. package/src/providers/anthropic/client.ts +21 -24
  250. package/src/providers/call-site-routing.ts +5 -4
  251. package/src/providers/connection-resolution.ts +29 -1
  252. package/src/providers/content-block-size.test.ts +82 -0
  253. package/src/providers/content-block-size.ts +99 -0
  254. package/src/providers/file-block-text.test.ts +64 -0
  255. package/src/providers/file-block-text.ts +28 -0
  256. package/src/providers/gemini/client.ts +15 -9
  257. package/src/providers/inference/auth.ts +6 -6
  258. package/src/providers/media-resolve.ts +14 -0
  259. package/src/providers/model-catalog.ts +47 -34
  260. package/src/providers/openai/chat-completions-provider.ts +9 -25
  261. package/src/providers/openai/responses-provider.ts +11 -18
  262. package/src/providers/registry.ts +10 -7
  263. package/src/providers/routing-identity.ts +2 -1
  264. package/src/providers/types.ts +1 -4
  265. package/src/providers/vellum-model-routing.ts +3 -2
  266. package/src/runtime/AGENTS.md +40 -15
  267. package/src/runtime/approval-message-composer.ts +1 -6
  268. package/src/runtime/assistant-event-hub.ts +2 -39
  269. package/src/runtime/channel-approval-types.ts +1 -5
  270. package/src/runtime/channel-reply-delivery.ts +182 -115
  271. package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
  272. package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
  273. package/src/runtime/channel-retry-sweep.ts +102 -1
  274. package/src/runtime/finalize-event-delivery.ts +4 -4
  275. package/src/runtime/guardian-action-message-composer.ts +1 -4
  276. package/src/runtime/guardian-reply-router.ts +4 -75
  277. package/src/runtime/http-router.ts +1 -5
  278. package/src/runtime/question-request-guardian-bridge.ts +2 -3
  279. package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
  280. package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
  281. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
  282. package/src/runtime/routes/acp-routes.ts +1 -1
  283. package/src/runtime/routes/canned-reply-release.ts +19 -9
  284. package/src/runtime/routes/channel-route-shared.ts +0 -39
  285. package/src/runtime/routes/channel-verification-routes.ts +4 -1
  286. package/src/runtime/routes/conversation-list-routes.ts +44 -3
  287. package/src/runtime/routes/conversation-management-routes.ts +2 -1
  288. package/src/runtime/routes/conversation-routes.ts +138 -59
  289. package/src/runtime/routes/diagnostics-routes.ts +111 -60
  290. package/src/runtime/routes/guardian-action-routes.ts +10 -14
  291. package/src/runtime/routes/guardian-approval-interception.ts +0 -5
  292. package/src/runtime/routes/inbound-message-handler.ts +170 -48
  293. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
  294. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
  295. package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
  296. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
  297. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
  298. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
  299. package/src/runtime/routes/index.ts +2 -0
  300. package/src/runtime/routes/platform-routes.ts +99 -10
  301. package/src/runtime/routes/sight-frame-routes.ts +136 -0
  302. package/src/runtime/sync/resource-sync-events.ts +14 -37
  303. package/src/runtime/sync/sync-publisher.test.ts +5 -4
  304. package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
  305. package/src/tools/client-os.ts +11 -1
  306. package/src/tools/host-filesystem/edit.ts +2 -2
  307. package/src/tools/host-filesystem/read.ts +2 -2
  308. package/src/tools/host-filesystem/transfer.ts +2 -2
  309. package/src/tools/host-filesystem/write.ts +2 -2
  310. package/src/tools/host-terminal/host-shell.ts +2 -2
  311. package/src/tools/skills/load.ts +1 -1
  312. package/src/tools/terminal/safe-env.ts +4 -0
  313. package/src/tools/tool-input-schemas.ts +2 -0
  314. package/src/tools/tool-manifest.ts +2 -0
  315. package/src/tools/ui-surface/definitions.ts +4 -3
  316. package/src/tools/watch/watch-retro-report.ts +205 -0
  317. package/src/watch/__tests__/watch-retro.test.ts +268 -40
  318. package/src/watch/watch-retro.ts +190 -77
  319. package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
  320. package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
  321. package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
  322. package/src/workspace/migrations/registry.ts +4 -0
  323. package/docker-kata-pip-chroot.sh +0 -22
  324. package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
  325. package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
  326. package/src/api/events/conversation-list-invalidated.ts +0 -38
@@ -15,6 +15,8 @@ import { z } from "zod";
15
15
  import { externalSourceLinkSchema } from "../messaging/channel-binding-schema.js";
16
16
  import { isSlackDmConversation } from "../messaging/providers/slack/message-metadata.js";
17
17
  import { nonEmpty } from "./notification-utils.js";
18
+ import type { NotificationSignal } from "./signal.js";
19
+ import type { NotificationChannel, RenderedChannelCopy } from "./types.js";
18
20
 
19
21
  // ── Schema primitives ──────────────────────────────────────────────────
20
22
 
@@ -630,10 +632,6 @@ export function hasGuardianRequestCodeInstruction(
630
632
  }
631
633
  }
632
634
 
633
- function escapeRegExp(value: string): string {
634
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
635
- }
636
-
637
635
  function normalizeInstructionText(value: string): string {
638
636
  return value
639
637
  .replace(/[ \t]+\n/g, "\n")
@@ -660,7 +658,7 @@ export function stripConflictingGuardianRequestInstructions(
660
658
  requestCode: string,
661
659
  mode: GuardianQuestionInstructionMode,
662
660
  ): string {
663
- const escapedCode = escapeRegExp(requestCode);
661
+ const escapedCode = RegExp.escape(requestCode);
664
662
  const next =
665
663
  mode === "answer"
666
664
  ? text.replace(buildApprovalInstructionPattern(escapedCode), "")
@@ -679,7 +677,7 @@ export function stripGuardianRequestCodeInstructions(
679
677
  text: string,
680
678
  requestCode: string,
681
679
  ): string {
682
- const escapedCode = escapeRegExp(requestCode);
680
+ const escapedCode = RegExp.escape(requestCode);
683
681
  const next = text
684
682
  .replace(buildApprovalInstructionPattern(escapedCode), "")
685
683
  .replace(buildAnswerInstructionPattern(escapedCode), "")
@@ -712,6 +710,149 @@ export function parseInteractiveApprovalPayload(
712
710
  return nonEmpty(parsed.requestId) ? parsed : null;
713
711
  }
714
712
 
713
+ // ── Reply mechanics per channel ─────────────────────────────────────────
714
+
715
+ /**
716
+ * Whether a channel's rendered guardian-request copy carries request-code
717
+ * reply mechanics (`Reference code: X. Reply "X <your answer>".` and its
718
+ * approval-mode twin). Decided by where that channel's copy is read:
719
+ *
720
+ * - `vellum`: the notification bell's detail, the OS banner, and the
721
+ * delivery audit row. The conversation gets the card
722
+ * (`seedContentBlocks`), which carries its own text fallback. None of
723
+ * these takes a typed reply; the guardian acts through the card or the
724
+ * conversation it points at.
725
+ * - `platform`: a push banner mirroring the vellum copy.
726
+ * - `slack`, for payloads that render Approve/Reject buttons
727
+ * (`resolveApprovalContext` in broadcaster.ts, gated on the same
728
+ * {@link parseInteractiveApprovalPayload}).
729
+ *
730
+ * Every other channel is a text chat where "CODE <reply>" is how a guardian
731
+ * answers, so its copy has to carry the instruction even when the composed
732
+ * copy left it out. A newly deliverable channel lands here by default,
733
+ * which keeps its text answerable until it grows a card.
734
+ */
735
+ export function guardianCopyCarriesReplyMechanics(
736
+ channel: NotificationChannel,
737
+ payload: Record<string, unknown>,
738
+ ): boolean {
739
+ switch (channel) {
740
+ case "vellum":
741
+ case "platform":
742
+ return false;
743
+ case "slack":
744
+ return parseInteractiveApprovalPayload(payload) == null;
745
+ default:
746
+ return true;
747
+ }
748
+ }
749
+
750
+ function ensureGuardianRequestCodeInCopy(
751
+ copy: RenderedChannelCopy,
752
+ requestCode: string,
753
+ mode: GuardianQuestionInstructionMode,
754
+ ): RenderedChannelCopy {
755
+ const instruction = buildGuardianRequestCodeInstruction(requestCode, mode);
756
+
757
+ const ensureText = (text: string | undefined): string => {
758
+ const base = typeof text === "string" ? text.trim() : "";
759
+ const sanitized = stripConflictingGuardianRequestInstructions(
760
+ base,
761
+ requestCode,
762
+ mode,
763
+ );
764
+ if (hasGuardianRequestCodeInstruction(sanitized, requestCode, mode)) {
765
+ return sanitized;
766
+ }
767
+ return sanitized.length > 0
768
+ ? `${sanitized}\n\n${instruction}`
769
+ : instruction;
770
+ };
771
+
772
+ return {
773
+ ...copy,
774
+ body: ensureText(copy.body),
775
+ deliveryText: copy.deliveryText
776
+ ? ensureText(copy.deliveryText)
777
+ : copy.deliveryText,
778
+ conversationSeedMessage: copy.conversationSeedMessage
779
+ ? ensureText(copy.conversationSeedMessage)
780
+ : copy.conversationSeedMessage,
781
+ };
782
+ }
783
+
784
+ /**
785
+ * A field that was nothing but the instruction becomes the request's own
786
+ * question text, the producer's human-readable ask, so a card surface never
787
+ * shows the mechanics it exists to avoid. Without one it keeps its original
788
+ * text rather than becoming empty: downstream treats an empty body as
789
+ * missing copy.
790
+ */
791
+ function stripGuardianRequestCodeInCopy(
792
+ copy: RenderedChannelCopy,
793
+ requestCode: string,
794
+ fallbackText: string | undefined,
795
+ ): RenderedChannelCopy {
796
+ const strip = (text: string): string => {
797
+ const stripped = stripGuardianRequestCodeInstructions(text, requestCode);
798
+ return stripped.length > 0 ? stripped : (fallbackText ?? text);
799
+ };
800
+ // A title is a headline, never the ask, so a code-only title keeps its
801
+ // text rather than taking the question.
802
+ const strippedTitle = stripGuardianRequestCodeInstructions(
803
+ copy.title,
804
+ requestCode,
805
+ );
806
+
807
+ return {
808
+ ...copy,
809
+ title: strippedTitle.length > 0 ? strippedTitle : copy.title,
810
+ body: strip(copy.body),
811
+ deliveryText: copy.deliveryText
812
+ ? strip(copy.deliveryText)
813
+ : copy.deliveryText,
814
+ conversationSeedMessage: copy.conversationSeedMessage
815
+ ? strip(copy.conversationSeedMessage)
816
+ : copy.conversationSeedMessage,
817
+ };
818
+ }
819
+
820
+ /**
821
+ * Apply the reply-mechanics rule to one channel's copy of a
822
+ * `guardian.question` signal: enforce the request-code instruction into
823
+ * copy the guardian answers by typing, strip it (model-authored code
824
+ * phrasing included) from copy that acts through a card. Any other signal
825
+ * passes through untouched. Idempotent, so the template composer and the
826
+ * decision engine can both apply it and the result is the same whichever
827
+ * path rendered the copy.
828
+ */
829
+ export function applyGuardianReplyMechanics(
830
+ copy: RenderedChannelCopy,
831
+ channel: NotificationChannel,
832
+ signal: Pick<NotificationSignal, "sourceEventName" | "contextPayload">,
833
+ ): RenderedChannelCopy {
834
+ if (signal.sourceEventName !== "guardian.question") {
835
+ return copy;
836
+ }
837
+ const rawCode = signal.contextPayload.requestCode;
838
+ if (typeof rawCode !== "string" || rawCode.trim().length === 0) {
839
+ return copy;
840
+ }
841
+ const requestCode = rawCode.trim().toUpperCase();
842
+ if (!guardianCopyCarriesReplyMechanics(channel, signal.contextPayload)) {
843
+ const questionText = signal.contextPayload.questionText;
844
+ return stripGuardianRequestCodeInCopy(
845
+ copy,
846
+ requestCode,
847
+ typeof questionText === "string" ? nonEmpty(questionText) : undefined,
848
+ );
849
+ }
850
+ const { mode } = resolveGuardianQuestionInstructionMode(
851
+ signal.contextPayload,
852
+ );
853
+ return ensureGuardianRequestCodeInCopy(copy, requestCode, mode);
854
+ }
855
+
715
856
  /**
716
857
  * Resolve guardian reply instruction mode from a raw context payload.
717
858
  *
@@ -9,8 +9,9 @@
9
9
  import type { Root, RootContent } from "mdast";
10
10
 
11
11
  import { parseMarkdown } from "../messaging/content/parse.js";
12
- import { stripAnsiAndControlChars } from "../util/ansi.js";
12
+ import { stripAnsiAndControlChars, stripAnsiSequences } from "../util/ansi.js";
13
13
  import { isPlainObject } from "../util/object.js";
14
+ import type { RenderedChannelCopy } from "./types.js";
14
15
 
15
16
  // ── String helpers ──────────────────────────────────────────────────────────
16
17
 
@@ -237,6 +238,40 @@ function sanitize(value: string, maxLength: number): string {
237
238
  return truncate(stripAnsiAndControlChars(value, " ").trim(), maxLength);
238
239
  }
239
240
 
241
+ /**
242
+ * Collapse horizontal whitespace and blank-line runs while keeping paragraph
243
+ * breaks. Tabs become spaces. Three or more consecutive newlines collapse to
244
+ * one blank line so padding does not eat the preview budget.
245
+ */
246
+ export function collapseHorizontalWhitespace(value: string): string {
247
+ return value
248
+ .replace(/[^\S\n]+/g, " ")
249
+ .replace(/ *\n */g, "\n")
250
+ .replace(/\n{3,}/g, "\n\n")
251
+ .trim();
252
+ }
253
+
254
+ /**
255
+ * Sanitize a notification body that should keep its line breaks.
256
+ *
257
+ * ANSI sequences and non-newline control characters are stripped; `\r\n` and
258
+ * `\r` become `\n`. Horizontal whitespace is collapsed. The result is clamped
259
+ * to {@link MESSAGE_PREVIEW_MAX_LENGTH}.
260
+ *
261
+ * Single-line surfaces (titles, identity fields, channel previews that must
262
+ * not wrap) should keep using {@link sanitizeMessagePreview}.
263
+ */
264
+ export function sanitizeMultilineMessagePreview(value: string): string {
265
+ const normalized = stripAnsiSequences(value)
266
+ .replace(/\r\n/g, "\n")
267
+ .replace(/\r/g, "\n")
268
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]+/g, " ");
269
+ return truncate(
270
+ collapseHorizontalWhitespace(normalized),
271
+ MESSAGE_PREVIEW_MAX_LENGTH,
272
+ );
273
+ }
274
+
240
275
  /**
241
276
  * Sanitize an untrusted identity field for inclusion in notification copy.
242
277
  * Strips control characters and clamps to 120 characters.
@@ -271,3 +306,84 @@ export const NOTIFICATION_TITLE_MAX_LENGTH = 60;
271
306
  export function sanitizeNotificationTitle(value: string): string {
272
307
  return sanitize(value, NOTIFICATION_TITLE_MAX_LENGTH);
273
308
  }
309
+
310
+ /**
311
+ * Tidy copy after a sentence was cut out of it: trailing spaces before a
312
+ * line break go, runs of blank lines collapse to one, and the ends trim.
313
+ */
314
+ export function normalizeStrippedText(value: string): string {
315
+ return value
316
+ .replace(/(?<=\S)[ \t]{2,}(?=\S)/g, " ")
317
+ .replace(/[ \t]+\n/g, "\n")
318
+ .replace(/\n{3,}/g, "\n\n")
319
+ .trim();
320
+ }
321
+
322
+ const DIRECTIVE_VERBS = "approve|reject|verify|trust|block|<your\\s+answer>";
323
+
324
+ /**
325
+ * Remove request-code reply mechanics from copy: every sentence that pairs
326
+ * the code with a reply verb (`Reply "X approve" or "X reject"`, a
327
+ * paraphrase, a negated form like `Do not reply "X reject"`, and the same
328
+ * with backticks, smart quotes, or no quotes at all), and every sentence
329
+ * that names a reference, approval, or request code together with the code
330
+ * (`Reference code: X.`, `Use reference code X for this request.`).
331
+ *
332
+ * Whole sentences go, never a suffix from "reply" onward, so no fragment
333
+ * like "Alice wants access. Do not" survives. A sentence is text between
334
+ * line breaks or `.!?` terminators. Sentences that mention other codes or
335
+ * merely contain the word "reply" are untouched.
336
+ */
337
+ export function stripRequestCodeDirectives(
338
+ text: string,
339
+ requestCode: string,
340
+ ): string {
341
+ const code = RegExp.escape(requestCode);
342
+ const sentence = (core: string): RegExp =>
343
+ new RegExp(`[^.!?\\n]*${core}[^.!?\\n]*[.!?]?(?:[ \\t]*\\n)?`, "gi");
344
+ const next = text
345
+ .replace(
346
+ sentence(`(?<![A-Z0-9])${code}\\s+(?:${DIRECTIVE_VERBS})(?![A-Za-z])`),
347
+ "",
348
+ )
349
+ .replace(
350
+ sentence(
351
+ `\\b(?:reference|approval|request)\\s+code\\b[^.!?\\n]*?(?<![A-Z0-9])${code}(?![A-Z0-9])`,
352
+ ),
353
+ "",
354
+ );
355
+ return normalizeStrippedText(next);
356
+ }
357
+
358
+ /**
359
+ * Run a reply-mechanics strip over every text field of a channel's copy.
360
+ * A body, deliveryText, or conversationSeedMessage left empty by the strip
361
+ * becomes `ask`, the request's own deterministic text, so a card surface
362
+ * never shows the mechanics it exists to avoid; without one it keeps its
363
+ * text rather than becoming empty, which downstream reads as missing copy.
364
+ * A title is a headline, never the ask, so a mechanics-only title keeps
365
+ * its text.
366
+ */
367
+ export function stripReplyMechanicsFromCopy(
368
+ copy: RenderedChannelCopy,
369
+ strip: (text: string) => string,
370
+ ask: string | undefined,
371
+ ): RenderedChannelCopy {
372
+ const fallback = ask === undefined ? undefined : nonEmpty(ask);
373
+ const stripField = (text: string): string => {
374
+ const stripped = strip(text);
375
+ return stripped.length > 0 ? stripped : (fallback ?? text);
376
+ };
377
+ const strippedTitle = strip(copy.title);
378
+ return {
379
+ ...copy,
380
+ title: strippedTitle.length > 0 ? strippedTitle : copy.title,
381
+ body: stripField(copy.body),
382
+ deliveryText: copy.deliveryText
383
+ ? stripField(copy.deliveryText)
384
+ : copy.deliveryText,
385
+ conversationSeedMessage: copy.conversationSeedMessage
386
+ ? stripField(copy.conversationSeedMessage)
387
+ : copy.conversationSeedMessage,
388
+ };
389
+ }
@@ -8,7 +8,7 @@
8
8
  * generic event hub — lets the hub stay a pure pub/sub primitive with no
9
9
  * dependency on the conversation registry or guardian bridge.
10
10
  *
11
- * Channel guardian decisions (reactions, buttons, text) all route through the
11
+ * Channel guardian decisions (buttons, text) all route through the
12
12
  * guardian-request pipeline, so without this record none of them can resolve the
13
13
  * confirmation.
14
14
  *
@@ -135,7 +135,7 @@ export async function createGuardianRequestForConfirmation(
135
135
  if (err instanceof IntegrityError) {
136
136
  // The confirmation could not be promoted to a guardian request
137
137
  // (e.g. its trust context resolved no guardianPrincipalId). Channel
138
- // guardian decisions — reactions, buttons, and text — all route through
138
+ // guardian decisions (buttons and text) all route through
139
139
  // the guardian-request pipeline, so without this record none of them can resolve
140
140
  // the confirmation. Surface it rather than swallowing: for a guardian's
141
141
  // own confirmation a bound principal should always be present.
@@ -173,7 +173,7 @@ export class PermissionPrompter {
173
173
  this.sendToClient(confirmationMsg);
174
174
 
175
175
  // Promote the confirmation to a guardian request so channel
176
- // guardian decisions (reactions, buttons, text) can resolve it.
176
+ // guardian decisions (buttons, text) can resolve it.
177
177
  // The prompter runs inside the emitting turn, so the request binds
178
178
  // to that turn's trust snapshot.
179
179
  if (conversationId) {
@@ -32,6 +32,7 @@ import { conversationMetadataSyncTag } from "../daemon/message-types/sync.js";
32
32
  import type { TrustContext } from "../daemon/trust-context-types.js";
33
33
  import { clearAllConversationIds } from "../home/feed-writer.js";
34
34
  import type { ConversationDeletedInputContext } from "../hooks/types.js";
35
+ import { readProviderMetadata } from "../messaging/read-provider-metadata.js";
35
36
  import { HOOKS } from "../plugin-api/constants.js";
36
37
  import { forkConversationMemory } from "../plugins/defaults/memory/fork-conversation-memory.js";
37
38
  import { indexMessageNow } from "../plugins/defaults/memory/indexer.js";
@@ -309,7 +310,7 @@ export const messageMetadataSchema = z
309
310
  /**
310
311
  * Optional client-side metadata bag attached to user messages at persist
311
312
  * time. `os` carries the client-reported OS surface ("web" | "ios" |
312
- * "macos" | "windows" | "android") from the request body's `clientOs`
313
+ * "macos" | "windows" | "linux" | "android") from the request body's `clientOs`
313
314
  * field, stamped by `persistQueuedMessageBody`. The transport
314
315
  * `userMessageInterface` is
315
316
  * "web" for the web, mobile, and desktop apps alike, so this is the only
@@ -422,6 +423,12 @@ export const messageMetadataSchema = z
422
423
  * as a literal here (like `memoryInjectedBlock`) so the storage schema does
423
424
  * not import the memory feature. */
424
425
  memoryV3InjectedBlock: z.string().optional(),
426
+ /** Memory-v3 per-turn `<memory_spotlight>` block (wrapped). Rehydrated
427
+ * by `loadFromDb` so historical turns keep the spotlight they were sent
428
+ * with. The key matches the memory plugin's
429
+ * `MEMORY_V3_SPOTLIGHT_BLOCK_METADATA_KEY`, kept as a literal here so
430
+ * the storage schema does not import the memory feature. */
431
+ memoryV3SpotlightBlock: z.string().optional(),
425
432
  turnContextBlock: z.string().optional(),
426
433
  pkbSystemReminderBlock: z.string().optional(),
427
434
  workspaceBlock: z.string().optional(),
@@ -491,8 +498,9 @@ export function isProviderErrorMetadata(
491
498
  }
492
499
 
493
500
  /**
494
- * True when an assistant row is a standalone display turn: a system card or
495
- * a provider-error notice, or a deliberate-silence marker. Standalone rows never merge with adjacent
501
+ * True when an assistant row is a standalone display turn: a system card, a
502
+ * provider-error notice, a deliberate-silence marker, a reaction, or a row
503
+ * deleted on its channel. Standalone rows never merge with adjacent
496
504
  * assistant rows, and turn grouping closes on them, so display merging and
497
505
  * the turn resolver agree on boundaries. Takes the raw persisted `metadata`
498
506
  * JSON string; malformed JSON and non-assistant roles are never standalone.
@@ -510,13 +518,28 @@ export function isStandaloneAssistantMessage(
510
518
  isSystemCardMetadata(parsed) ||
511
519
  isProviderErrorMetadata(parsed) ||
512
520
  isNoResponseMetadata(parsed) ||
513
- isReactionMessageMetadata(parsed)
521
+ isReactionMessageMetadata(parsed) ||
522
+ isChannelDeletedMetadata(metadata)
514
523
  );
515
524
  } catch {
516
525
  return false;
517
526
  }
518
527
  }
519
528
 
529
+ /**
530
+ * True when the row was deleted on its channel after it was stored. The
531
+ * marker lives in the provider envelope rather than in `messageKind`, so a
532
+ * merged run would take the anchor's envelope and either drop the deletion
533
+ * or claim it over text that is still visible. The substring guard keeps the
534
+ * envelope parse off rows that cannot carry it.
535
+ */
536
+ function isChannelDeletedMetadata(metadata: string): boolean {
537
+ return (
538
+ metadata.includes("deletedAt") &&
539
+ readProviderMetadata(metadata)?.deletedAt !== undefined
540
+ );
541
+ }
542
+
520
543
  /**
521
544
  * Parse a persisted message's metadata JSON against {@link messageMetadataSchema}
522
545
  * — the single source of truth for its shape — returning the validated fields,
@@ -753,11 +776,17 @@ const parseMessage = createRowMapper<typeof messages.$inferSelect, MessageRow>({
753
776
  });
754
777
 
755
778
  /**
756
- * Monotonic timestamp source for message ordering. Two messages saved within
757
- * the same millisecond (e.g., tool_results user message + assistant message in
758
- * message_complete) would get the same Date.now(), making their reload order
759
- * non-deterministic. This counter ensures every call returns a strictly
760
- * increasing value so insertion order is always preserved.
779
+ * Monotonic timestamp source for message ordering and conversation creation.
780
+ * Two messages saved within the same millisecond (e.g., tool_results user
781
+ * message + assistant message in message_complete) would get the same
782
+ * Date.now(), making their reload order non-deterministic. This counter
783
+ * ensures every call returns a strictly increasing value so insertion order is
784
+ * always preserved.
785
+ *
786
+ * Conversation rows draw from it for a second reason: `created_at` is what
787
+ * tells one incarnation of an id from another, so two creations in one
788
+ * millisecond must not be able to collide. Sharing the counter with messages
789
+ * costs nothing, both wanting the same "never twice the same value" guarantee.
761
790
  */
762
791
  let lastTimestamp = 0;
763
792
  function monotonicNow(): number {
@@ -780,6 +809,24 @@ interface InsertedMessage {
780
809
  deduplicated: boolean;
781
810
  }
782
811
 
812
+ /**
813
+ * Thrown by an insert whose caller's `insertPrecondition` reads false.
814
+ *
815
+ * No row was written, so a caller holding resources for the message it asked
816
+ * for (an uploaded attachment, a pending client receipt) is free to give them
817
+ * up on this error. Carries no SQLite code, which is what keeps
818
+ * {@link withSqliteRetry} from mistaking it for contention and retrying an
819
+ * abort that will only abort again.
820
+ */
821
+ export class MessageInsertPreconditionError extends Error {
822
+ constructor(conversationId: string) {
823
+ super(
824
+ `Message insert precondition failed for conversation ${conversationId}`,
825
+ );
826
+ this.name = "MessageInsertPreconditionError";
827
+ }
828
+ }
829
+
783
830
  interface InsertMessageCoreParams {
784
831
  conversationId: string;
785
832
  role: MessageRole;
@@ -793,6 +840,9 @@ interface InsertMessageCoreParams {
793
840
  * `requestId` for user turns) can pass it here so the persisted
794
841
  * row ID matches the runtime request ID. */
795
842
  id?: string;
843
+ /** Answered synchronously at the top of every insert attempt. See
844
+ * {@link AddMessageOptions.insertPrecondition}. */
845
+ insertPrecondition?: () => boolean;
796
846
  }
797
847
 
798
848
  /**
@@ -879,6 +929,7 @@ async function insertMessageCore(
879
929
  metadata,
880
930
  clientMessageId,
881
931
  id,
932
+ insertPrecondition,
882
933
  } = params;
883
934
  warnOnModelInvisibleContent(content, conversationId);
884
935
  const db = getDb();
@@ -905,8 +956,17 @@ async function insertMessageCore(
905
956
  // The timestamp is recomputed each attempt so a late retry doesn't persist a
906
957
  // stale `updatedAt`.
907
958
  return withSqliteRetry(
908
- (): InsertedMessage =>
909
- timeSyncSection(
959
+ (): InsertedMessage => {
960
+ // Asked at the top of EVERY attempt, and synchronously, because that is
961
+ // the scope the answer holds for. Contention retries this function after
962
+ // an awaited backoff, so an answer given once for the call would be
963
+ // reporting on the world as it stood before a sleep the caller cannot
964
+ // see. From here to the statement below there is nothing async, so the
965
+ // answer and the row this attempt writes share one tick.
966
+ if (insertPrecondition && !insertPrecondition()) {
967
+ throw new MessageInsertPreconditionError(conversationId);
968
+ }
969
+ return timeSyncSection(
910
970
  "messages:insert",
911
971
  (): InsertedMessage => {
912
972
  const now = monotonicNow();
@@ -1016,7 +1076,8 @@ async function insertMessageCore(
1016
1076
  contentBytes:
1017
1077
  typeof content === "string" ? content.length : undefined,
1018
1078
  }),
1019
- ),
1079
+ );
1080
+ },
1020
1081
  { op: "insertMessageCore", context: { conversationId } },
1021
1082
  );
1022
1083
  }
@@ -1073,7 +1134,12 @@ export function createConversation(
1073
1134
  },
1074
1135
  ) {
1075
1136
  const db = getDb();
1076
- const now = Date.now();
1137
+ // Monotonic, because `created_at` is a conversation's incarnation identity:
1138
+ // callers holding work for an id compare against the stamp they were
1139
+ // accepted for, so a row deleted and written back under that id has to carry
1140
+ // a later one even when both land in the same millisecond. Per process is
1141
+ // the scope that matters, the holders being in-memory and gone on a restart.
1142
+ const now = monotonicNow();
1077
1143
  const initialSeq = getCurrentSeq();
1078
1144
  const opts =
1079
1145
  typeof titleOrOpts === "string"
@@ -2361,6 +2427,17 @@ export interface AddMessageOptions {
2361
2427
  * internally. Pass the same value as `requestId` for user turns so
2362
2428
  * the persisted row ID matches the runtime correlation ID. */
2363
2429
  id?: string;
2430
+ /**
2431
+ * Answered synchronously at the top of every insert attempt, immediately
2432
+ * before that attempt's statement. False aborts with a
2433
+ * {@link MessageInsertPreconditionError} and writes nothing.
2434
+ *
2435
+ * For a caller whose right to write can lapse while the insert is in
2436
+ * flight. Per attempt rather than per call because contention retries the
2437
+ * insert after an awaited backoff, and the world can move under a caller
2438
+ * during that sleep.
2439
+ */
2440
+ insertPrecondition?: () => boolean;
2364
2441
  }
2365
2442
 
2366
2443
  /**
@@ -2374,7 +2451,8 @@ export async function addMessage(
2374
2451
  content: string,
2375
2452
  options?: AddMessageOptions,
2376
2453
  ) {
2377
- const { metadata, skipIndexing, clientMessageId, id } = options ?? {};
2454
+ const { metadata, skipIndexing, clientMessageId, id, insertPrecondition } =
2455
+ options ?? {};
2378
2456
  const inserted = await insertMessageCore({
2379
2457
  conversationId,
2380
2458
  role,
@@ -2382,6 +2460,7 @@ export async function addMessage(
2382
2460
  metadata,
2383
2461
  clientMessageId,
2384
2462
  id,
2463
+ ...(insertPrecondition ? { insertPrecondition } : {}),
2385
2464
  });
2386
2465
 
2387
2466
  if (inserted.deduplicated) {