@vellumai/assistant 0.11.3 → 0.11.4-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (324) hide show
  1. package/ARCHITECTURE.md +11 -6
  2. package/docs/architecture/memory.md +11 -0
  3. package/docs/architecture/turn-actor.md +70 -0
  4. package/docs/flux-turn-detection-spike.md +243 -0
  5. package/docs/stt-provider-onboarding.md +3 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  8. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
  9. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  10. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  11. package/openapi.yaml +140 -38
  12. package/package.json +1 -1
  13. package/scripts/voice-ttft-spike.ts +3 -3
  14. package/src/__tests__/app-compiler.test.ts +38 -3
  15. package/src/__tests__/attachments-store.test.ts +21 -12
  16. package/src/__tests__/byok-default-profile-ensure.test.ts +2 -0
  17. package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
  18. package/src/__tests__/call-site-routing-provider.test.ts +1 -1
  19. package/src/__tests__/channel-availability-routes.test.ts +14 -1
  20. package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
  21. package/src/__tests__/channel-delivery-store.test.ts +14 -14
  22. package/src/__tests__/config-loader-backfill.test.ts +3 -3
  23. package/src/__tests__/config-schema.test.ts +25 -10
  24. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
  25. package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
  26. package/src/__tests__/conversation-agent-loop.test.ts +28 -20
  27. package/src/__tests__/conversation-attention-store.test.ts +63 -0
  28. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
  29. package/src/__tests__/conversation-fork-crud.test.ts +69 -0
  30. package/src/__tests__/conversation-fork-referential.test.ts +67 -0
  31. package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
  32. package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
  33. package/src/__tests__/conversation-queue.test.ts +55 -4
  34. package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
  35. package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
  36. package/src/__tests__/credential-prompt-route.test.ts +7 -10
  37. package/src/__tests__/custom-profile-ensure.test.ts +5 -1
  38. package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
  39. package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
  40. package/src/__tests__/document-append-idempotency.test.ts +233 -0
  41. package/src/__tests__/edit-propagation.test.ts +0 -7
  42. package/src/__tests__/helpers/mock-actor-context.ts +49 -0
  43. package/src/__tests__/helpers/mock-conversation.ts +13 -1
  44. package/src/__tests__/injector-chain.test.ts +63 -41
  45. package/src/__tests__/injector-disk-pressure.test.ts +11 -23
  46. package/src/__tests__/llm-context-resolution.test.ts +73 -1
  47. package/src/__tests__/llm-schema.test.ts +5 -2
  48. package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
  49. package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
  50. package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
  51. package/src/__tests__/mtime-cache.test.ts +1 -1
  52. package/src/__tests__/non-member-access-request.test.ts +0 -20
  53. package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
  54. package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
  55. package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
  56. package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
  57. package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
  58. package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
  59. package/src/__tests__/scoped-approval-grants.test.ts +11 -6
  60. package/src/__tests__/secret-ingress-channel.test.ts +0 -1
  61. package/src/__tests__/skills.test.ts +32 -0
  62. package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
  63. package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
  64. package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
  65. package/src/__tests__/turn-events-store.test.ts +43 -0
  66. package/src/__tests__/user-plugin-loader.test.ts +1 -1
  67. package/src/__tests__/visible-app-context.test.ts +16 -9
  68. package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
  69. package/src/__tests__/worker-plugin-surface.test.ts +77 -0
  70. package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
  71. package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
  72. package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
  73. package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
  74. package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
  75. package/src/acp/__tests__/auth-required.test.ts +161 -0
  76. package/src/acp/acp-claude-oauth.ts +19 -2
  77. package/src/acp/agent-process.test.ts +100 -0
  78. package/src/acp/agent-process.ts +29 -26
  79. package/src/acp/auth-required.ts +102 -0
  80. package/src/acp/session-manager.test.ts +119 -0
  81. package/src/acp/session-manager.ts +68 -2
  82. package/src/api/events/acp-auth-required.ts +55 -0
  83. package/src/api/index.ts +7 -0
  84. package/src/apps/app-store.ts +3 -0
  85. package/src/bundler/package-resolver.ts +2 -30
  86. package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
  87. package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
  88. package/src/calls/call-controller.ts +9 -2
  89. package/src/calls/call-setup-flow.ts +0 -1
  90. package/src/calls/media-stream-stt-session.ts +15 -0
  91. package/src/calls/voice-session-bridge.ts +71 -16
  92. package/src/calls/voice-triage-escalate.ts +104 -2
  93. package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
  94. package/src/channels/config.ts +13 -0
  95. package/src/channels/plugin-channel-declarations.ts +108 -0
  96. package/src/channels/types.ts +30 -0
  97. package/src/cli/AGENTS.md +5 -2
  98. package/src/cli/commands/credentials.help.ts +2 -2
  99. package/src/cli/commands/inference-providers.ts +1 -1
  100. package/src/cli/commands/mcp.help.ts +13 -4
  101. package/src/cli/commands/mcp.ts +9 -0
  102. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
  103. package/src/cli/commands/memory/index.help.ts +43 -1
  104. package/src/cli/commands/memory/memory-v3.ts +64 -0
  105. package/src/cli/commands/stt.help.ts +27 -2
  106. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
  107. package/src/cli/lib/bundled-marketplace.json +13 -0
  108. package/src/cli/lib/upgrade-plugin.ts +42 -0
  109. package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
  110. package/src/config/__tests__/default-provider.test.ts +6 -1
  111. package/src/config/__tests__/profile-materialization.test.ts +75 -19
  112. package/src/config/bundled-skills/acp/SKILL.md +6 -7
  113. package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
  114. package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
  115. package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
  116. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  117. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
  118. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
  119. package/src/config/call-site-defaults.ts +4 -5
  120. package/src/config/default-profile-catalog.ts +82 -11
  121. package/src/config/default-profile-names.ts +4 -1
  122. package/src/config/default-provider-resolution.ts +4 -0
  123. package/src/config/llm-context-resolution.ts +11 -3
  124. package/src/config/llm-resolver.ts +28 -1
  125. package/src/config/profile-materialization.ts +70 -22
  126. package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
  127. package/src/config/schemas/call-site-catalog.ts +4 -4
  128. package/src/config/schemas/live-voice.ts +57 -23
  129. package/src/config/schemas/llm.ts +59 -32
  130. package/src/config/schemas/mcp.ts +23 -0
  131. package/src/config/schemas/plugin-updates.ts +6 -2
  132. package/src/config/schemas/stt.ts +1 -0
  133. package/src/context/outbound-sanitize.ts +96 -1
  134. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
  135. package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
  136. package/src/daemon/conversation-agent-loop.ts +17 -6
  137. package/src/daemon/conversation-messaging.ts +5 -1
  138. package/src/daemon/conversation-notifiers.ts +9 -1
  139. package/src/daemon/conversation-process.ts +9 -6
  140. package/src/daemon/conversation-runtime-assembly.ts +3 -4
  141. package/src/daemon/conversation-tool-setup.ts +1 -2
  142. package/src/daemon/conversation.ts +48 -0
  143. package/src/daemon/mcp-reload-service.ts +36 -6
  144. package/src/daemon/process-message.ts +13 -3
  145. package/src/daemon/providers-setup.ts +6 -3
  146. package/src/daemon/trust-context-types.ts +29 -0
  147. package/src/daemon/wake-conversation-ops.ts +3 -2
  148. package/src/documents/document-store.ts +138 -5
  149. package/src/hooks/hook-loader.ts +3 -3
  150. package/src/hooks/registry.ts +50 -6
  151. package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
  152. package/src/inbound/oauth-callback-url.ts +61 -0
  153. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
  154. package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
  155. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
  156. package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
  157. package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
  158. package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
  159. package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
  160. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
  161. package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
  162. package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
  163. package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
  164. package/src/live-voice/live-voice-archive.ts +2 -0
  165. package/src/live-voice/live-voice-metrics.ts +57 -32
  166. package/src/live-voice/live-voice-photo.ts +1 -2
  167. package/src/live-voice/live-voice-session.ts +535 -314
  168. package/src/live-voice/progress-narration.ts +277 -0
  169. package/src/live-voice/protocol.ts +21 -1
  170. package/src/mcp/__tests__/effective-config.test.ts +238 -0
  171. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
  172. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
  173. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
  174. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
  175. package/src/mcp/client.ts +16 -11
  176. package/src/mcp/effective-config.ts +113 -0
  177. package/src/mcp/manager.ts +11 -6
  178. package/src/mcp/mcp-auth-orchestrator.ts +13 -22
  179. package/src/mcp/mcp-oauth-provider.ts +205 -240
  180. package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
  181. package/src/monitoring/plugin-auto-update.ts +128 -24
  182. package/src/notifications/signal.ts +1 -0
  183. package/src/permissions/confirmation-guardian-request.test.ts +15 -11
  184. package/src/permissions/confirmation-guardian-request.ts +2 -2
  185. package/src/permissions/question-guardian-request.test.ts +14 -6
  186. package/src/permissions/question-guardian-request.ts +1 -2
  187. package/src/persistence/attachments-store.ts +8 -1
  188. package/src/persistence/bookmark-crud.ts +3 -7
  189. package/src/persistence/conversation-attention-store.ts +16 -45
  190. package/src/persistence/conversation-crud.ts +33 -4
  191. package/src/persistence/conversation-lineage.ts +9 -0
  192. package/src/persistence/conversation-queries.ts +108 -41
  193. package/src/persistence/delivery-crud.ts +38 -29
  194. package/src/persistence/external-conversation-store.ts +32 -4
  195. package/src/persistence/llm-request-log-store.ts +4 -10
  196. package/src/persistence/llm-usage-store.ts +8 -3
  197. package/src/persistence/message-reads.test.ts +197 -0
  198. package/src/persistence/message-reads.ts +211 -0
  199. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
  200. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
  201. package/src/persistence/real-user-turn-filter.ts +27 -3
  202. package/src/persistence/steps.ts +9 -0
  203. package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
  204. package/src/plugin-api/conversation-turn.ts +168 -5
  205. package/src/plugin-api/index.ts +21 -5
  206. package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
  207. package/src/plugins/defaults/memory/AGENTS.md +4 -0
  208. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
  209. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
  210. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
  211. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
  212. package/src/plugins/defaults/memory/buffer-format.ts +165 -0
  213. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
  214. package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
  215. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
  216. package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
  217. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
  218. package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
  219. package/src/plugins/defaults/memory/indexer.ts +3 -1
  220. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
  221. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
  222. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
  223. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
  224. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
  225. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
  226. package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
  227. package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
  228. package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
  229. package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
  230. package/src/plugins/defaults/memory/v3/prune.ts +2 -0
  231. package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
  232. package/src/plugins/defaults/memory/worker.ts +6 -3
  233. package/src/plugins/external-plugin-loader.ts +47 -0
  234. package/src/plugins/mcp-servers.ts +361 -0
  235. package/src/plugins/mtime-cache.ts +23 -49
  236. package/src/plugins/worker-plugin-surface.ts +33 -0
  237. package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
  238. package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
  239. package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
  240. package/src/providers/__tests__/retry-callsite.test.ts +5 -2
  241. package/src/providers/call-site-routing.ts +30 -3
  242. package/src/providers/connection-resolution.ts +194 -11
  243. package/src/providers/inference/auth.ts +6 -0
  244. package/src/providers/inference/connection-availability.ts +24 -2
  245. package/src/providers/inference/connections.ts +2 -0
  246. package/src/providers/model-intents.ts +26 -6
  247. package/src/providers/openai/codex-models.ts +2 -1
  248. package/src/providers/provider-send-message.ts +32 -3
  249. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
  250. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
  251. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
  252. package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
  253. package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
  254. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
  255. package/src/providers/speech-to-text/provider-catalog.ts +99 -8
  256. package/src/providers/speech-to-text/resolve.ts +25 -2
  257. package/src/routes/worker.ts +17 -5
  258. package/src/runtime/access-request-helper.ts +9 -12
  259. package/src/runtime/agent-wake.ts +3 -3
  260. package/src/runtime/pre-first-message-gate.ts +4 -0
  261. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
  262. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
  263. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
  264. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
  265. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
  266. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
  267. package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
  268. package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
  269. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
  270. package/src/runtime/routes/channel-availability-routes.ts +32 -14
  271. package/src/runtime/routes/channel-route-shared.ts +0 -6
  272. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
  273. package/src/runtime/routes/conversation-list-routes.ts +112 -1
  274. package/src/runtime/routes/conversation-query-routes.ts +40 -27
  275. package/src/runtime/routes/credential-prompt-routes.ts +4 -7
  276. package/src/runtime/routes/default-provider-routes.ts +15 -0
  277. package/src/runtime/routes/inbound-message-handler.ts +17 -41
  278. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
  279. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
  280. package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
  281. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
  282. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
  283. package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
  284. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
  285. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
  286. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
  287. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
  288. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
  289. package/src/runtime/routes/inference-profiles-routes.ts +20 -11
  290. package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
  291. package/src/runtime/routes/log-export-routes.ts +3 -0
  292. package/src/runtime/routes/mcp-auth-routes.ts +148 -57
  293. package/src/runtime/routes/plugins-routes.ts +21 -3
  294. package/src/runtime/routes/stt-routes.ts +31 -25
  295. package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
  296. package/src/runtime/routes/user-route-dispatcher.ts +39 -14
  297. package/src/runtime/routes/user-route-import.ts +108 -0
  298. package/src/schedule/worker.ts +6 -0
  299. package/src/security/oauth2.ts +6 -22
  300. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
  301. package/src/stt/__tests__/types.test.ts +94 -0
  302. package/src/stt/daemon-batch-transcriber.ts +10 -0
  303. package/src/stt/stt-stream-session.ts +8 -4
  304. package/src/stt/types.ts +103 -0
  305. package/src/subagent/manager.ts +1 -3
  306. package/src/subagent/types.ts +7 -6
  307. package/src/tools/acp/spawn.test.ts +97 -0
  308. package/src/tools/acp/spawn.ts +32 -0
  309. package/src/tools/document/document-tool.ts +12 -3
  310. package/src/tools/registry.ts +2 -1
  311. package/src/tools/workflows/run-workflow.ts +1 -2
  312. package/src/tts/__tests__/reasoning-tag-filter.test.ts +63 -0
  313. package/src/tts/reasoning-tag-filter.ts +110 -0
  314. package/src/workspace/byok-default-profile-ensure.ts +61 -18
  315. package/src/workspace/custom-profile-ensure.ts +4 -24
  316. package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
  317. package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
  318. package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
  319. package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
  320. package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
  321. package/src/workspace/migrations/registry.ts +8 -0
  322. package/src/workspace/provider-commit-message-generator.ts +7 -5
  323. package/src/live-voice/__tests__/front-decision.test.ts +0 -645
  324. package/src/live-voice/front-decision.ts +0 -476
@@ -3,6 +3,7 @@ import { v4 as uuid } from "uuid";
3
3
 
4
4
  import type { DrizzleDb } from "./db-connection.js";
5
5
  import { stringifyMessageContent } from "./message-content.js";
6
+ import { messageConversationId } from "./message-reads.js";
6
7
  import { conversations, messageBookmarks, messages } from "./schema.js";
7
8
 
8
9
  /**
@@ -138,15 +139,10 @@ export function createBookmark(
138
139
  params: { messageId: string },
139
140
  ): CreateBookmarkResult {
140
141
  const { messageId } = params;
141
- const message = db
142
- .select({ conversationId: messages.conversationId })
143
- .from(messages)
144
- .where(eq(messages.id, messageId))
145
- .get();
146
- if (!message) {
142
+ const conversationId = messageConversationId(messageId, { db });
143
+ if (conversationId === null) {
147
144
  throw new Error(`Message ${messageId} not found`);
148
145
  }
149
- const conversationId = message.conversationId;
150
146
 
151
147
  const existing = db
152
148
  .select({ id: messageBookmarks.id })
@@ -21,11 +21,14 @@ import { v4 as uuid } from "uuid";
21
21
 
22
22
  import { UserError } from "../util/errors.js";
23
23
  import { getDb } from "./db-connection.js";
24
+ import {
25
+ latestAssistantMessage,
26
+ latestAssistantMessageBefore,
27
+ } from "./message-reads.js";
24
28
  import {
25
29
  conversationAssistantAttentionState,
26
30
  conversationAttentionEvents,
27
31
  conversations,
28
- messages,
29
32
  } from "./schema/index.js";
30
33
 
31
34
  // ── Types ────────────────────────────────────────────────────────────
@@ -361,19 +364,11 @@ export function recordConversationSeenSignal(params: {
361
364
  if (!state) {
362
365
  // No state row yet — look up the conversation's latest assistant message so
363
366
  // upgraded databases (with existing messages but no attention row) correctly
364
- // initialize the full state on the first seen signal.
365
- const latestMsg = tx
366
- .select({ id: messages.id, createdAt: messages.createdAt })
367
- .from(messages)
368
- .where(
369
- and(
370
- eq(messages.conversationId, conversationId),
371
- eq(messages.role, "assistant"),
372
- ),
373
- )
374
- .orderBy(desc(messages.createdAt))
375
- .limit(1)
376
- .get();
367
+ // initialize the full state on the first seen signal. Finalized-only via
368
+ // the accessor: a still-streaming reply is not a reply the user has seen,
369
+ // and anchoring the watermark on it would classify the finished reply as
370
+ // seen the moment it completes.
371
+ const latestMsg = latestAssistantMessage(conversationId, { db: tx });
377
372
 
378
373
  const latestMsgId = latestMsg?.id ?? null;
379
374
  const latestMsgAt = latestMsg?.createdAt ?? null;
@@ -461,26 +456,11 @@ function resolveAssistantCursor(params: {
461
456
  latestAssistantMessageAt,
462
457
  } = params;
463
458
 
464
- // Unread classification compares timestamps strictly, so rewinding to a
465
- // same-timestamp sibling would leave the latest reply classified as seen.
466
- const previousAssistantMessageBefore = (before: number) =>
467
- db
468
- .select({ id: messages.id, createdAt: messages.createdAt })
469
- .from(messages)
470
- .where(
471
- and(
472
- eq(messages.conversationId, conversationId),
473
- eq(messages.role, "assistant"),
474
- lt(messages.createdAt, before),
475
- ),
476
- )
477
- .orderBy(desc(messages.createdAt), desc(messages.id))
478
- .limit(1)
479
- .get();
480
-
481
459
  if (latestAssistantMessageId && latestAssistantMessageAt != null) {
482
- const previousMessage = previousAssistantMessageBefore(
460
+ const previousMessage = latestAssistantMessageBefore(
461
+ conversationId,
483
462
  latestAssistantMessageAt,
463
+ { db },
484
464
  );
485
465
 
486
466
  return {
@@ -491,25 +471,16 @@ function resolveAssistantCursor(params: {
491
471
  };
492
472
  }
493
473
 
494
- const latestMessage = db
495
- .select({ id: messages.id, createdAt: messages.createdAt })
496
- .from(messages)
497
- .where(
498
- and(
499
- eq(messages.conversationId, conversationId),
500
- eq(messages.role, "assistant"),
501
- ),
502
- )
503
- .orderBy(desc(messages.createdAt), desc(messages.id))
504
- .limit(1)
505
- .get();
474
+ const latestMessage = latestAssistantMessage(conversationId, { db });
506
475
 
507
476
  if (!latestMessage) {
508
477
  return null;
509
478
  }
510
479
 
511
- const previousMessage = previousAssistantMessageBefore(
480
+ const previousMessage = latestAssistantMessageBefore(
481
+ conversationId,
512
482
  latestMessage.createdAt,
483
+ { db },
513
484
  );
514
485
  return {
515
486
  latestAssistantMessageId: latestMessage.id,
@@ -498,6 +498,25 @@ export function parseMessageMetadata(
498
498
  }
499
499
  }
500
500
 
501
+ /**
502
+ * Rows a fork may copy: finalized ones. A `finalized = 0` row is mid-write,
503
+ * its content a `{ ref }` into the source turn's in-flight delta file, which
504
+ * the source deletes when the turn completes. A copied row would point at a
505
+ * file another conversation is about to delete, leaving a permanently empty
506
+ * message that reads as final and is invisible to crash recovery (which scans
507
+ * `finalized = 0` only). Unfinalized is not "unsent": the message was sent
508
+ * and is still being written, so there is no completed content to copy. A
509
+ * fork reflects the conversation at a completed point.
510
+ *
511
+ * Callers apply this to `messagesToCopy` itself, upstream of the boundary
512
+ * timestamp, compaction inheritance, `forkParentMessageId`, and the id map,
513
+ * so every derivation describes the rows actually copied and the lineage
514
+ * anchor can never reference a row the fork does not hold.
515
+ */
516
+ function dropUnfinalizedRows(rows: MessageRow[]): MessageRow[] {
517
+ return rows.filter((message) => message.finalized === 1);
518
+ }
519
+
501
520
  function cloneForkMessageMetadata(
502
521
  metadata: string | null,
503
522
  sourceMessageId: string,
@@ -1336,7 +1355,7 @@ export function forkConversation(params: {
1336
1355
 
1337
1356
  const messagesToCopy =
1338
1357
  copyBoundaryIndex >= 0
1339
- ? sourceMessages.slice(0, copyBoundaryIndex + 1)
1358
+ ? dropUnfinalizedRows(sourceMessages.slice(0, copyBoundaryIndex + 1))
1340
1359
  : ([] as MessageRow[]);
1341
1360
 
1342
1361
  // Inherit the history-strip marker only when the fork boundary is at-or-
@@ -1461,7 +1480,13 @@ export function forkConversation(params: {
1461
1480
  messagesToCopy,
1462
1481
  forkedMessageIds,
1463
1482
  latestForkedAssistant,
1464
- isFullHistoryFork: copyBoundaryIndex === sourceMessages.length - 1,
1483
+ // The wholesale memory-state carry requires the fork's window to equal
1484
+ // the source's rendered window; a boundary at the tip no longer
1485
+ // guarantees that when the unfinalized filter dropped rows from the
1486
+ // slice, so both conditions gate it.
1487
+ isFullHistoryFork:
1488
+ copyBoundaryIndex === sourceMessages.length - 1 &&
1489
+ messagesToCopy.length === copyBoundaryIndex + 1,
1465
1490
  inheritedCompactedMessageCount:
1466
1491
  inheritedCompaction?.compactedMessageCount ?? 0,
1467
1492
  diskSyncQueue,
@@ -1772,7 +1797,7 @@ export async function forkConversationForRetrospective(params: {
1772
1797
  );
1773
1798
  const messagesToCopy =
1774
1799
  copyBoundaryIndex >= 0
1775
- ? sourceMessages.slice(0, copyBoundaryIndex + 1)
1800
+ ? dropUnfinalizedRows(sourceMessages.slice(0, copyBoundaryIndex + 1))
1776
1801
  : ([] as MessageRow[]);
1777
1802
 
1778
1803
  const sourceHistoryStrippedAt = sourceConversation.historyStrippedAt ?? null;
@@ -1936,8 +1961,12 @@ export async function forkConversationForRetrospective(params: {
1936
1961
  messagesToCopy: rowsToCopy,
1937
1962
  forkedMessageIds,
1938
1963
  latestForkedAssistant,
1964
+ // Both conditions, for the same reason as `forkConversation`:
1965
+ // a tip boundary no longer implies an equal window once the
1966
+ // unfinalized filter dropped rows from the slice.
1939
1967
  isFullHistoryFork:
1940
- copyBoundaryIndex === sourceMessages.length - 1,
1968
+ copyBoundaryIndex === sourceMessages.length - 1 &&
1969
+ messagesToCopy.length === copyBoundaryIndex + 1,
1941
1970
  // The copied range already starts at the visible window.
1942
1971
  inheritedCompactedMessageCount: forkCompactedMessageCount,
1943
1972
  skipCompactionLedgerCopy: inheritedCompaction != null,
@@ -184,6 +184,14 @@ export function isSingleSegmentLineage(segments: LineageSegment[]): boolean {
184
184
  * strictly-earlier `createdAt`, or an equal `createdAt` with an id that does
185
185
  * not sort after the bound. The fork message itself is included, because a
186
186
  * fork is taken THROUGH that message.
187
+ *
188
+ * Ancestor segments additionally exclude unfinalized rows. An ancestor's
189
+ * `finalized = 0` row is a message its own live turn is still writing; a
190
+ * descendant reading it would see partial content that the ancestor rewrites
191
+ * inline when the turn completes. The row enters the window on its own once
192
+ * it finalizes. A segment with no bound is the conversation's own tail and
193
+ * keeps its unfinalized rows: a conversation always sees its own in-flight
194
+ * message.
187
195
  */
188
196
  export function lineageMessageFilter(segments: LineageSegment[]): SQL {
189
197
  const clauses = segments.map((segment) => {
@@ -194,6 +202,7 @@ export function lineageMessageFilter(segments: LineageSegment[]): SQL {
194
202
  const { createdAt, id } = segment.through;
195
203
  return and(
196
204
  owner,
205
+ eq(messages.finalized, 1),
197
206
  or(
198
207
  lt(messages.createdAt, createdAt),
199
208
  and(
@@ -1,4 +1,4 @@
1
- import { and, count, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm";
1
+ import { and, count, desc, eq, isNull, lt, sql } from "drizzle-orm";
2
2
 
3
3
  import {
4
4
  parseExternalContentEnvelope,
@@ -26,11 +26,14 @@ import {
26
26
  parseContentRef,
27
27
  resolveMessageContentBlocks,
28
28
  } from "./message-content-file.js";
29
+ import {
30
+ countMessagesByRoleForConversations,
31
+ latestUserMessageRawContent,
32
+ } from "./message-reads.js";
29
33
  import { rawAll } from "./raw-query.js";
30
34
  import {
31
35
  conversationAssistantAttentionState,
32
36
  conversations,
33
- messages,
34
37
  } from "./schema/index.js";
35
38
 
36
39
  const log = getLogger("conversation-store");
@@ -438,31 +441,7 @@ export function getMessageRoleStatsByConversation(
438
441
  conversationIds: string[],
439
442
  role: string = "assistant",
440
443
  ): Map<string, { count: number; lastAt: number }> {
441
- if (conversationIds.length === 0) {
442
- return new Map();
443
- }
444
- const db = getDb();
445
- const rows = db
446
- .select({
447
- conversationId: messages.conversationId,
448
- count: sql<number>`COUNT(*)`.as("count"),
449
- lastAt: sql<number>`MAX(${messages.createdAt})`.as("last_at"),
450
- })
451
- .from(messages)
452
- .where(
453
- and(
454
- inArray(messages.conversationId, conversationIds),
455
- eq(messages.role, role),
456
- ),
457
- )
458
- .groupBy(messages.conversationId)
459
- .all();
460
- return new Map(
461
- rows.map((r) => [
462
- r.conversationId,
463
- { count: Number(r.count), lastAt: Number(r.lastAt) },
464
- ]),
465
- );
444
+ return countMessagesByRoleForConversations(conversationIds, role);
466
445
  }
467
446
 
468
447
  export function listPinnedConversations(
@@ -535,6 +514,8 @@ export function listConversationsByTitlePrefix(
535
514
  const rows = rawAll<Row>(
536
515
  "conversation:listByTitlePrefix",
537
516
  `SELECT c.id, c.title,
517
+ -- Any-state count (streaming rows included): a list-surface size
518
+ -- hint where a transient +1 during a live turn is immaterial.
538
519
  (SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count,
539
520
  c.created_at
540
521
  FROM conversations c
@@ -617,32 +598,114 @@ export function countUnreadConversations(): number {
617
598
  return total;
618
599
  }
619
600
 
601
+ /** Totals for one sidebar section: every member, and the unread among them. */
602
+ export interface ConversationSectionCount {
603
+ total: number;
604
+ unread: number;
605
+ }
606
+
620
607
  /**
621
- * Check whether the last user message in a conversation is a tool_result-only
622
- * message (i.e., not a real user-typed message). This is used by undo() to
623
- * determine if additional exchanges need to be deleted from the DB.
608
+ * Row counts for every non-empty sidebar section, with no rows fetched.
609
+ *
610
+ * Two axes, disjoint by construction because `group_id` is single-valued:
611
+ *
612
+ * - `groups`: rows filed somewhere, bucketed by `group_id`. Contains
613
+ * `'system:pinned'` and custom-group ids; the background/scheduled system
614
+ * buckets are excluded because no section renders them.
615
+ * - `channels`: rows filed nowhere, bucketed by effective origin channel,
616
+ * where NULL reads as {@link NATIVE_ORIGIN_CHANNEL} exactly as
617
+ * {@link originChannelClause} reads it. The native bucket is the Chats
618
+ * section; each other bucket is that channel's section.
619
+ *
620
+ * Only buckets with at least one row appear: GROUP BY cannot emit an empty
621
+ * bucket, and an empty section renders no card.
622
+ *
623
+ * `total` follows the standard-listing visibility
624
+ * ({@link conversationTypeClause}), active rows only, so it counts exactly
625
+ * the rows `GET /v1/conversations` would return for that section's filter.
626
+ * `unread` applies the same rules as {@link countUnreadConversations} on top
627
+ * (not an unsurfaced background/scheduled row, unseen per the attention
628
+ * projection), composed from the same predicates rather than restated, so
629
+ * the per-section numbers always sum against the global count.
624
630
  */
625
- export function isLastUserMessageToolResult(conversationId: string): boolean {
631
+ export interface ConversationSectionCounts {
632
+ groups: Array<{ groupId: string } & ConversationSectionCount>;
633
+ channels: Array<{ channel: string } & ConversationSectionCount>;
634
+ }
635
+
636
+ export function countConversationSections(): ConversationSectionCounts {
637
+ ensureGroupMigration();
626
638
  const db = getDb();
627
- const lastUserMsg = db
628
- .select({ content: messages.content })
629
- .from(messages)
639
+
640
+ /* `countUnreadConversations` gets the same effect with an INNER JOIN and
641
+ the unseen conditions in its WHERE; here every row must be counted and
642
+ only unread rows summed, so the join is LEFT and the conditions move
643
+ into the CASE. A row with no attention projection has NULL columns,
644
+ fails the conditions, and sums 0: identical membership. */
645
+ const unread = sql<number>`SUM(CASE WHEN ${and(
646
+ sql.raw(notBackgroundVisibilitySql()),
647
+ ...unseenAttentionStateConditions(),
648
+ )} THEN 1 ELSE 0 END)`;
649
+
650
+ const attentionJoin = eq(
651
+ conversationAssistantAttentionState.conversationId,
652
+ conversations.id,
653
+ );
654
+
655
+ const groups = db
656
+ .select({
657
+ groupId: sql<string>`group_id`,
658
+ total: count(),
659
+ unread,
660
+ })
661
+ .from(conversations)
662
+ .leftJoin(conversationAssistantAttentionState, attentionJoin)
630
663
  .where(
631
664
  and(
632
- eq(messages.conversationId, conversationId),
633
- eq(messages.role, "user"),
665
+ conversationTypeClause("standard"),
666
+ isNull(conversations.archivedAt),
667
+ sql`(group_id = ${PINNED_GROUP_ID} OR (group_id IS NOT NULL AND group_id NOT LIKE 'system:%'))`,
634
668
  ),
635
669
  )
636
- .orderBy(sql`rowid DESC`)
637
- .limit(1)
638
- .get();
670
+ .groupBy(sql`group_id`)
671
+ .all();
672
+
673
+ const effectiveChannel = sql<string>`COALESCE(${conversations.originChannel}, ${NATIVE_ORIGIN_CHANNEL})`;
674
+ const channels = db
675
+ .select({
676
+ channel: effectiveChannel,
677
+ total: count(),
678
+ unread,
679
+ })
680
+ .from(conversations)
681
+ .leftJoin(conversationAssistantAttentionState, attentionJoin)
682
+ .where(
683
+ and(
684
+ conversationTypeClause("standard"),
685
+ isNull(conversations.archivedAt),
686
+ groupIdClause(UNGROUPED_GROUP_ID),
687
+ ),
688
+ )
689
+ .groupBy(effectiveChannel)
690
+ .all();
691
+
692
+ return { groups, channels };
693
+ }
694
+
695
+ /**
696
+ * Check whether the last user message in a conversation is a tool_result-only
697
+ * message (i.e., not a real user-typed message). This is used by undo() to
698
+ * determine if additional exchanges need to be deleted from the DB.
699
+ */
700
+ export function isLastUserMessageToolResult(conversationId: string): boolean {
701
+ const lastUserContent = latestUserMessageRawContent(conversationId);
639
702
 
640
- if (!lastUserMsg) {
703
+ if (lastUserContent === null) {
641
704
  return false;
642
705
  }
643
706
 
644
707
  try {
645
- const parsed = JSON.parse(lastUserMsg.content);
708
+ const parsed = JSON.parse(lastUserContent);
646
709
  if (
647
710
  Array.isArray(parsed) &&
648
711
  parsed.length > 0 &&
@@ -816,6 +879,10 @@ export async function searchConversations(
816
879
  if (!opts?.includeArchived) {
817
880
  whereClauses.push(`c.archived_at IS NULL`);
818
881
  }
882
+ // No completeness predicate: candidate ids come from the lexical
883
+ // index, which filters finalized = 1 at index time, so an unfinalized
884
+ // row cannot be a candidate. Do not copy this shape for id sets with
885
+ // different provenance.
819
886
  const visibleRows = rawAll<CandidateRow>(
820
887
  "conversation:searchConversations:visibleCandidates",
821
888
  `
@@ -8,8 +8,8 @@
8
8
  import { and, desc, eq, isNotNull, ne, or, sql } from "drizzle-orm";
9
9
  import { v4 as uuid } from "uuid";
10
10
 
11
+ import type { ChannelId } from "../channels/types.js";
11
12
  import { readSlackMetadataFromMessageMetadata } from "../messaging/providers/slack/message-metadata.js";
12
- import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
13
13
  import type { SlackInboundMessageMetadata } from "../runtime/http-types.js";
14
14
  import { parseJsonSafe } from "../util/json.js";
15
15
  import { isPlainObject } from "../util/object.js";
@@ -19,6 +19,7 @@ import {
19
19
  getOrCreateConversation,
20
20
  setConversationKeyIfAbsent,
21
21
  } from "./conversation-key-store.js";
22
+ import type { NonScheduledConversationType } from "./conversation-types.js";
22
23
  import { getDb } from "./db-connection.js";
23
24
  import { channelInboundEvents, conversations } from "./schema.js";
24
25
 
@@ -31,7 +32,6 @@ export interface InboundResult {
31
32
 
32
33
  export interface RecordInboundOptions {
33
34
  sourceMessageId?: string;
34
- assistantId?: string;
35
35
  sourceThreadId?: string;
36
36
  }
37
37
 
@@ -45,30 +45,25 @@ const SLACK_LEGACY_THREAD_EVIDENCE_MAX_SCAN = 500;
45
45
  */
46
46
  const THREAD_SCOPED_CHANNELS = new Set(["slack", "telegram"]);
47
47
 
48
- function buildScopedConversationKeyForAssistant(
49
- assistantId: string,
48
+ /**
49
+ * Scope prefix on every conversation key.
50
+ *
51
+ * Part of the stored key format, so it is fixed: rows are written under
52
+ * `asst:self:` and stop resolving if it changes. The daemon is single-tenant
53
+ * and scopes all its storage to `self` (`DAEMON_INTERNAL_ASSISTANT_ID`).
54
+ */
55
+ const CONVERSATION_KEY_SCOPE = "asst:self";
56
+
57
+ export function buildScopedConversationKey(
50
58
  sourceChannel: string,
51
59
  externalChatId: string,
52
60
  sourceThreadId?: string | null,
53
61
  ): string {
54
62
  const threadId = sourceThreadId?.trim();
55
63
  if (THREAD_SCOPED_CHANNELS.has(sourceChannel) && threadId) {
56
- return `asst:${assistantId}:${sourceChannel}:${externalChatId}:thread:${threadId}`;
64
+ return `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}:thread:${threadId}`;
57
65
  }
58
- return `asst:${assistantId}:${sourceChannel}:${externalChatId}`;
59
- }
60
-
61
- export function buildScopedConversationKey(
62
- sourceChannel: string,
63
- externalChatId: string,
64
- sourceThreadId?: string | null,
65
- ): string {
66
- return buildScopedConversationKeyForAssistant(
67
- DAEMON_INTERNAL_ASSISTANT_ID,
68
- sourceChannel,
69
- externalChatId,
70
- sourceThreadId,
71
- );
66
+ return `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}`;
72
67
  }
73
68
 
74
69
  function legacySlackConversationHasThreadEvidence(
@@ -135,14 +130,24 @@ function legacySlackConversationHasThreadEvidence(
135
130
  return false;
136
131
  }
137
132
 
138
- function resolveInboundConversation(
139
- assistantId: string,
133
+ /**
134
+ * Applied only where this call is what creates the conversation. An address
135
+ * that already resolves is an existing conversation, and neither its type nor
136
+ * where it came from is the current message's to restate.
137
+ */
138
+ export interface ResolveInboundConversationOptions {
139
+ conversationType?: NonScheduledConversationType;
140
+ /** Channel this conversation originates on, for first-message attribution. */
141
+ origin?: ChannelId;
142
+ }
143
+
144
+ export function resolveInboundConversation(
140
145
  sourceChannel: string,
141
146
  externalChatId: string,
142
147
  sourceThreadId?: string | null,
148
+ opts?: ResolveInboundConversationOptions,
143
149
  ): { conversationId: string } {
144
- const threadedKey = buildScopedConversationKeyForAssistant(
145
- assistantId,
150
+ const threadedKey = buildScopedConversationKey(
146
151
  sourceChannel,
147
152
  externalChatId,
148
153
  sourceThreadId,
@@ -155,7 +160,7 @@ function resolveInboundConversation(
155
160
  // other thread-scoped channel (Telegram topics) has no flat-key aliasing —
156
161
  // a thread id always resolves the threaded key directly.
157
162
  if (sourceChannel !== "slack" || !threadId) {
158
- return getOrCreateConversation(threadedKey);
163
+ return getOrCreateConversation(threadedKey, opts);
159
164
  }
160
165
 
161
166
  const threadedMapping = getConversationByKey(threadedKey);
@@ -163,8 +168,7 @@ function resolveInboundConversation(
163
168
  return { conversationId: threadedMapping.conversationId };
164
169
  }
165
170
 
166
- const legacyKey = buildScopedConversationKeyForAssistant(
167
- assistantId,
171
+ const legacyKey = buildScopedConversationKey(
168
172
  sourceChannel,
169
173
  externalChatId,
170
174
  null,
@@ -185,7 +189,7 @@ function resolveInboundConversation(
185
189
  }
186
190
  }
187
191
 
188
- return getOrCreateConversation(threadedKey);
192
+ return getOrCreateConversation(threadedKey, opts);
189
193
  }
190
194
 
191
195
  /**
@@ -236,6 +240,13 @@ export function findInboundConversationId(
236
240
  /**
237
241
  * Record an inbound channel event. Returns `duplicate: true` if this
238
242
  * exact (channel, chat, message) combination was already seen.
243
+ *
244
+ * The dedup half of this is also implemented gateway-side, on the same
245
+ * triple, in `gateway/src/db/inbound-dedup-store.ts`. Deliberately, not by
246
+ * accident: the gateway claims a delivery before handing it over, so a
247
+ * vendor's retry costs no crossing, and this stays as the record that binds
248
+ * the conversation and tracks the reply. Both must key on the same three
249
+ * fields for either to mean anything.
239
250
  */
240
251
  export function recordInbound(
241
252
  sourceChannel: string,
@@ -269,9 +280,7 @@ export function recordInbound(
269
280
  };
270
281
  }
271
282
 
272
- const assistantId = options?.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
273
283
  const mapping = resolveInboundConversation(
274
- assistantId,
275
284
  sourceChannel,
276
285
  externalChatId,
277
286
  options?.sourceThreadId,
@@ -7,7 +7,8 @@
7
7
  * list APIs.
8
8
  */
9
9
 
10
- import { and, eq, inArray, isNull, sql } from "drizzle-orm";
10
+ import { and, eq, inArray, isNull, type SQL, sql } from "drizzle-orm";
11
+ import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
11
12
 
12
13
  import { getDb } from "./db-connection.js";
13
14
  import { externalConversationBindings } from "./schema/index.js";
@@ -45,6 +46,19 @@ function normalizeExternalThreadId(
45
46
  return trimmed ? trimmed : null;
46
47
  }
47
48
 
49
+ /**
50
+ * An upsert value that leaves the stored column alone when the caller said
51
+ * nothing about it, distinguishing "I do not know" from "there is none".
52
+ */
53
+ function keepStoredIfAbsent<T extends string | null | undefined>(
54
+ value: T,
55
+ column: AnySQLiteColumn,
56
+ ): Exclude<T, undefined> | SQL {
57
+ return value === undefined
58
+ ? sql`${column}`
59
+ : (value as Exclude<T, undefined>);
60
+ }
61
+
48
62
  function normalizeExternalChatName(
49
63
  externalChatName?: string | null,
50
64
  ): string | null {
@@ -103,9 +117,23 @@ export function upsertBinding(input: UpsertBindingInput): void {
103
117
  externalChatName ??
104
118
  sql`${externalConversationBindings.externalChatName}`,
105
119
  externalThreadId,
106
- externalUserId: input.externalUserId ?? null,
107
- displayName: input.displayName ?? null,
108
- username: input.username ?? null,
120
+ // An omitted sender field keeps what is stored; an explicit value,
121
+ // including null, replaces it. A caller that knows only the chat
122
+ // coordinates on a message is silent about the sender rather than
123
+ // asserting there is none, and erasing a known name on that silence
124
+ // drops it from the conversation and session APIs.
125
+ externalUserId: keepStoredIfAbsent(
126
+ input.externalUserId,
127
+ externalConversationBindings.externalUserId,
128
+ ),
129
+ displayName: keepStoredIfAbsent(
130
+ input.displayName,
131
+ externalConversationBindings.displayName,
132
+ ),
133
+ username: keepStoredIfAbsent(
134
+ input.username,
135
+ externalConversationBindings.username,
136
+ ),
109
137
  updatedAt: now,
110
138
  lastInboundAt: now,
111
139
  },
@@ -28,13 +28,14 @@ import {
28
28
  getTurnTimeBounds,
29
29
  messageMetadataSchema,
30
30
  } from "./conversation-crud.js";
31
- import { type DrizzleDb, getDb, getLogsDb } from "./db-connection.js";
31
+ import { type DrizzleDb, getLogsDb } from "./db-connection.js";
32
32
  import { getClickHouseLlmRequestLogSink } from "./llm-request-log-sink-clickhouse.js";
33
33
  import type {
34
34
  LlmRequestLogWriter,
35
35
  LlmRequestLogWriteRow,
36
36
  } from "./llm-request-log-writer-types.js";
37
- import { llmRequestLogs, messages } from "./schema/index.js";
37
+ import { existingMessageIds } from "./message-reads.js";
38
+ import { llmRequestLogs } from "./schema/index.js";
38
39
  import { timeSyncSection } from "./slow-sync-log.js";
39
40
 
40
41
  /**
@@ -593,14 +594,7 @@ function selectOrphanedLogsInRange(
593
594
  const candidateMessageIds = [
594
595
  ...new Set(candidates.map((c) => c.messageId as string)),
595
596
  ];
596
- const existing = new Set(
597
- getDb()
598
- .select({ id: messages.id })
599
- .from(messages)
600
- .where(inArray(messages.id, candidateMessageIds))
601
- .all()
602
- .map((r) => r.id),
603
- );
597
+ const existing = existingMessageIds(candidateMessageIds);
604
598
 
605
599
  // Orphaned = the referenced message no longer exists.
606
600
  return candidates.filter((c) => !existing.has(c.messageId as string));