@vellumai/assistant 0.11.3 → 0.11.4-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 (341) 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 +139 -37
  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 +17 -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 +177 -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__/ui-shape-teaching.test.ts +33 -0
  67. package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
  68. package/src/__tests__/user-plugin-loader.test.ts +1 -1
  69. package/src/__tests__/visible-app-context.test.ts +16 -9
  70. package/src/__tests__/voice-config-update.test.ts +40 -0
  71. package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
  72. package/src/__tests__/worker-plugin-surface.test.ts +77 -0
  73. package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
  74. package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
  75. package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
  76. package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
  77. package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
  78. package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
  79. package/src/acp/__tests__/auth-required.test.ts +161 -0
  80. package/src/acp/acp-claude-oauth.ts +19 -2
  81. package/src/acp/agent-process.test.ts +100 -0
  82. package/src/acp/agent-process.ts +29 -26
  83. package/src/acp/auth-required.ts +102 -0
  84. package/src/acp/session-manager.test.ts +119 -0
  85. package/src/acp/session-manager.ts +68 -2
  86. package/src/api/events/acp-auth-required.ts +55 -0
  87. package/src/api/index.ts +7 -0
  88. package/src/api/surfaces.ts +7 -3
  89. package/src/apps/app-store.ts +3 -0
  90. package/src/bundler/package-resolver.ts +2 -30
  91. package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
  92. package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
  93. package/src/calls/call-controller.ts +19 -3
  94. package/src/calls/call-setup-flow.ts +0 -1
  95. package/src/calls/media-stream-stt-session.ts +15 -0
  96. package/src/calls/voice-session-bridge.ts +71 -16
  97. package/src/calls/voice-triage-escalate.ts +104 -2
  98. package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
  99. package/src/channels/config.ts +13 -0
  100. package/src/channels/plugin-channel-declarations.ts +108 -0
  101. package/src/channels/types.ts +30 -0
  102. package/src/cli/AGENTS.md +5 -2
  103. package/src/cli/commands/credentials.help.ts +2 -2
  104. package/src/cli/commands/inference-providers.ts +1 -1
  105. package/src/cli/commands/mcp.help.ts +13 -4
  106. package/src/cli/commands/mcp.ts +9 -0
  107. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
  108. package/src/cli/commands/memory/index.help.ts +43 -1
  109. package/src/cli/commands/memory/memory-v3.ts +64 -0
  110. package/src/cli/commands/stt.help.ts +27 -2
  111. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
  112. package/src/cli/lib/bundled-marketplace.json +13 -0
  113. package/src/cli/lib/upgrade-plugin.ts +42 -0
  114. package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
  115. package/src/config/__tests__/default-provider.test.ts +6 -1
  116. package/src/config/__tests__/profile-materialization.test.ts +75 -19
  117. package/src/config/bundled-skills/acp/SKILL.md +6 -7
  118. package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
  119. package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
  120. package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
  121. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  122. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
  123. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
  124. package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
  125. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
  126. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
  127. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
  128. package/src/config/call-site-defaults.ts +4 -5
  129. package/src/config/default-profile-catalog.ts +83 -12
  130. package/src/config/default-profile-names.ts +4 -1
  131. package/src/config/default-provider-resolution.ts +4 -0
  132. package/src/config/llm-context-resolution.ts +11 -3
  133. package/src/config/llm-resolver.ts +28 -1
  134. package/src/config/profile-materialization.ts +70 -22
  135. package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
  136. package/src/config/schemas/call-site-catalog.ts +4 -4
  137. package/src/config/schemas/live-voice.ts +57 -23
  138. package/src/config/schemas/llm.ts +59 -32
  139. package/src/config/schemas/mcp.ts +23 -0
  140. package/src/config/schemas/plugin-updates.ts +6 -2
  141. package/src/config/schemas/stt.ts +1 -0
  142. package/src/context/outbound-sanitize.ts +96 -1
  143. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
  144. package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
  145. package/src/daemon/conversation-agent-loop.ts +17 -6
  146. package/src/daemon/conversation-messaging.ts +5 -1
  147. package/src/daemon/conversation-notifiers.ts +9 -1
  148. package/src/daemon/conversation-process.ts +36 -6
  149. package/src/daemon/conversation-runtime-assembly.ts +3 -4
  150. package/src/daemon/conversation-surfaces.ts +27 -5
  151. package/src/daemon/conversation-tool-setup.ts +1 -2
  152. package/src/daemon/conversation.ts +48 -0
  153. package/src/daemon/interactive-turn-sender.ts +59 -0
  154. package/src/daemon/mcp-reload-service.ts +36 -6
  155. package/src/daemon/process-message.ts +24 -24
  156. package/src/daemon/providers-setup.ts +6 -3
  157. package/src/daemon/trust-context-types.ts +29 -0
  158. package/src/daemon/wake-conversation-ops.ts +3 -2
  159. package/src/documents/document-store.ts +138 -5
  160. package/src/hooks/hook-loader.ts +3 -3
  161. package/src/hooks/registry.ts +50 -6
  162. package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
  163. package/src/inbound/oauth-callback-url.ts +61 -0
  164. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
  165. package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
  166. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
  167. package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
  168. package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
  169. package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
  170. package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
  171. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
  172. package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
  173. package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
  174. package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
  175. package/src/live-voice/live-voice-archive.ts +2 -0
  176. package/src/live-voice/live-voice-metrics.ts +57 -32
  177. package/src/live-voice/live-voice-photo.ts +1 -2
  178. package/src/live-voice/live-voice-session.ts +535 -314
  179. package/src/live-voice/progress-narration.ts +277 -0
  180. package/src/live-voice/protocol.ts +21 -1
  181. package/src/mcp/__tests__/effective-config.test.ts +238 -0
  182. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
  183. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
  184. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
  185. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
  186. package/src/mcp/client.ts +16 -11
  187. package/src/mcp/effective-config.ts +113 -0
  188. package/src/mcp/manager.ts +11 -6
  189. package/src/mcp/mcp-auth-orchestrator.ts +13 -22
  190. package/src/mcp/mcp-oauth-provider.ts +205 -240
  191. package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
  192. package/src/monitoring/plugin-auto-update.ts +128 -24
  193. package/src/notifications/signal.ts +1 -0
  194. package/src/permissions/confirmation-guardian-request.test.ts +15 -11
  195. package/src/permissions/confirmation-guardian-request.ts +2 -2
  196. package/src/permissions/question-guardian-request.test.ts +14 -6
  197. package/src/permissions/question-guardian-request.ts +1 -2
  198. package/src/persistence/attachments-store.ts +8 -1
  199. package/src/persistence/bookmark-crud.ts +3 -7
  200. package/src/persistence/conversation-attention-store.ts +16 -45
  201. package/src/persistence/conversation-crud.ts +33 -4
  202. package/src/persistence/conversation-lineage.ts +9 -0
  203. package/src/persistence/conversation-queries.ts +108 -41
  204. package/src/persistence/delivery-crud.ts +38 -29
  205. package/src/persistence/external-conversation-store.ts +32 -4
  206. package/src/persistence/llm-request-log-store.ts +4 -10
  207. package/src/persistence/llm-usage-store.ts +8 -3
  208. package/src/persistence/message-reads.test.ts +197 -0
  209. package/src/persistence/message-reads.ts +211 -0
  210. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
  211. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
  212. package/src/persistence/real-user-turn-filter.ts +27 -3
  213. package/src/persistence/steps.ts +9 -0
  214. package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
  215. package/src/plugin-api/conversation-turn.ts +168 -5
  216. package/src/plugin-api/index.ts +21 -5
  217. package/src/plugin-api/vision-support.test.ts +1 -1
  218. package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
  219. package/src/plugins/defaults/memory/AGENTS.md +4 -0
  220. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
  224. package/src/plugins/defaults/memory/buffer-format.ts +165 -0
  225. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
  226. package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
  227. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
  228. package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
  229. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
  230. package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
  231. package/src/plugins/defaults/memory/indexer.ts +3 -1
  232. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
  233. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
  234. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
  235. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
  236. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
  237. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
  238. package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
  239. package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
  240. package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
  241. package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
  242. package/src/plugins/defaults/memory/v3/prune.ts +2 -0
  243. package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
  244. package/src/plugins/defaults/memory/worker.ts +6 -3
  245. package/src/plugins/external-plugin-loader.ts +47 -0
  246. package/src/plugins/mcp-servers.ts +361 -0
  247. package/src/plugins/mtime-cache.ts +23 -49
  248. package/src/plugins/worker-plugin-surface.ts +33 -0
  249. package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
  250. package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
  251. package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
  252. package/src/providers/__tests__/retry-callsite.test.ts +5 -2
  253. package/src/providers/call-site-routing.ts +30 -3
  254. package/src/providers/connection-resolution.ts +194 -11
  255. package/src/providers/inference/auth.ts +6 -0
  256. package/src/providers/inference/connection-availability.ts +24 -2
  257. package/src/providers/inference/connections.ts +2 -0
  258. package/src/providers/model-catalog.ts +3 -3
  259. package/src/providers/model-intents.ts +28 -8
  260. package/src/providers/openai/chat-completions-provider.ts +5 -6
  261. package/src/providers/openai/codex-models.ts +2 -1
  262. package/src/providers/provider-send-message.ts +32 -3
  263. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
  264. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
  265. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
  266. package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
  267. package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
  268. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
  269. package/src/providers/speech-to-text/provider-catalog.ts +99 -8
  270. package/src/providers/speech-to-text/resolve.ts +25 -2
  271. package/src/routes/worker.ts +17 -5
  272. package/src/runtime/access-request-helper.ts +9 -12
  273. package/src/runtime/agent-wake.ts +3 -3
  274. package/src/runtime/pre-first-message-gate.ts +4 -0
  275. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
  276. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
  277. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
  278. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
  279. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
  280. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
  281. package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
  282. package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
  283. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
  284. package/src/runtime/routes/channel-availability-routes.ts +32 -14
  285. package/src/runtime/routes/channel-route-shared.ts +0 -6
  286. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
  287. package/src/runtime/routes/conversation-list-routes.ts +112 -1
  288. package/src/runtime/routes/conversation-query-routes.ts +40 -27
  289. package/src/runtime/routes/credential-prompt-routes.ts +4 -7
  290. package/src/runtime/routes/default-provider-routes.ts +15 -0
  291. package/src/runtime/routes/inbound-message-handler.ts +17 -41
  292. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
  293. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
  294. package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
  295. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
  296. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
  297. package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
  298. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
  299. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
  300. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
  301. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
  302. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
  303. package/src/runtime/routes/inference-profiles-routes.ts +20 -11
  304. package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
  305. package/src/runtime/routes/log-export-routes.ts +3 -0
  306. package/src/runtime/routes/mcp-auth-routes.ts +148 -57
  307. package/src/runtime/routes/plugins-routes.ts +21 -3
  308. package/src/runtime/routes/stt-routes.ts +31 -25
  309. package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
  310. package/src/runtime/routes/user-route-dispatcher.ts +39 -14
  311. package/src/runtime/routes/user-route-import.ts +108 -0
  312. package/src/schedule/worker.ts +6 -0
  313. package/src/security/oauth2.ts +6 -22
  314. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
  315. package/src/stt/__tests__/types.test.ts +94 -0
  316. package/src/stt/daemon-batch-transcriber.ts +10 -0
  317. package/src/stt/stt-stream-session.ts +8 -4
  318. package/src/stt/types.ts +103 -0
  319. package/src/subagent/manager.ts +1 -3
  320. package/src/subagent/types.ts +7 -6
  321. package/src/tools/acp/spawn.test.ts +97 -0
  322. package/src/tools/acp/spawn.ts +32 -0
  323. package/src/tools/document/document-tool.ts +12 -3
  324. package/src/tools/registry.ts +2 -1
  325. package/src/tools/ui-surface/surface-shape-docs.ts +11 -0
  326. package/src/tools/workflows/run-workflow.ts +1 -2
  327. package/src/tts/__tests__/reasoning-tag-filter.test.ts +78 -0
  328. package/src/tts/reasoning-tag-filter.ts +89 -0
  329. package/src/util/think-tag-stream.ts +95 -0
  330. package/src/workspace/byok-default-profile-ensure.ts +76 -24
  331. package/src/workspace/custom-profile-ensure.ts +4 -24
  332. package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
  333. package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
  334. package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
  335. package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
  336. package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
  337. package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
  338. package/src/workspace/migrations/registry.ts +10 -0
  339. package/src/workspace/provider-commit-message-generator.ts +7 -5
  340. package/src/live-voice/__tests__/front-decision.test.ts +0 -645
  341. package/src/live-voice/front-decision.ts +0 -476
@@ -96,7 +96,10 @@ mock.module("../find-most-recent-retrospective-for.js", () => ({
96
96
  }));
97
97
 
98
98
  mock.module("../../../../persistence/conversation-crud.js", () => ({
99
- getMessagesAfter: (_id: string, _afterId: string | null) => newMessages,
99
+ // Real rows always carry `finalized` (NOT NULL, default 1); the slice
100
+ // filter drops unfinalized rows, so mock rows default to finalized.
101
+ getMessagesAfter: (_id: string, _afterId: string | null) =>
102
+ newMessages.map((row) => ({ finalized: 1, ...row })),
100
103
  getMessages: (id: string) => messageStores[id] ?? [],
101
104
  // `defaultResolveTarget` in the REAL wake reads the FORK id's row for the
102
105
  // archived check, so both rows carry archivedAt/createdAt. The fork's
@@ -0,0 +1,165 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Memory buffer entry format: the single owner
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // `memory/buffer.md` is the append-only staging log that `remember()` writes
6
+ // and consolidation drains. Its line format is written here and recognized
7
+ // here, by one matcher, so the writer and every reader agree by construction.
8
+ //
9
+ // This module deliberately sits at the plugin root (shared infra, not a tier)
10
+ // and imports nothing. `substrate/` is the bottom tier and may not import a
11
+ // tier, but plugin-root infra is fair game, so all readers can reach this one
12
+ // without inverting the layering.
13
+ //
14
+ // A second matcher for this format anywhere in the tree is a bug. Two copies
15
+ // disagree on the shapes nobody thinks to test (indentation, spacing after the
16
+ // bullet dash, spacing inside the date), and each disagreement splits or merges
17
+ // somebody's fact.
18
+
19
+ /**
20
+ * A parsed buffer entry opening line.
21
+ */
22
+ export interface BufferEntryStart {
23
+ /**
24
+ * The bracketed timestamp, verbatim. Returned unparsed so it can serve
25
+ * directly as a consolidation cutoff: both sides of the agent's
26
+ * "timestamp >= cutoff" comparison then share this exact shape.
27
+ */
28
+ timestamp: string;
29
+ /** Everything after the timestamp bracket, trimmed. */
30
+ text: string;
31
+ }
32
+
33
+ /**
34
+ * Matches an entry opening line: `- [Mon D, h:mm AM/PM] fact`, the shape
35
+ * {@link formatRememberEntry} writes.
36
+ *
37
+ * Anchored at column 0, requiring a space after the dash, and spelling the
38
+ * timestamp with the single spaces {@link formatBufferTimestamp} emits.
39
+ * Everything else in the file is a continuation line belonging to the entry
40
+ * above it, which is how a multiline fact keeps its body: `remember()` writes
41
+ * only the opening line at column 0 and nests the body beneath it. That rule
42
+ * is what makes a bullet carrying other bracketed text (a `- [ ]` checklist
43
+ * item, a `- [[wikilink]]` bullet) read as continuation rather than as a new
44
+ * fact, and it is why a body line can never imitate an opening.
45
+ *
46
+ * Matching the writer's exact shape is the conservative choice in both
47
+ * directions: a line the writer could not have produced stays body text, so an
48
+ * odd hand-edit merges into the fact around it instead of splitting that fact
49
+ * or supplying a malformed timestamp to a caller that treats one as a cutoff.
50
+ *
51
+ * Group 1 is the timestamp, group 2 the fact text.
52
+ */
53
+ const BUFFER_ENTRY_REGEX =
54
+ /^- \[([A-Z][a-z]{2} \d{1,2}, \d{1,2}:\d{2} [AP]M)\]\s*(.*)$/;
55
+
56
+ /**
57
+ * Parse `line` as an entry opening, or `null` if it is a continuation line.
58
+ *
59
+ * Pass the raw line. Callers must not trim it first: leading whitespace is
60
+ * exactly what distinguishes an indented body line from a real entry, so a
61
+ * trimmed line reads an indented body bullet as a fresh fact and splits one
62
+ * entry into two.
63
+ */
64
+ export function matchBufferEntryStart(line: string): BufferEntryStart | null {
65
+ const match = BUFFER_ENTRY_REGEX.exec(line.trimEnd());
66
+ if (match === null) {
67
+ return null;
68
+ }
69
+ return { timestamp: match[1]!, text: match[2]!.trim() };
70
+ }
71
+
72
+ /** Whether `line` opens a buffer entry. See {@link matchBufferEntryStart}. */
73
+ export function isBufferEntryStart(line: string): boolean {
74
+ return BUFFER_ENTRY_REGEX.test(line.trimEnd());
75
+ }
76
+
77
+ /**
78
+ * Format `now` as a buffer-entry timestamp (`Mon D, h:mm AM/PM`). Exported so
79
+ * the consolidation job can present its cutoff in the same shape the buffer
80
+ * entries use, making the agent's "timestamp >= cutoff" comparison
81
+ * unambiguous at minute precision.
82
+ */
83
+ export function formatBufferTimestamp(now: Date): string {
84
+ const month = now.toLocaleString("en-US", { month: "short" });
85
+ const day = now.getDate();
86
+ const hours = now.getHours();
87
+ const minutes = String(now.getMinutes()).padStart(2, "0");
88
+ const ampm = hours >= 12 ? "PM" : "AM";
89
+ const displayHour = hours % 12 || 12;
90
+ return `${month} ${day}, ${displayHour}:${minutes} ${ampm}`;
91
+ }
92
+
93
+ /**
94
+ * Indent applied to a fact's continuation lines. Two spaces is the markdown
95
+ * convention for content nested under a `- ` bullet, so an entry stays
96
+ * readable as a list item.
97
+ */
98
+ const CONTINUATION_INDENT = " ";
99
+
100
+ /**
101
+ * Build a timestamped bullet entry for `buffer.md` / `archive/<date>.md`.
102
+ *
103
+ * Format mirrors the long-standing v1 PKB layout so buffers stay
104
+ * human-readable and downstream consumers (sweep, consolidation, the graph
105
+ * view) can parse the same shape regardless of which path produced the entry.
106
+ *
107
+ * A fact's body is indented, which is what makes the format round-trip. The
108
+ * buffer is line-delimited and the delimiter is "canonical entry shape at
109
+ * column 0", so a body line carrying that exact shape would otherwise be
110
+ * indistinguishable from the start of the next fact and split one entry into
111
+ * two. Indenting moves every body line off column 0, so no fact content can
112
+ * imitate a delimiter no matter what the user dictated. Blank lines stay
113
+ * genuinely blank rather than becoming whitespace.
114
+ *
115
+ * Readers strip the indent: {@link matchBufferEntryStart} classifies on the
116
+ * raw line while `parseBufferEntries` trims continuation lines, so the fact
117
+ * text a caller gets back is unchanged by this.
118
+ *
119
+ * Exported so memory sweep / extractor jobs format their auto-remembered
120
+ * entries identically to user-facing `remember()` calls.
121
+ */
122
+ export function formatRememberEntry(content: string, now: Date): string {
123
+ const [opening = "", ...body] = content.split("\n");
124
+ const nested = body.map((line) =>
125
+ line.trim().length === 0 ? "" : `${CONTINUATION_INDENT}${line}`,
126
+ );
127
+ return `- [${formatBufferTimestamp(now)}] ${[opening, ...nested].join("\n")}\n`;
128
+ }
129
+
130
+ /**
131
+ * Split buffer content into entries, each keeping its lines verbatim.
132
+ *
133
+ * The unit callers almost always want. Counting or trimming a buffer by raw
134
+ * lines conflates a five-line fact with five facts, and cuts a fact in half
135
+ * wherever the count runs out.
136
+ *
137
+ * Leading lines that precede the first entry opening (a hand-written buffer,
138
+ * or stray prose) are returned as a headless first group with
139
+ * `start === null`, so callers can decide what to do with them rather than
140
+ * silently dropping content.
141
+ */
142
+ export function splitBufferEntries(
143
+ lines: readonly string[],
144
+ ): BufferEntryLines[] {
145
+ const groups: BufferEntryLines[] = [];
146
+ for (const [index, line] of lines.entries()) {
147
+ const start = matchBufferEntryStart(line);
148
+ if (start !== null || groups.length === 0) {
149
+ groups.push({ start, firstLine: index, lines: [line] });
150
+ continue;
151
+ }
152
+ groups[groups.length - 1]!.lines.push(line);
153
+ }
154
+ return groups;
155
+ }
156
+
157
+ /** One entry's verbatim lines, as returned by {@link splitBufferEntries}. */
158
+ export interface BufferEntryLines {
159
+ /** `null` for the headless group before the first entry opening. */
160
+ start: BufferEntryStart | null;
161
+ /** Index of this group's first line in the input array. */
162
+ firstLine: number;
163
+ /** The group's lines, verbatim and in order. */
164
+ lines: string[];
165
+ }
@@ -224,6 +224,12 @@ async function gatherCandidateRowsFromQdrant(
224
224
  * the source/type/excluded-conversation predicates in SQL (Qdrant has no
225
225
  * visibility filtering). The app-side scorer determines final ordering, so
226
226
  * the SQL tie-break falls back to recency.
227
+ *
228
+ * No `finalized = 1` predicate, deliberately: every id in `messageIds` comes
229
+ * from the lexical or vector index, and indexing filters `finalized = 1` at
230
+ * index time (`message-lexical.ts`), so an unfinalized row can never be a
231
+ * candidate here. Do not copy this query shape to a reader whose id set has a
232
+ * different provenance; those need the filter themselves.
227
233
  */
228
234
  function searchByIds(
229
235
  messageIds: readonly string[],
@@ -17,6 +17,9 @@ export async function loadImageRefData(
17
17
  const msg = db
18
18
  .select({ content: messages.content })
19
19
  .from(messages)
20
+ // No completeness predicate: refs come from the extracted memory graph,
21
+ // and extraction reads finalized rows only, so an unfinalized id cannot
22
+ // reach here. Do not copy this shape for ids with other provenance.
20
23
  .where(eq(messages.id, ref.messageId))
21
24
  .get();
22
25
  if (!msg) {
@@ -15,6 +15,7 @@ import {
15
15
  } from "../../../../config/memory-v3-gate.js";
16
16
  import type { AssistantConfig } from "../../../../config/types.js";
17
17
  import { enqueueMemoryJob } from "../../../../persistence/jobs-store.js";
18
+ import { formatRememberEntry } from "../buffer-format.js";
18
19
  import { getWorkspaceDir } from "../paths.js";
19
20
  import type { GraphStats } from "./store.js";
20
21
  import {
@@ -98,36 +99,6 @@ export function handleRemember(
98
99
  return { success: true, message };
99
100
  }
100
101
 
101
- /**
102
- * Format `now` as a buffer-entry timestamp (`Mon D, h:mm AM/PM`). Exported so
103
- * the memory v2 consolidation job can present its cutoff in the same shape
104
- * the buffer entries use, making the agent's "timestamp ≥ cutoff" comparison
105
- * unambiguous at minute precision.
106
- */
107
- export function formatBufferTimestamp(now: Date): string {
108
- const month = now.toLocaleString("en-US", { month: "short" });
109
- const day = now.getDate();
110
- const hours = now.getHours();
111
- const minutes = String(now.getMinutes()).padStart(2, "0");
112
- const ampm = hours >= 12 ? "PM" : "AM";
113
- const displayHour = hours % 12 || 12;
114
- return `${month} ${day}, ${displayHour}:${minutes} ${ampm}`;
115
- }
116
-
117
- /**
118
- * Build a timestamped bullet entry for `buffer.md` / `archive/<date>.md`.
119
- *
120
- * Format mirrors the long-standing v1 PKB layout so v2 buffers stay
121
- * human-readable and downstream consumers (sweep, consolidation) can parse
122
- * the same shape regardless of which path produced the entry.
123
- *
124
- * Exported so memory v2 sweep / extractor jobs format their auto-remembered
125
- * entries identically to user-facing `remember()` calls.
126
- */
127
- export function formatRememberEntry(content: string, now: Date): string {
128
- return `- [${formatBufferTimestamp(now)}] ${content}\n`;
129
- }
130
-
131
102
  /**
132
103
  * Append `entry` to `<rootDir>/buffer.md` and `<rootDir>/archive/<today>.md`,
133
104
  * creating the archive directory and seeding the archive header if missing.
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { formatRememberEntry } from "../buffer-format.js";
3
4
  import {
4
5
  buildPendingGraph,
5
6
  findPendingEntryForContent,
@@ -42,6 +43,39 @@ describe("parseBufferEntries", () => {
42
43
  expect(entries[1]!.text).toBe("separate fact");
43
44
  });
44
45
 
46
+ test("a written fact stays one node even when its body is entry-shaped at column 0", () => {
47
+ // Round trip through the real writer. A user can dictate a fact whose body
48
+ // is itself a dated bullet, and without the writer nesting the body those
49
+ // lines are byte-identical to the start of the next fact, so one memory
50
+ // renders as several pending nodes in the Memory tab.
51
+ const entries = parseBufferEntries(
52
+ formatRememberEntry(
53
+ "Rollout plan:\n- [Jul 21, 9:00 AM] cutover window opens\n- [Jul 22, 9:00 AM] rollback deadline",
54
+ new Date(2026, 6, 20, 15, 15),
55
+ ),
56
+ );
57
+
58
+ expect(entries).toHaveLength(1);
59
+ expect(entries[0]!.text).toBe(
60
+ "Rollout plan:\n- [Jul 21, 9:00 AM] cutover window opens\n- [Jul 22, 9:00 AM] rollback deadline",
61
+ );
62
+ });
63
+
64
+ test("an indented entry-shaped line stays inside the fact above it", () => {
65
+ // Only an entry's opening line sits at column 0, so an indented bullet
66
+ // that happens to carry a timestamp is body text the user wrote. Reading
67
+ // it as an entry splits one fact into two nodes in the Memory tab.
68
+ const entries = parseBufferEntries(
69
+ "- [Jul 20, 3:15 PM] Timeline for the migration:\n" +
70
+ " - [Jul 21, 9:00 AM] cutover window opens\n" +
71
+ " - [Jul 22, 9:00 AM] rollback deadline\n",
72
+ );
73
+ expect(entries).toHaveLength(1);
74
+ expect(entries[0]!.text).toBe(
75
+ "Timeline for the migration:\n- [Jul 21, 9:00 AM] cutover window opens\n- [Jul 22, 9:00 AM] rollback deadline",
76
+ );
77
+ });
78
+
45
79
  test("keeps interior blank lines inside a multiline fact", () => {
46
80
  const entries = parseBufferEntries(
47
81
  "- [Jul 20, 3:15 PM] first paragraph\n\nsecond paragraph\n- [Jul 20, 3:16 PM] next\n",
@@ -18,7 +18,10 @@
18
18
  import { readFile } from "node:fs/promises";
19
19
  import { join } from "node:path";
20
20
 
21
- import { formatRememberEntry } from "../graph/tool-handlers.js";
21
+ import {
22
+ formatRememberEntry,
23
+ matchBufferEntryStart,
24
+ } from "../buffer-format.js";
22
25
  import type { MemoryGraphEdge, MemoryGraphNode } from "./types.js";
23
26
 
24
27
  /** Node id prefix marking a pending buffer entry (id = prefix + text hash). */
@@ -36,15 +39,6 @@ const MAX_PENDING_NODES = 200;
36
39
 
37
40
  const PENDING_LABEL_MAX_CHARS = 60;
38
41
 
39
- /**
40
- * Matches a real entry start: `- [Mon D, h:mm AM/PM] fact`, the exact shape
41
- * `formatRememberEntry` writes. The timestamp must be present and
42
- * timestamp-shaped — a bullet with other bracketed text (e.g. a `- [ ]`
43
- * checklist item inside a multiline fact) is a continuation, not an entry.
44
- */
45
- const BUFFER_ENTRY_REGEX =
46
- /^-\s+\[[A-Z][a-z]{2}\s+\d{1,2},\s+\d{1,2}:\d{2}\s+[AP]M\]\s*(.*)$/;
47
-
48
42
  /** Lenient fallback for hand-written buffers: a plain bullet with no
49
43
  * timestamped entry seen yet still counts as an entry of its own. */
50
44
  const PLAIN_BULLET_REGEX = /^-\s+(.+)$/;
@@ -102,10 +96,12 @@ export function parseBufferEntries(content: string): PendingBufferEntry[] {
102
96
 
103
97
  for (const rawLine of content.split("\n")) {
104
98
  const line = rawLine.trimEnd();
105
- const entryStart = BUFFER_ENTRY_REGEX.exec(line.trim());
99
+ // Matched against the untrimmed line: an indented entry-shaped bullet is
100
+ // body text inside the fact above it, not a fact of its own.
101
+ const entryStart = matchBufferEntryStart(line);
106
102
  if (entryStart) {
107
103
  flush();
108
- current = [entryStart[1]!.trim()];
104
+ current = [entryStart.text];
109
105
  continue;
110
106
  }
111
107
  if (current !== null) {
@@ -70,10 +70,7 @@ const postCompact: HookFunction<PostCompactContext> = async (ctx) => {
70
70
  // concurrent request's actor and the contact/member registry the actor
71
71
  // context derives from can be mutated by contact tools. The snapshots are the
72
72
  // values the turn's initial assembly resolved.
73
- const trust =
74
- conversation?.currentTurnTrustContext ??
75
- conversation?.trustContext ??
76
- FALLBACK_TURN_TRUST;
73
+ const trust = conversation?.getTurnOrRestingTrust() ?? FALLBACK_TURN_TRUST;
77
74
  const actorContext = conversation?.currentTurnInboundActorContext ?? null;
78
75
  // Render the `model_profile:` label from the turn-start notice key, using
79
76
  // the call site self-resolved from the live conversation — the same
@@ -123,7 +123,9 @@ export async function indexMessageNow(
123
123
  // memory_segments has no cross-file FK to messages, so this call must not
124
124
  // leave pieces for a message with no row. Skip early when the source
125
125
  // row is already gone, the common case of a backfill job running after the
126
- // message was deleted. A post-write re-check below closes the narrower window
126
+ // message was deleted. Any-state existence check, deliberately: a
127
+ // streaming row exists, and orphan prevention is about deletion, not
128
+ // completeness. A post-write re-check below closes the narrower window
127
129
  // where the delete lands mid-transaction.
128
130
  const sourceMessage = getDb()
129
131
  .select({ id: messages.id })
@@ -52,18 +52,30 @@ export function isSkillCardMessage(row: { metadata: string | null }): boolean {
52
52
  }
53
53
 
54
54
  /**
55
- * `getMessagesAfter` minus skill-card rows. The retrospective job's
56
- * new-message slice: a card-only tail yields an empty slice (the job's
57
- * `no_new_messages` early return), and a mixed tail's cutoff lands on the
58
- * last real message rather than the card.
55
+ * `getMessagesAfter` truncated at the first unfinalized row, minus skill-card
56
+ * rows. The retrospective job's new-message slice: a card-only tail yields an
57
+ * empty slice (the job's `no_new_messages` early return), and a mixed tail's
58
+ * cutoff lands on the last real message rather than the card.
59
+ *
60
+ * The slice STOPS BEFORE the first unfinalized row rather than filtering it
61
+ * out. The job's cutoff (and therefore `lastProcessedMessageId` on success)
62
+ * is chosen from this slice, while the fork the job reviews skips unfinalized
63
+ * rows, so the cursor must never move past one: filtering through a stale
64
+ * mid-slice `finalized = 0` row would advance the cursor beyond it, and once
65
+ * the row later finalizes its `(createdAt, id)` sits behind the cursor,
66
+ * unreviewed forever. Truncating parks the cursor before the unfinalized row;
67
+ * the rows behind it are reviewed by the retrospective that runs after the
68
+ * row resolves (finalizes, or is repaired away by crash recovery).
59
69
  */
60
70
  export function getRetrospectiveMessagesAfter(
61
71
  conversationId: string,
62
72
  afterMessageId: string | null,
63
73
  ): MessageRow[] {
64
- return getMessagesAfter(conversationId, afterMessageId).filter(
65
- (row) => !isSkillCardMessage(row),
66
- );
74
+ const rows = getMessagesAfter(conversationId, afterMessageId);
75
+ const firstUnfinalized = rows.findIndex((row) => row.finalized !== 1);
76
+ const bounded =
77
+ firstUnfinalized === -1 ? rows : rows.slice(0, firstUnfinalized);
78
+ return bounded.filter((row) => !isSkillCardMessage(row));
67
79
  }
68
80
 
69
81
  /**
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Tests for `handleMemoryV3GateStats` in `memory-v3-routes.ts`.
3
+ *
4
+ * The handler aggregates gate telemetry from the telemetry DB and buckets it
5
+ * by concept page count. These tests cover:
6
+ * - empty DB → zero counts, all buckets present, scoredPassRate null
7
+ * - mixed rows land in the correct buckets by real_concept_page_count
8
+ * - scored vs unscored runs are counted separately
9
+ * - rows with no real_concept_page_count go to unknownPageCount
10
+ * - lookbackDays is clamped to [1, 90]
11
+ * - malformed JSON rows are skipped
12
+ * - null/absent DB returns a zero response
13
+ */
14
+
15
+ import type { Database } from "bun:sqlite";
16
+ import { describe, expect, test } from "bun:test";
17
+
18
+ import { handleMemoryV3GateStats } from "../memory-v3-routes.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Minimal fake SQLite DB that returns pre-set rows
22
+ // ---------------------------------------------------------------------------
23
+
24
+ type FakeRow = { payload: string };
25
+
26
+ function makePayload(detail: Record<string, unknown>): string {
27
+ return JSON.stringify({
28
+ type: "watchdog",
29
+ daemon_event_id: "test-id",
30
+ recorded_at: Date.now(),
31
+ check_name: "memory_v3_injection_gate",
32
+ value: 1,
33
+ detail,
34
+ });
35
+ }
36
+
37
+ function fakeDb(rows: FakeRow[]): Database {
38
+ return {
39
+ query: (_sql: string) => ({
40
+ all: (..._args: unknown[]) => rows,
41
+ }),
42
+ } as unknown as Database;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Tests
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe("handleMemoryV3GateStats", () => {
50
+ test("returns zero counts with all buckets when DB is null", () => {
51
+ const result = handleMemoryV3GateStats(30, null);
52
+ expect(result.totalRuns).toBe(0);
53
+ expect(result.buckets).toHaveLength(4);
54
+ for (const bucket of result.buckets) {
55
+ expect(bucket.total).toBe(0);
56
+ expect(bucket.scored).toBe(0);
57
+ expect(bucket.passed).toBe(0);
58
+ expect(bucket.scoredPassRate).toBeNull();
59
+ expect(bucket.reasons).toEqual({});
60
+ }
61
+ expect(result.unknownPageCount).toEqual({
62
+ total: 0,
63
+ passed: 0,
64
+ reasons: {},
65
+ });
66
+ });
67
+
68
+ test("returns zero counts with all buckets when DB returns no rows", () => {
69
+ const result = handleMemoryV3GateStats(30, fakeDb([]));
70
+ expect(result.totalRuns).toBe(0);
71
+ expect(result.buckets[0]!.pageCountRange).toBe("0–9");
72
+ expect(result.buckets[3]!.pageCountRange).toBe("200+");
73
+ for (const b of result.buckets) {
74
+ expect(b.scoredPassRate).toBeNull();
75
+ }
76
+ });
77
+
78
+ test("buckets a dense_pass row into 10–49 bucket", () => {
79
+ const rows = [
80
+ {
81
+ payload: makePayload({
82
+ pass: true,
83
+ reason: "dense_pass",
84
+ scored: true,
85
+ real_concept_page_count: 25,
86
+ }),
87
+ },
88
+ ];
89
+ const result = handleMemoryV3GateStats(30, fakeDb(rows));
90
+ expect(result.totalRuns).toBe(1);
91
+ const bucket = result.buckets.find((b) => b.pageCountRange === "10–49")!;
92
+ expect(bucket.total).toBe(1);
93
+ expect(bucket.scored).toBe(1);
94
+ expect(bucket.passed).toBe(1);
95
+ expect(bucket.scoredPassRate).toBe(1);
96
+ expect(bucket.reasons).toEqual({ dense_pass: 1 });
97
+ });
98
+
99
+ test("buckets a fail row into 200+ bucket", () => {
100
+ const rows = [
101
+ {
102
+ payload: makePayload({
103
+ pass: false,
104
+ reason: "fail_no_signal",
105
+ scored: true,
106
+ real_concept_page_count: 350,
107
+ }),
108
+ },
109
+ ];
110
+ const result = handleMemoryV3GateStats(30, fakeDb(rows));
111
+ const bucket = result.buckets.find((b) => b.pageCountRange === "200+")!;
112
+ expect(bucket.total).toBe(1);
113
+ expect(bucket.passed).toBe(0);
114
+ expect(bucket.scoredPassRate).toBe(0);
115
+ expect(bucket.reasons).toEqual({ fail_no_signal: 1 });
116
+ });
117
+
118
+ test("unscored pass-open rows count in total but not scored", () => {
119
+ const rows = [
120
+ {
121
+ payload: makePayload({
122
+ pass: true,
123
+ reason: "dense_unavailable",
124
+ scored: false,
125
+ real_concept_page_count: 5,
126
+ }),
127
+ },
128
+ ];
129
+ const result = handleMemoryV3GateStats(30, fakeDb(rows));
130
+ const bucket = result.buckets.find((b) => b.pageCountRange === "0–9")!;
131
+ expect(bucket.total).toBe(1);
132
+ expect(bucket.scored).toBe(0);
133
+ expect(bucket.passed).toBe(1);
134
+ // No scored runs → scoredPassRate is null (unscored pass does not count)
135
+ expect(bucket.scoredPassRate).toBeNull();
136
+ });
137
+
138
+ test("unscored pass mixed with scored fail does not inflate scoredPassRate", () => {
139
+ // Regression: before fix, pass-open + scored-fail in same bucket gave 100%
140
+ const rows = [
141
+ {
142
+ payload: makePayload({
143
+ pass: true,
144
+ reason: "dense_unavailable",
145
+ scored: false,
146
+ real_concept_page_count: 30,
147
+ }),
148
+ },
149
+ {
150
+ payload: makePayload({
151
+ pass: false,
152
+ reason: "fail_no_signal",
153
+ scored: true,
154
+ real_concept_page_count: 40,
155
+ }),
156
+ },
157
+ ];
158
+ const result = handleMemoryV3GateStats(30, fakeDb(rows));
159
+ const bucket = result.buckets.find((b) => b.pageCountRange === "10–49")!;
160
+ expect(bucket.total).toBe(2);
161
+ expect(bucket.scored).toBe(1);
162
+ expect(bucket.passed).toBe(1); // total passes (scored + unscored)
163
+ // Only the scored fail counts toward the rate — no scored passes
164
+ expect(bucket.scoredPassRate).toBe(0);
165
+ });
166
+
167
+ test("rows with no real_concept_page_count go to unknownPageCount", () => {
168
+ const rows = [
169
+ {
170
+ payload: makePayload({
171
+ pass: false,
172
+ reason: "fail_no_signal",
173
+ scored: true,
174
+ }),
175
+ },
176
+ ];
177
+ const result = handleMemoryV3GateStats(30, fakeDb(rows));
178
+ expect(result.unknownPageCount.total).toBe(1);
179
+ expect(result.unknownPageCount.passed).toBe(0);
180
+ expect(result.unknownPageCount.reasons).toEqual({ fail_no_signal: 1 });
181
+ for (const b of result.buckets) {
182
+ expect(b.total).toBe(0);
183
+ }
184
+ });
185
+
186
+ test("mixed rows across multiple buckets are aggregated correctly", () => {
187
+ const rows: FakeRow[] = [
188
+ // 0–9 bucket: 1 unscored pass
189
+ {
190
+ payload: makePayload({
191
+ pass: true,
192
+ reason: "dense_disabled",
193
+ scored: false,
194
+ real_concept_page_count: 3,
195
+ }),
196
+ },
197
+ // 10–49 bucket: 1 pass, 1 fail (both scored)
198
+ {
199
+ payload: makePayload({
200
+ pass: true,
201
+ reason: "dense_pass",
202
+ scored: true,
203
+ real_concept_page_count: 20,
204
+ }),
205
+ },
206
+ {
207
+ payload: makePayload({
208
+ pass: false,
209
+ reason: "fail_no_signal",
210
+ scored: true,
211
+ real_concept_page_count: 45,
212
+ }),
213
+ },
214
+ // 50–199 bucket: 2 passes
215
+ {
216
+ payload: makePayload({
217
+ pass: true,
218
+ reason: "sparse_only_strong",
219
+ scored: true,
220
+ real_concept_page_count: 100,
221
+ }),
222
+ },
223
+ {
224
+ payload: makePayload({
225
+ pass: true,
226
+ reason: "dense_pass",
227
+ scored: true,
228
+ real_concept_page_count: 150,
229
+ }),
230
+ },
231
+ ];
232
+
233
+ const result = handleMemoryV3GateStats(30, fakeDb(rows));
234
+ expect(result.totalRuns).toBe(5);
235
+
236
+ const b09 = result.buckets.find((b) => b.pageCountRange === "0–9")!;
237
+ expect(b09.total).toBe(1);
238
+ expect(b09.scored).toBe(0);
239
+ expect(b09.scoredPassRate).toBeNull();
240
+
241
+ const b1049 = result.buckets.find((b) => b.pageCountRange === "10–49")!;
242
+ expect(b1049.total).toBe(2);
243
+ expect(b1049.scored).toBe(2);
244
+ expect(b1049.passed).toBe(1);
245
+ expect(b1049.scoredPassRate).toBe(0.5);
246
+ expect(b1049.reasons).toEqual({ dense_pass: 1, fail_no_signal: 1 });
247
+
248
+ const b50199 = result.buckets.find((b) => b.pageCountRange === "50–199")!;
249
+ expect(b50199.scored).toBe(2);
250
+ expect(b50199.passed).toBe(2);
251
+ expect(b50199.scoredPassRate).toBe(1);
252
+ });
253
+
254
+ test("lookbackDays is clamped to 1–90", () => {
255
+ const r1 = handleMemoryV3GateStats(0, fakeDb([]));
256
+ expect(r1.lookbackDays).toBe(1);
257
+ const r2 = handleMemoryV3GateStats(999, fakeDb([]));
258
+ expect(r2.lookbackDays).toBe(90);
259
+ const r3 = handleMemoryV3GateStats(30, fakeDb([]));
260
+ expect(r3.lookbackDays).toBe(30);
261
+ });
262
+
263
+ test("malformed JSON payload rows are skipped", () => {
264
+ const rows: FakeRow[] = [
265
+ { payload: "not-valid-json" },
266
+ {
267
+ payload: makePayload({
268
+ pass: true,
269
+ reason: "dense_pass",
270
+ scored: true,
271
+ real_concept_page_count: 25,
272
+ }),
273
+ },
274
+ ];
275
+ const result = handleMemoryV3GateStats(30, fakeDb(rows));
276
+ // Only the valid row is counted
277
+ expect(result.totalRuns).toBe(2); // totalRuns counts raw rows before parse
278
+ const bucket = result.buckets.find((b) => b.pageCountRange === "10–49")!;
279
+ expect(bucket.total).toBe(1);
280
+ });
281
+ });