@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
@@ -25,6 +25,8 @@ import { ensureDisplayOrderMigration } from "./conversation-display-order-migrat
25
25
  import { ensureGroupMigration } from "./conversation-group-migration.js";
26
26
  import { searchMessageIdsLexical } from "./conversation-search-lexical.js";
27
27
  import {
28
+ ASSISTANT_INITIATED_GROUP_ID,
29
+ ASSISTANT_INITIATED_SOURCE,
28
30
  type ConversationType,
29
31
  NATIVE_ORIGIN_CHANNEL,
30
32
  PINNED_GROUP_ID,
@@ -213,6 +215,58 @@ function notBackgroundVisibilitySql(alias = "conversations"): string {
213
215
  );
214
216
  }
215
217
 
218
+ /**
219
+ * Raw SQL predicate for "a thread the assistant started on its own": the rows
220
+ * stamped {@link ASSISTANT_INITIATED_SOURCE} at creation by a producer that
221
+ * opted the thread into the section.
222
+ *
223
+ * Membership rides `source` rather than `group_id` because those rows are
224
+ * filed nowhere — see {@link ASSISTANT_INITIATED_GROUP_ID} for why the section
225
+ * is still named as a group id. It is a dedicated source rather than the
226
+ * notification pipeline's `'notification'`, whose rows are the transactional
227
+ * request trails (guardian approvals, confirmation / access / question /
228
+ * tool-grant requests, channel deliveries) that belong to the bell and to
229
+ * Chats - see {@link ASSISTANT_INITIATED_SOURCE} for the full rationale.
230
+ */
231
+ function assistantInitiatedSql(alias = "conversations"): string {
232
+ return `(${alias}.source = '${ASSISTANT_INITIATED_SOURCE}' AND ${ungroupedSql(alias)})`;
233
+ }
234
+
235
+ /**
236
+ * The complement of {@link assistantInitiatedSql}, spelled out rather than
237
+ * wrapped in `NOT`.
238
+ *
239
+ * `source` is nullable and the overwhelming majority of rows carry NULL, so
240
+ * `NOT (source = '...')` evaluates to NULL for them under SQL's three-valued
241
+ * logic and the WHERE clause drops them. Negating the column naively would
242
+ * therefore not withhold one section from Chats - it would empty Chats.
243
+ */
244
+ function notAssistantInitiatedSql(alias = "conversations"): string {
245
+ return `(${alias}.source IS NULL OR ${alias}.source != '${ASSISTANT_INITIATED_SOURCE}' OR NOT ${ungroupedSql(alias)})`;
246
+ }
247
+
248
+ /**
249
+ * Raw SQL predicate for {@link UNGROUPED_GROUP_ID}: every row the sidebar's
250
+ * flat list shows — not pinned and not filed in a custom group.
251
+ *
252
+ * It deliberately admits the system buckets, because a surfaced background or
253
+ * scheduled conversation keeps its `system:background` / `system:scheduled`
254
+ * group id (surfacing writes only `surfaced_at`) while the standard listing
255
+ * renders it in Recents. Whether such a row is actually visible stays with
256
+ * {@link conversationTypeClause}, which admits the surfaced ones and excludes
257
+ * the rest; matching on group id alone here would drop them.
258
+ *
259
+ * Named because two sections are carved out of this same set — Chats and the
260
+ * assistant-initiated threads — and they have to agree on what it contains or
261
+ * a row lands in both or neither.
262
+ */
263
+ function ungroupedSql(alias = "conversations"): string {
264
+ return (
265
+ `(${alias}.group_id IS NULL` +
266
+ ` OR (${alias}.group_id LIKE 'system:%' AND ${alias}.group_id != '${PINNED_GROUP_ID}'))`
267
+ );
268
+ }
269
+
216
270
  /**
217
271
  * SQL predicate selecting which bucket {@link listConversations} and
218
272
  * {@link countConversations} return, keyed by the canonical
@@ -293,6 +347,21 @@ export interface ConversationListFilter {
293
347
  * row instead of paging past runs it would skip.
294
348
  */
295
349
  foregroundOnly?: true;
350
+ /**
351
+ * Withhold the threads the assistant started on its own
352
+ * ({@link assistantInitiatedSql}) from the result.
353
+ *
354
+ * The exact complement of `groupId: ASSISTANT_INITIATED_GROUP_ID`, and the
355
+ * other half of splitting that section out of Chats: without it those rows
356
+ * would be counted and listed twice, once in their own section and once in
357
+ * the leftover bucket that never excluded them. Being the exact complement
358
+ * matters as much as the exclusion — a thread the user pinned or filed into
359
+ * a custom group is not in the assistant section, so this must not withhold
360
+ * it from the section that does hold it. Only `true` narrows; omit to keep
361
+ * every row, which is what every caller does while the
362
+ * `assistant-initiated-threads` flag is off.
363
+ */
364
+ excludeAssistantInitiated?: true;
296
365
  }
297
366
 
298
367
  export interface ConversationListQuery extends ConversationListFilter {
@@ -313,16 +382,15 @@ export interface ConversationListQuery extends ConversationListFilter {
313
382
  * declared in `schema/conversations.ts`.
314
383
  */
315
384
  function groupIdClause(groupId: string) {
385
+ if (groupId === ASSISTANT_INITIATED_GROUP_ID) {
386
+ // Not a `group_id` match: `source` decides membership. See
387
+ // ASSISTANT_INITIATED_GROUP_ID. Still narrowed to the ungrouped set,
388
+ // because filing one of these threads into a custom group — or pinning it
389
+ // — must move it there rather than leave it in two sections at once.
390
+ return sql.raw(assistantInitiatedSql());
391
+ }
316
392
  if (groupId === UNGROUPED_GROUP_ID) {
317
- // "Ungrouped" means every row the sidebar's flat list shows: not pinned
318
- // and not filed in a custom group. It deliberately admits the system
319
- // buckets, because a surfaced background or scheduled conversation keeps
320
- // its `system:background` / `system:scheduled` group id (surfacing writes
321
- // only `surfaced_at`) while the standard listing renders it in Recents.
322
- // Whether such a row is actually visible stays with
323
- // `conversationTypeClause`, which admits the surfaced ones and excludes
324
- // the rest; matching on group id alone here would drop them.
325
- return sql`(group_id IS NULL OR (group_id LIKE 'system:%' AND group_id != ${PINNED_GROUP_ID}))`;
393
+ return sql.raw(ungroupedSql());
326
394
  }
327
395
  // `group_id` is single-valued and authoritative, so a row belongs to
328
396
  // exactly one section by construction. `is_pinned` is a derived duplicate
@@ -376,6 +444,7 @@ function conversationListWhere(filter: ConversationListFilter) {
376
444
  groupId,
377
445
  needsAttention,
378
446
  foregroundOnly,
447
+ excludeAssistantInitiated,
379
448
  } = filter;
380
449
  return and(
381
450
  conversationTypeClause(conversationType),
@@ -384,6 +453,7 @@ function conversationListWhere(filter: ConversationListFilter) {
384
453
  groupId ? groupIdClause(groupId) : undefined,
385
454
  ...(needsAttention ? unseenAttentionStateConditions() : []),
386
455
  foregroundOnly ? sql.raw(notBackgroundVisibilitySql()) : undefined,
456
+ excludeAssistantInitiated ? sql.raw(notAssistantInitiatedSql()) : undefined,
387
457
  );
388
458
  }
389
459
 
@@ -699,11 +769,34 @@ export interface ConversationSectionCount {
699
769
  export interface ConversationSectionCounts {
700
770
  groups: Array<{ groupId: string } & ConversationSectionCount>;
701
771
  channels: Array<{ channel: string } & ConversationSectionCount>;
772
+ /**
773
+ * The assistant-initiated section, present only when the caller asked for
774
+ * the split ({@link CountConversationSectionsOptions.splitAssistantInitiated}).
775
+ * Absent — not zero — when it did not, so a caller that never asked cannot
776
+ * mistake "not split out" for "split out and empty".
777
+ *
778
+ * When present, these rows are withheld from `channels`, keeping the two
779
+ * axes disjoint the way `groups` and `channels` already are.
780
+ */
781
+ assistantInitiated?: ConversationSectionCount;
782
+ }
783
+
784
+ export interface CountConversationSectionsOptions {
785
+ /**
786
+ * Count the threads the assistant started on its own as their own section
787
+ * and withhold them from the channel buckets. Off by default: the caller
788
+ * that knows about the `assistant-initiated-threads` flag turns it on, and
789
+ * every other caller keeps today's shape.
790
+ */
791
+ splitAssistantInitiated?: boolean;
702
792
  }
703
793
 
704
- export function countConversationSections(): ConversationSectionCounts {
794
+ export function countConversationSections(
795
+ options: CountConversationSectionsOptions = {},
796
+ ): ConversationSectionCounts {
705
797
  ensureGroupMigration();
706
798
  const db = getDb();
799
+ const { splitAssistantInitiated = false } = options;
707
800
 
708
801
  /* `countUnreadConversations` gets the same effect with an INNER JOIN and
709
802
  the unseen conditions in its WHERE; here every row must be counted and
@@ -747,12 +840,48 @@ export function countConversationSections(): ConversationSectionCounts {
747
840
  conversationTypeClause("standard"),
748
841
  isNull(conversations.archivedAt),
749
842
  groupIdClause(UNGROUPED_GROUP_ID),
843
+ /* The split's other half. These rows are ungrouped and natively
844
+ attributed, so without this they stay in the Chats bucket and get
845
+ counted in both sections at once. */
846
+ splitAssistantInitiated
847
+ ? sql.raw(notAssistantInitiatedSql())
848
+ : undefined,
750
849
  ),
751
850
  )
752
851
  .groupBy(effectiveChannel)
753
852
  .all();
754
853
 
755
- return { groups, channels };
854
+ if (!splitAssistantInitiated) {
855
+ return { groups, channels };
856
+ }
857
+
858
+ /* Composed from the same predicates as the buckets above rather than
859
+ restated, so this section's badge cannot drift from theirs: same standard
860
+ listing visibility, same active-rows-only rule, same unread CASE. */
861
+ const [assistantInitiated] = db
862
+ .select({ total: count(), unread })
863
+ .from(conversations)
864
+ .leftJoin(conversationAssistantAttentionState, attentionJoin)
865
+ .where(
866
+ and(
867
+ conversationTypeClause("standard"),
868
+ isNull(conversations.archivedAt),
869
+ groupIdClause(ASSISTANT_INITIATED_GROUP_ID),
870
+ ),
871
+ )
872
+ .all();
873
+
874
+ return {
875
+ groups,
876
+ channels,
877
+ /* The aggregate is over no GROUP BY, so it always yields one row — but it
878
+ yields `unread: null` rather than 0 when no row matched, because SUM
879
+ over an empty set is NULL. */
880
+ assistantInitiated: {
881
+ total: assistantInitiated?.total ?? 0,
882
+ unread: assistantInitiated?.unread ?? 0,
883
+ },
884
+ };
756
885
  }
757
886
 
758
887
  /**
@@ -1250,10 +1379,6 @@ function wrapRecallEvidenceExcerpt(
1250
1379
  : wrapUntrustedContent(excerpt, { source });
1251
1380
  }
1252
1381
 
1253
- function escapeRegExp(value: string): string {
1254
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1255
- }
1256
-
1257
1382
  /**
1258
1383
  * Earliest case-insensitive match of the query in `text`: the contiguous
1259
1384
  * query when present, otherwise the earliest of its lexical tokens (same
@@ -1275,7 +1400,7 @@ function findEarliestMatch(
1275
1400
  ).exec(text);
1276
1401
  };
1277
1402
 
1278
- const whole = execAnchored(escapeRegExp(query));
1403
+ const whole = execAnchored(RegExp.escape(query));
1279
1404
  if (whole) {
1280
1405
  return { index: whole.index, length: whole[0].length };
1281
1406
  }
@@ -1285,7 +1410,9 @@ function findEarliestMatch(
1285
1410
  if (tokens.length === 0) {
1286
1411
  return null;
1287
1412
  }
1288
- const match = execAnchored(tokens.map(escapeRegExp).join("|"));
1413
+ const match = execAnchored(
1414
+ tokens.map((token) => RegExp.escape(token)).join("|"),
1415
+ );
1289
1416
  return match ? { index: match.index, length: match[0].length } : null;
1290
1417
  }
1291
1418
 
@@ -82,10 +82,7 @@ export function isBackgroundConversationType(
82
82
  * columns into one label.
83
83
  */
84
84
  export type ConversationKind =
85
- | "user"
86
- | "background"
87
- | "background_memory_consolidation"
88
- | "scheduled";
85
+ "user" | "background" | "background_memory_consolidation" | "scheduled";
89
86
 
90
87
  /**
91
88
  * Single classifier shared by the LLM-context routes and the notification
@@ -363,7 +360,9 @@ export function isDesktopOriginatedUserMessage(
363
360
  return false;
364
361
  }
365
362
  const clientOs = parseClientOs((client as Record<string, unknown>).os);
366
- return clientOs === "macos" || clientOs === "windows";
363
+ return (
364
+ clientOs === "macos" || clientOs === "windows" || clientOs === "linux"
365
+ );
367
366
  }
368
367
 
369
368
  /**
@@ -449,6 +448,41 @@ export function isReplyPushIneligibleUserMessage(
449
448
  export const UNGROUPED_GROUP_ID = "system:all";
450
449
  export const PINNED_GROUP_ID = "system:pinned";
451
450
 
451
+ /**
452
+ * The `conversations.source` that makes a thread a member of the
453
+ * assistant-initiated section ({@link ASSISTANT_INITIATED_GROUP_ID}).
454
+ *
455
+ * Opt-in and stamped at creation, by a producer declaring "this is a thread
456
+ * I started because it is worth the user's time" - a heartbeat realization,
457
+ * an observation the user did not ask for. Deliberately NOT the notification
458
+ * pipeline's `'notification'`: the conversations that stamp carries are the
459
+ * transactional flows (guardian call approvals, confirmation / access /
460
+ * question / tool-grant requests, channel delivery trails - the
461
+ * `requiresConversation` producers), which belong to the bell and to Chats,
462
+ * and matching on it would also sweep every historical row into the section
463
+ * the day the flag turns on. A dedicated source makes membership all-new by
464
+ * construction: nothing stamps it until a producer passes it through
465
+ * `conversationMetadata.source` on its notification signal (see
466
+ * `notifications/conversation-pairing.ts`), so the section holds exactly the
467
+ * threads written for it and nothing retroactively.
468
+ */
469
+ // FROZEN: persisted `conversations.source` value. Never rename it.
470
+ export const ASSISTANT_INITIATED_SOURCE = "assistant_initiated";
471
+
472
+ /**
473
+ * The section holding the conversations the assistant started on its own:
474
+ * the threads stamped {@link ASSISTANT_INITIATED_SOURCE} at creation.
475
+ *
476
+ * A pseudo-group in the same sense as {@link UNGROUPED_GROUP_ID}: no row
477
+ * carries it in `group_id`. Those conversations are filed nowhere (NULL
478
+ * `group_id`), so without the split they land in Chats; membership is
479
+ * decided by `source`, not by the column this id names. It is spelled as a
480
+ * group id anyway so a client selects the section through the `groupId`
481
+ * parameter every other section already uses, rather than through a filter
482
+ * only this one section knows about.
483
+ */
484
+ export const ASSISTANT_INITIATED_GROUP_ID = "system:assistant";
485
+
452
486
  /**
453
487
  * The `origin_channel` value for a conversation started in Vellum itself
454
488
  * rather than arriving from an external channel.
@@ -5,10 +5,21 @@
5
5
  * finding messages by source identifiers, and managing raw payload storage.
6
6
  */
7
7
 
8
- import { and, desc, eq, isNotNull, like, ne, or, sql } from "drizzle-orm";
8
+ import {
9
+ and,
10
+ desc,
11
+ eq,
12
+ isNotNull,
13
+ like,
14
+ ne,
15
+ notExists,
16
+ or,
17
+ sql,
18
+ } from "drizzle-orm";
9
19
  import { v4 as uuid } from "uuid";
10
20
 
11
21
  import type { ChannelId } from "../channels/types.js";
22
+ import type { ProviderMessageMetadata } from "../messaging/provider-message-metadata.js";
12
23
  import { readProviderMetadata } from "../messaging/read-provider-metadata.js";
13
24
  import type { SlackInboundMessageMetadata } from "../runtime/http-types.js";
14
25
  import { parseJsonSafe } from "../util/json.js";
@@ -23,6 +34,7 @@ import type { NonScheduledConversationType } from "./conversation-types.js";
23
34
  import { getDb } from "./db-connection.js";
24
35
  import {
25
36
  channelInboundEvents,
37
+ channelOutboundPosts,
26
38
  conversationKeys,
27
39
  conversations,
28
40
  messages,
@@ -55,8 +67,26 @@ const SLACK_LEGACY_THREAD_EVIDENCE_MAX_SCAN = 500;
55
67
  * Bounded on purpose: the scan runs on the inbound path while the gateway
56
68
  * waits for its ack, and reactions land on recent messages, so a cap costs
57
69
  * almost no recall and keeps the cost flat as the database grows.
70
+ *
71
+ * The candidate set is provenance-bearing rows with no inbound-event link
72
+ * (the NOT EXISTS in the query): inbound user rows carry `providerMeta` too,
73
+ * but the inbound-event index already resolves them, so admitting them here
74
+ * would only shrink the window of assistant posts the cap can reach.
75
+ */
76
+ const OUTBOUND_MESSAGE_ID_MAX_SCAN = 800;
77
+ const PROVIDER_MESSAGE_ID_SCAN_BATCH_SIZE = 200;
78
+ const UNRECONCILED_OUTBOUND_ROW_SCAN = 25;
79
+
80
+ /**
81
+ * Widened `maxScan` for the deletion path's TRANSITIONAL fallback. Posts
82
+ * delivered since `channel_outbound_posts` exists resolve through that
83
+ * index exactly, at any age; this bound serves only rows reconciled before
84
+ * the table. A delete can target an arbitrarily old such post, so recency
85
+ * is not a completeness contract, but deletes are rare and run off the
86
+ * gateway-ack hot path, so a deep batched scan is affordable. Delete this
87
+ * (and the fallback scan) once pre-table rows stop mattering.
58
88
  */
59
- const OUTBOUND_MESSAGE_ID_MAX_SCAN = 400;
89
+ export const DELETE_PROVIDER_MESSAGE_ID_MAX_SCAN = 20_000;
60
90
 
61
91
  /**
62
92
  * Channels where an inbound thread id scopes the conversation: a Slack thread
@@ -295,33 +325,173 @@ export function findInboundEvent(
295
325
  }
296
326
 
297
327
  /**
298
- * The conversation holding the message with this provider id, found by
299
- * reading the metadata the assistant's own posts carry, on any channel.
328
+ * The message row carrying this provider id in its stored envelope, on any
329
+ * channel. Returns the same shape as {@link findMessageBySourceId} so the
330
+ * two resolution legs compose: that one covers every message that arrived
331
+ * as an inbound event, and this one covers what the assistant posted, which
332
+ * opens no inbound event and is only identifiable by the id its row carries.
300
333
  *
301
334
  * Reads through `readProviderMetadata`, so it matches any channel that
302
335
  * describes its rows in the neutral shape as well as Slack's own envelope.
303
336
  *
304
- * `findMessageBySourceId` covers every message that arrived as an inbound
305
- * event. It cannot see what the assistant posted, because an outbound reply
306
- * opens no inbound event, so a reaction on the assistant's own message needs
307
- * this. The search is confined to conversations already bound to the same
308
- * channel address and to the most recent
309
- * {@link OUTBOUND_MESSAGE_ID_MAX_SCAN} rows among them; beyond that it gives
310
- * up and the caller drops the annotation, which is the same outcome as never
311
- * finding it at all.
337
+ * The search is confined to conversations already bound to the same
338
+ * channel address and, by default, to the most recent
339
+ * {@link OUTBOUND_MESSAGE_ID_MAX_SCAN} rows among them: reactions land on
340
+ * recent messages and the reaction path runs while the gateway waits for
341
+ * its ack. A caller resolving an event that can target arbitrarily old
342
+ * posts off the hot path (a deletion) widens the window via `maxScan`;
343
+ * beyond whichever bound applies the lookup gives up and the caller drops
344
+ * the annotation, which is the same outcome as never finding it at all.
312
345
  */
313
- export function findConversationByProviderMessageId(
346
+ export function findMessageByProviderMessageId(
314
347
  sourceChannel: string,
315
348
  externalChatId: string,
316
349
  providerMessageId: string,
317
- ): string | null {
350
+ opts?: { maxScan?: number },
351
+ ): { messageId: string; conversationId: string } | null {
318
352
  const db = getDb();
319
- const keyPrefix = `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}`;
320
- const rows = db
353
+
354
+ // The `channel_outbound_posts` index is the resolution contract: exact,
355
+ // unbounded by recency, one indexed read. It covers every post delivered
356
+ // since the table exists; the batched envelope scan below survives only
357
+ // as the transitional fallback for rows reconciled before it.
358
+ const indexed = db
321
359
  .select({
322
- conversationId: messages.conversationId,
323
- metadata: messages.metadata,
360
+ messageId: channelOutboundPosts.messageId,
361
+ conversationId: channelOutboundPosts.conversationId,
324
362
  })
363
+ .from(channelOutboundPosts)
364
+ .where(
365
+ and(
366
+ eq(channelOutboundPosts.sourceChannel, sourceChannel),
367
+ eq(channelOutboundPosts.externalChatId, externalChatId),
368
+ eq(channelOutboundPosts.providerMessageId, providerMessageId),
369
+ ),
370
+ )
371
+ .get();
372
+ if (indexed) {
373
+ return indexed;
374
+ }
375
+
376
+ const keyPrefix = `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}`;
377
+ const maxScan = opts?.maxScan ?? OUTBOUND_MESSAGE_ID_MAX_SCAN;
378
+
379
+ let offset = 0;
380
+ while (offset < maxScan) {
381
+ const batchLimit = Math.min(
382
+ PROVIDER_MESSAGE_ID_SCAN_BATCH_SIZE,
383
+ maxScan - offset,
384
+ );
385
+ const rows = db
386
+ .select({
387
+ id: messages.id,
388
+ conversationId: messages.conversationId,
389
+ metadata: messages.metadata,
390
+ })
391
+ .from(messages)
392
+ .innerJoin(
393
+ conversationKeys,
394
+ eq(conversationKeys.conversationId, messages.conversationId),
395
+ )
396
+ .where(
397
+ and(
398
+ or(
399
+ eq(conversationKeys.conversationKey, keyPrefix),
400
+ like(conversationKeys.conversationKey, `${keyPrefix}:thread:%`),
401
+ ),
402
+ or(
403
+ like(messages.metadata, '%"providerMeta"%'),
404
+ like(messages.metadata, '%"slackMeta"%'),
405
+ ),
406
+ // Rows the inbound-event index resolves are not candidates: this
407
+ // fallback exists for messages `findMessageBySourceId` cannot
408
+ // answer (the assistant's pre-table posts, a crash-window inbound
409
+ // row whose link never landed, and legacy linked rows whose event
410
+ // carries no source_message_id), and admitting resolvable rows
411
+ // would burn the scan budget on messages the primary path already
412
+ // answers.
413
+ notExists(
414
+ db
415
+ .select({ id: channelInboundEvents.id })
416
+ .from(channelInboundEvents)
417
+ .where(
418
+ and(
419
+ eq(channelInboundEvents.messageId, messages.id),
420
+ isNotNull(channelInboundEvents.sourceMessageId),
421
+ ),
422
+ ),
423
+ ),
424
+ ),
425
+ )
426
+ .orderBy(desc(messages.createdAt))
427
+ .limit(batchLimit)
428
+ .offset(offset)
429
+ .all();
430
+
431
+ for (const row of rows) {
432
+ const meta = readProviderMetadata(row.metadata, {
433
+ allowFlatLegacy: true,
434
+ });
435
+ if (
436
+ meta?.conversationExternalId === externalChatId &&
437
+ (meta.messageId === providerMessageId ||
438
+ meta.additionalMessageIds?.includes(providerMessageId))
439
+ ) {
440
+ return { messageId: row.id, conversationId: row.conversationId };
441
+ }
442
+ }
443
+ if (rows.length < batchLimit) {
444
+ return null;
445
+ }
446
+ offset += rows.length;
447
+ }
448
+ return null;
449
+ }
450
+
451
+ /**
452
+ * Record one provider post the assistant's delivery produced, into the
453
+ * `channel_outbound_posts` index. Idempotent: a redelivered id upserts onto
454
+ * the same triple and changes nothing. Written by the post-send
455
+ * reconciliation alongside the row's `providerMeta` envelope; the envelope
456
+ * stays the row's self-description, this table is the resolution index.
457
+ */
458
+ export function recordOutboundPost(post: {
459
+ sourceChannel: string;
460
+ externalChatId: string;
461
+ providerMessageId: string;
462
+ messageId: string;
463
+ conversationId: string;
464
+ }): void {
465
+ const db = getDb();
466
+ db.insert(channelOutboundPosts)
467
+ .values({
468
+ sourceChannel: post.sourceChannel,
469
+ externalChatId: post.externalChatId,
470
+ providerMessageId: post.providerMessageId,
471
+ messageId: post.messageId,
472
+ conversationId: post.conversationId,
473
+ createdAt: Date.now(),
474
+ })
475
+ .onConflictDoNothing()
476
+ .run();
477
+ }
478
+
479
+ /**
480
+ * Whether the chat has a recent outbound row still awaiting its post-send id
481
+ * reconciliation (a neutral envelope naming no `messageId`). The deletion
482
+ * path's evidence that "unresolvable" may mean "not reconciled yet" rather
483
+ * than "never stored": with no such row, a miss is final and the delete
484
+ * returns without paying a retry window. Bounded to the newest handful of
485
+ * rows: the race it detects is one delivery round-trip wide.
486
+ */
487
+ export function hasUnreconciledOutboundRow(
488
+ sourceChannel: string,
489
+ externalChatId: string,
490
+ ): boolean {
491
+ const db = getDb();
492
+ const keyPrefix = `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}`;
493
+ const rows = db
494
+ .select({ metadata: messages.metadata })
325
495
  .from(messages)
326
496
  .innerJoin(
327
497
  conversationKeys,
@@ -333,27 +503,38 @@ export function findConversationByProviderMessageId(
333
503
  eq(conversationKeys.conversationKey, keyPrefix),
334
504
  like(conversationKeys.conversationKey, `${keyPrefix}:thread:%`),
335
505
  ),
336
- or(
337
- like(messages.metadata, '%"providerMeta"%'),
338
- like(messages.metadata, '%"slackMeta"%'),
506
+ like(messages.metadata, '%"providerMeta"%'),
507
+ // Inbound rows carry `providerMeta` too, and a busy chat's newest
508
+ // rows are mostly inbound; excluding rows the inbound-event index
509
+ // resolves keeps this probe's small window counting only rows that
510
+ // could actually be an unreconciled outbound post.
511
+ notExists(
512
+ db
513
+ .select({ id: channelInboundEvents.id })
514
+ .from(channelInboundEvents)
515
+ .where(
516
+ and(
517
+ eq(channelInboundEvents.messageId, messages.id),
518
+ isNotNull(channelInboundEvents.sourceMessageId),
519
+ ),
520
+ ),
339
521
  ),
340
522
  ),
341
523
  )
342
524
  .orderBy(desc(messages.createdAt))
343
- .limit(OUTBOUND_MESSAGE_ID_MAX_SCAN)
525
+ .limit(UNRECONCILED_OUTBOUND_ROW_SCAN)
344
526
  .all();
345
-
346
527
  for (const row of rows) {
347
- const meta = readProviderMetadata(row.metadata, { allowFlatLegacy: true });
528
+ const meta = readProviderMetadata(row.metadata);
348
529
  if (
349
530
  meta?.conversationExternalId === externalChatId &&
350
- (meta.messageId === providerMessageId ||
351
- meta.additionalMessageIds?.includes(providerMessageId))
531
+ meta.eventKind === "message" &&
532
+ meta.messageId === undefined
352
533
  ) {
353
- return row.conversationId;
534
+ return true;
354
535
  }
355
536
  }
356
- return null;
537
+ return false;
357
538
  }
358
539
 
359
540
  export function recordInbound(
@@ -653,6 +834,21 @@ export function storeInboundSlackMetadata(
653
834
  mergeRawPayload(eventId, { slackInbound });
654
835
  }
655
836
 
837
+ /**
838
+ * Persist the neutral channel inbound envelope captured at ingress onto the
839
+ * stored payload, the non-Slack counterpart of
840
+ * {@link storeInboundSlackMetadata}: the retry sweep replays the turn with
841
+ * the SAME `channelInbound` the live path used, so the replayed row carries
842
+ * an identical `providerMeta` envelope. No-ops when the payload was cleared
843
+ * (e.g. a secret-bearing ingress), so cleared secrets are never resurrected.
844
+ */
845
+ export function storeInboundChannelMetadata(
846
+ eventId: string,
847
+ channelInbound: ProviderMessageMetadata,
848
+ ): void {
849
+ mergeRawPayload(eventId, { channelInbound });
850
+ }
851
+
656
852
  /**
657
853
  * Return the `slackStreamMessageTs` durably recorded by any sibling inbound
658
854
  * event linked to the given user message (excluding `excludeEventId`).
@@ -14,8 +14,27 @@ import {
14
14
  RETRY_MAX_ATTEMPTS,
15
15
  retryDelayForAttempt,
16
16
  } from "./job-utils.js";
17
+ import type { ChannelDeliveryStatus } from "./schema.js";
17
18
  import { channelInboundEvents } from "./schema.js";
18
19
 
20
+ /**
21
+ * Whether a delivery status means the row owes no further delivery.
22
+ *
23
+ * A total map over the union rather than a `!== "pending"` comparison: adding
24
+ * a status is then a compile error until its terminality is decided, and that
25
+ * decision is load-bearing. `isDeduplicatedDeliveryOwnedBySibling` reads it to
26
+ * decide whether a sibling event already owns this turn's reply, and the
27
+ * stranded-delivery recovery step treats a non-terminal row as a reply still
28
+ * owed. A new status silently defaulting to one side or the other either
29
+ * double-posts a reply or drops one.
30
+ */
31
+ const DELIVERY_STATUS_IS_TERMINAL: Record<ChannelDeliveryStatus, boolean> = {
32
+ pending: false,
33
+ delivered: true,
34
+ failed: true,
35
+ dead_letter: true,
36
+ };
37
+
19
38
  /**
20
39
  * How long {@link deferRetryUntilIdle} pushes `retryAfter` forward. Shorter than
21
40
  * the retry sweep's own interval so a busy-deferred event is re-evaluated on the
@@ -327,7 +346,7 @@ export function getRetryableDeliveryEvents(limit = 20): Array<{
327
346
  export function getSiblingEventDeliveryStatuses(
328
347
  messageId: string,
329
348
  excludeEventId: string,
330
- ): string[] {
349
+ ): ChannelDeliveryStatus[] {
331
350
  const db = getDb();
332
351
  return db
333
352
  .select({ deliveryStatus: channelInboundEvents.deliveryStatus })
@@ -355,7 +374,7 @@ export function isDeduplicatedDeliveryOwnedBySibling(
355
374
  excludeEventId: string,
356
375
  ): boolean {
357
376
  return getSiblingEventDeliveryStatuses(messageId, excludeEventId).some(
358
- (status) => status !== "pending",
377
+ (status) => DELIVERY_STATUS_IS_TERMINAL[status],
359
378
  );
360
379
  }
361
380