@vellumai/assistant 0.8.7 → 0.8.8

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 (387) hide show
  1. package/Dockerfile +20 -4
  2. package/docker-entrypoint.sh +4 -2
  3. package/docker-init-apt-root.sh +3 -1
  4. package/docker-kata-apt-env.sh +3 -1
  5. package/docker-kata-runtime-family.sh +12 -0
  6. package/docs/architecture/memory.md +1 -1
  7. package/docs/plugins.md +75 -79
  8. package/examples/plugins/echo/README.md +6 -12
  9. package/examples/plugins/echo/register.ts +0 -41
  10. package/node_modules/@vellumai/skill-host-contracts/src/server-message.ts +3 -3
  11. package/openapi.yaml +3381 -348
  12. package/package.json +1 -1
  13. package/scripts/generate-openapi.ts +68 -41
  14. package/src/__tests__/agent-loop-exit-reason.test.ts +34 -39
  15. package/src/__tests__/agent-loop-provider-error-recording.test.ts +1 -1
  16. package/src/__tests__/agent-loop.test.ts +37 -87
  17. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +2 -0
  18. package/src/__tests__/annotate-activity-metadata.test.ts +262 -0
  19. package/src/__tests__/annotate-risk-options.test.ts +2 -3
  20. package/src/__tests__/anthropic-provider.test.ts +95 -2
  21. package/src/__tests__/assistant-event-hub.test.ts +25 -0
  22. package/src/__tests__/assistant-events-sse-shed.test.ts +8 -0
  23. package/src/__tests__/{conversation-stream-state.test.ts → assistant-stream-state.test.ts} +252 -91
  24. package/src/__tests__/auth-fallback-events-store.test.ts +116 -0
  25. package/src/__tests__/background-workers-disk-pressure.test.ts +6 -0
  26. package/src/__tests__/btw-routes.test.ts +62 -3
  27. package/src/__tests__/build-persisted-content.test.ts +184 -0
  28. package/src/__tests__/catalog-files.test.ts +1 -1
  29. package/src/__tests__/clawhub-files.test.ts +1 -1
  30. package/src/__tests__/compaction-pipeline.test.ts +1 -1
  31. package/src/__tests__/compaction.benchmark.test.ts +0 -30
  32. package/src/__tests__/config-watcher.test.ts +1 -1
  33. package/src/__tests__/conversation-abort-tool-results.test.ts +57 -19
  34. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +6 -2
  35. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +10 -4
  36. package/src/__tests__/conversation-agent-loop-overflow.test.ts +313 -1136
  37. package/src/__tests__/conversation-agent-loop.test.ts +596 -1616
  38. package/src/__tests__/conversation-analysis-routes.test.ts +6 -0
  39. package/src/__tests__/conversation-history-web-search.test.ts +11 -1
  40. package/src/__tests__/conversation-pairing.test.ts +4 -31
  41. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +6 -0
  42. package/src/__tests__/conversation-provider-retry-repair.test.ts +26 -5
  43. package/src/__tests__/conversation-queue.test.ts +2 -0
  44. package/src/__tests__/conversation-routes-disk-view.test.ts +3 -0
  45. package/src/__tests__/conversation-routes-slash-commands.test.ts +6 -5
  46. package/src/__tests__/conversation-runtime-assembly.test.ts +170 -229
  47. package/src/__tests__/conversation-runtime-workspace.test.ts +3 -24
  48. package/src/__tests__/conversation-slash-commands.test.ts +8 -42
  49. package/src/__tests__/conversation-slash-queue.test.ts +6 -1
  50. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +84 -0
  51. package/src/__tests__/conversation-sync-tags.test.ts +27 -15
  52. package/src/__tests__/conversation-title-service.test.ts +135 -2
  53. package/src/__tests__/conversation-workspace-injection.test.ts +6 -1
  54. package/src/__tests__/cross-provider-web-search.test.ts +214 -1
  55. package/src/__tests__/db-schedule-syntax-migration.test.ts +5 -0
  56. package/src/__tests__/dm-persistence.test.ts +5 -1
  57. package/src/__tests__/empty-response-hook.test.ts +304 -0
  58. package/src/__tests__/feature-flag-test-helpers.ts +2 -2
  59. package/src/__tests__/gemini-image-service.test.ts +13 -0
  60. package/src/__tests__/helpers/mock-provider.ts +110 -0
  61. package/src/__tests__/helpers/native-web-search-harness.ts +129 -0
  62. package/src/__tests__/history-repair-hook.test.ts +1 -0
  63. package/src/__tests__/identity-intro-cache.test.ts +12 -100
  64. package/src/__tests__/identity-routes.test.ts +248 -7
  65. package/src/__tests__/inbound-slack-persistence.test.ts +5 -1
  66. package/src/__tests__/injector-background-turn.test.ts +2 -8
  67. package/src/__tests__/injector-chain.test.ts +106 -270
  68. package/src/__tests__/injector-disk-pressure.test.ts +3 -12
  69. package/src/__tests__/injector-document-comments.test.ts +2 -2
  70. package/src/__tests__/injector-pkb-v2-silenced.test.ts +30 -22
  71. package/src/__tests__/injector-v3-suppression.test.ts +31 -37
  72. package/src/__tests__/internal-telemetry-routes.test.ts +109 -0
  73. package/src/__tests__/list-messages-page-latest.test.ts +60 -0
  74. package/src/__tests__/list-messages-tool-merge.test.ts +20 -0
  75. package/src/__tests__/llm-usage-store.test.ts +223 -1
  76. package/src/__tests__/memory-retrieval-hook.test.ts +297 -0
  77. package/src/__tests__/memory-v2-static-injector.test.ts +103 -35
  78. package/src/__tests__/native-web-search.test.ts +191 -0
  79. package/src/__tests__/onboarding-template-contract.test.ts +2 -0
  80. package/src/__tests__/openai-image-service.test.ts +17 -0
  81. package/src/__tests__/openai-provider.test.ts +31 -1
  82. package/src/__tests__/persist-unsendable-image.test.ts +215 -0
  83. package/src/__tests__/persistence-secret-redaction.test.ts +1 -0
  84. package/src/__tests__/pipeline-runner.test.ts +29 -39
  85. package/src/__tests__/pkb-autoinject.test.ts +2 -5
  86. package/src/__tests__/plugin-bootstrap.test.ts +13 -28
  87. package/src/__tests__/plugin-registry.test.ts +0 -27
  88. package/src/__tests__/plugin-types.test.ts +2 -125
  89. package/src/__tests__/process-message-display-content.test.ts +6 -2
  90. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +5 -1
  91. package/src/__tests__/resolve-trust-class.test.ts +4 -4
  92. package/src/__tests__/runtime-events-sse-reconnect.test.ts +60 -23
  93. package/src/__tests__/schedule-routes.test.ts +603 -2
  94. package/src/__tests__/schedule-store.test.ts +41 -0
  95. package/src/__tests__/schedule-tools.test.ts +35 -0
  96. package/src/__tests__/server-history-render.test.ts +314 -1
  97. package/src/__tests__/skillssh-files.test.ts +1 -1
  98. package/src/__tests__/system-prompt.test.ts +20 -0
  99. package/src/__tests__/task-scheduler.test.ts +162 -1
  100. package/src/__tests__/terminal-tools.test.ts +6 -1
  101. package/src/__tests__/title-generate-hook.test.ts +319 -0
  102. package/src/__tests__/tool-error-hook.test.ts +278 -0
  103. package/src/__tests__/tool-preview-lifecycle.test.ts +468 -5
  104. package/src/__tests__/tool-result-metadata-plumbing.test.ts +1 -0
  105. package/src/__tests__/tool-result-truncate-hook.test.ts +127 -0
  106. package/src/__tests__/tool-result-truncation.test.ts +0 -2
  107. package/src/__tests__/ui-choice-copy-surfaces.test.ts +254 -0
  108. package/src/__tests__/ui-work-result-surface.test.ts +159 -0
  109. package/src/__tests__/usage-routes.test.ts +285 -1
  110. package/src/__tests__/user-plugin-loader.test.ts +2 -2
  111. package/src/__tests__/voice-session-bridge.test.ts +6 -3
  112. package/src/__tests__/web-search-backend-failure.test.ts +166 -0
  113. package/src/agent/loop.ts +346 -442
  114. package/src/api/events/assistant-thinking-delta.ts +33 -0
  115. package/src/api/events/tool-output-chunk.ts +45 -0
  116. package/src/api/events/tool-use-preview-start.ts +32 -0
  117. package/src/api/events/trace-event.ts +69 -0
  118. package/src/api/index.ts +48 -13
  119. package/src/api/responses/conversation-message.ts +368 -0
  120. package/src/avatar/__tests__/avatar-store.test.ts +34 -29
  121. package/src/cli/commands/__tests__/notifications.test.ts +58 -14
  122. package/src/cli/commands/notifications.ts +112 -60
  123. package/src/config/assistant-feature-flags.ts +22 -11
  124. package/src/config/bundled-skills/app-builder/SKILL.md +3 -20
  125. package/src/config/bundled-skills/app-builder/references/examples/README.md +17 -0
  126. package/src/config/bundled-skills/app-builder/references/examples/expense-tracker.md +515 -0
  127. package/src/config/bundled-skills/app-builder/references/examples/focus-timer.md +342 -0
  128. package/src/config/bundled-skills/app-builder/references/examples/habit-tracker.md +490 -0
  129. package/src/config/bundled-skills/document-editor/SKILL.md +1 -1
  130. package/src/config/bundled-skills/messaging/SKILL.md +0 -7
  131. package/src/config/feature-flag-cache.ts +3 -3
  132. package/src/config/feature-flag-registry.json +35 -3
  133. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  134. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -0
  135. package/src/config/schemas/llm.ts +1 -0
  136. package/src/config/schemas/memory-v2.ts +8 -0
  137. package/src/config/schemas/memory-v3.ts +8 -0
  138. package/src/config/schemas/platform.ts +8 -0
  139. package/src/config/seed-inference-profiles.ts +2 -2
  140. package/src/config/skills.ts +13 -0
  141. package/src/context/compactor.ts +1 -1
  142. package/src/context/strip-injections.ts +122 -0
  143. package/src/context/token-estimator.ts +23 -0
  144. package/src/context/tool-result-truncation.ts +0 -23
  145. package/src/context/window-manager.ts +3 -6
  146. package/src/credential-execution/executable-discovery.ts +16 -0
  147. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +6 -0
  148. package/src/daemon/__tests__/inference-profile-notification.test.ts +153 -0
  149. package/src/daemon/__tests__/native-web-search-metadata.test.ts +10 -8
  150. package/src/daemon/assistant-attachments.ts +1 -1
  151. package/src/daemon/config-watcher.ts +2 -2
  152. package/src/daemon/context-overflow-reducer.ts +0 -1
  153. package/src/daemon/conversation-agent-loop-handlers.ts +605 -153
  154. package/src/daemon/conversation-agent-loop.ts +281 -760
  155. package/src/daemon/conversation-history.ts +5 -4
  156. package/src/daemon/conversation-lifecycle.ts +3 -4
  157. package/src/daemon/conversation-messaging.ts +7 -6
  158. package/src/daemon/conversation-process.ts +11 -16
  159. package/src/daemon/conversation-runtime-assembly.ts +130 -347
  160. package/src/daemon/conversation-slash.ts +6 -25
  161. package/src/daemon/conversation-surfaces.ts +222 -4
  162. package/src/daemon/conversation-tool-setup.ts +2 -29
  163. package/src/daemon/conversation.ts +32 -14
  164. package/src/daemon/external-plugins-bootstrap.ts +9 -10
  165. package/src/daemon/handlers/config-a2a.ts +51 -36
  166. package/src/daemon/handlers/config-slack-channel.ts +20 -14
  167. package/src/daemon/handlers/config-telegram.ts +16 -2
  168. package/src/daemon/handlers/shared.ts +156 -84
  169. package/src/daemon/handlers/skills.ts +39 -10
  170. package/src/daemon/lifecycle.ts +4 -0
  171. package/src/daemon/message-types/apps.ts +1 -29
  172. package/src/daemon/message-types/messages.ts +9 -57
  173. package/src/daemon/message-types/skills.ts +2 -0
  174. package/src/daemon/message-types/surfaces.ts +136 -3
  175. package/src/daemon/now-scratchpad.ts +21 -0
  176. package/src/daemon/orphan-reaper.test.ts +210 -0
  177. package/src/daemon/orphan-reaper.ts +240 -0
  178. package/src/daemon/persist-unsendable-image.ts +117 -0
  179. package/src/daemon/process-message.ts +1 -3
  180. package/src/daemon/trace-emitter.ts +6 -4
  181. package/src/daemon/trust-context.ts +19 -0
  182. package/src/daemon/wake-target-adapter.ts +3 -1
  183. package/src/home/home-greeting-cache.ts +24 -1
  184. package/src/ipc/gateway-client.test.ts +2 -2
  185. package/src/ipc/gateway-client.ts +3 -3
  186. package/src/media/gemini-image-service.ts +15 -0
  187. package/src/media/openai-image-service.ts +14 -0
  188. package/src/media/types.ts +34 -0
  189. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +56 -0
  190. package/src/memory/auth-fallback-events-store.ts +94 -0
  191. package/src/memory/conversation-title-service.ts +65 -41
  192. package/src/memory/db-init.ts +4 -0
  193. package/src/memory/graph/__tests__/conversation-graph-memory-registry.test.ts +119 -0
  194. package/src/memory/graph/conversation-graph-memory.ts +65 -0
  195. package/src/memory/jobs-store.ts +33 -0
  196. package/src/memory/jobs-worker.ts +31 -4
  197. package/src/memory/llm-usage-store.ts +224 -50
  198. package/src/memory/migrations/222-strip-placeholder-sentinels-from-messages.ts +6 -5
  199. package/src/memory/migrations/270-schedule-source-conversation.ts +13 -0
  200. package/src/memory/migrations/271-create-auth-fallback-events.ts +21 -0
  201. package/src/memory/migrations/index.ts +2 -0
  202. package/src/memory/pkb/autoinject.ts +61 -0
  203. package/src/memory/pkb/context.ts +50 -0
  204. package/src/memory/pkb/types.ts +14 -0
  205. package/src/memory/schedule-attribution-sql.ts +104 -0
  206. package/src/memory/schema/infrastructure.ts +16 -0
  207. package/src/memory/usage-grouped-buckets.ts +6 -1
  208. package/src/memory/v2/__tests__/consolidation-job.test.ts +1 -1
  209. package/src/memory/v2/consolidation-job.ts +1 -1
  210. package/src/memory/v3/__tests__/health.test.ts +16 -0
  211. package/src/memory/v3/__tests__/orchestrate.test.ts +45 -9
  212. package/src/memory/v3/__tests__/provider-blocks.test.ts +13 -0
  213. package/src/memory/v3/__tests__/router.test.ts +101 -29
  214. package/src/memory/v3/__tests__/selector.test.ts +93 -27
  215. package/src/memory/v3/__tests__/shadow-plugin.test.ts +23 -5
  216. package/src/memory/v3/health.ts +0 -0
  217. package/src/memory/v3/llm-retry.ts +32 -0
  218. package/src/memory/v3/orchestrate.ts +26 -14
  219. package/src/memory/v3/provider-blocks.ts +15 -5
  220. package/src/memory/v3/router.ts +48 -42
  221. package/src/memory/v3/selector.ts +57 -42
  222. package/src/memory/v3/shadow-plugin.ts +47 -15
  223. package/src/memory/v3/types.ts +8 -0
  224. package/src/notifications/conversation-pairing.ts +8 -15
  225. package/src/notifications/decision-engine.ts +6 -3
  226. package/src/notifications/home-feed-side-effect.ts +12 -1
  227. package/src/permissions/prompter.ts +4 -0
  228. package/src/plugin-api/constants.ts +4 -0
  229. package/src/plugin-api/index.ts +8 -1
  230. package/src/plugin-api/types.ts +151 -1
  231. package/src/plugins/defaults/empty-response/hooks/stop.ts +126 -0
  232. package/src/plugins/defaults/empty-response/register.ts +8 -13
  233. package/src/plugins/defaults/index.ts +1 -15
  234. package/src/plugins/defaults/injectors/register.ts +243 -74
  235. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +91 -0
  236. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +216 -0
  237. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +35 -0
  238. package/src/plugins/defaults/title-generate/hooks/stop.ts +75 -0
  239. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +35 -0
  240. package/src/plugins/defaults/title-generate/package.json +1 -1
  241. package/src/plugins/defaults/title-generate/register.ts +18 -18
  242. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +118 -0
  243. package/src/plugins/defaults/tool-error/package.json +1 -1
  244. package/src/plugins/defaults/tool-error/register.ts +9 -21
  245. package/src/plugins/defaults/tool-result-truncate/hooks/post-tool-use.ts +32 -0
  246. package/src/plugins/defaults/tool-result-truncate/register.ts +10 -21
  247. package/src/plugins/defaults/tool-result-truncate/terminal.ts +37 -18
  248. package/src/plugins/pipeline.ts +6 -18
  249. package/src/plugins/registry.ts +8 -25
  250. package/src/plugins/types.ts +43 -474
  251. package/src/proactive-artifact/aux-message-injector.ts +3 -3
  252. package/src/proactive-artifact/job.test.ts +7 -12
  253. package/src/prompts/__tests__/system-prompt.test.ts +36 -0
  254. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +62 -0
  255. package/src/prompts/templates/BOOTSTRAP.md +2 -2
  256. package/src/prompts/templates/system-sections.ts +15 -0
  257. package/src/providers/anthropic/client.ts +37 -29
  258. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +112 -0
  259. package/src/providers/openai/chat-completions-provider.ts +44 -0
  260. package/src/providers/openrouter/client.ts +1 -0
  261. package/src/providers/placeholder-sentinels.ts +35 -0
  262. package/src/runtime/__tests__/agent-wake.test.ts +5 -1
  263. package/src/runtime/agent-wake.ts +2 -2
  264. package/src/runtime/assistant-event-hub.ts +36 -6
  265. package/src/runtime/{conversation-stream-state.ts → assistant-stream-state.ts} +132 -58
  266. package/src/runtime/http-router.ts +16 -21
  267. package/src/runtime/http-types.ts +16 -70
  268. package/src/runtime/pending-interactions.ts +1 -0
  269. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +265 -2
  270. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +31 -1
  271. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +6 -2
  272. package/src/runtime/routes/__tests__/tts-routes.test.ts +6 -2
  273. package/src/runtime/routes/app-management-routes.ts +6 -117
  274. package/src/runtime/routes/app-routes.ts +13 -15
  275. package/src/runtime/routes/attachment-routes.ts +26 -15
  276. package/src/runtime/routes/avatar-routes.ts +26 -0
  277. package/src/runtime/routes/btw-routes.ts +29 -23
  278. package/src/runtime/routes/consolidation-routes.ts +120 -20
  279. package/src/runtime/routes/conversation-query-routes.ts +2 -0
  280. package/src/runtime/routes/conversation-routes.ts +358 -184
  281. package/src/runtime/routes/documents-routes.ts +4 -0
  282. package/src/runtime/routes/domain-routes.ts +51 -37
  283. package/src/runtime/routes/epoch-millis-range.ts +34 -0
  284. package/src/runtime/routes/events-routes.ts +28 -34
  285. package/src/runtime/routes/gateway-log-routes.ts +26 -4
  286. package/src/runtime/routes/heartbeat-routes.ts +32 -12
  287. package/src/runtime/routes/identity-intro-cache.ts +11 -34
  288. package/src/runtime/routes/identity-routes.ts +208 -17
  289. package/src/runtime/routes/image-generation-routes.ts +40 -2
  290. package/src/runtime/routes/index.ts +2 -0
  291. package/src/runtime/routes/integrations/a2a.ts +12 -10
  292. package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +16 -0
  293. package/src/runtime/routes/integrations/slack/channel.ts +4 -0
  294. package/src/runtime/routes/integrations/slack/share.ts +27 -6
  295. package/src/runtime/routes/integrations/telegram.ts +6 -0
  296. package/src/runtime/routes/integrations/twilio.ts +42 -0
  297. package/src/runtime/routes/internal-telemetry-routes.ts +88 -0
  298. package/src/runtime/routes/log-export-routes.ts +8 -0
  299. package/src/runtime/routes/memory-v2-routes.ts +15 -8
  300. package/src/runtime/routes/memory-v3-routes.ts +50 -28
  301. package/src/runtime/routes/oauth-apps.ts +66 -12
  302. package/src/runtime/routes/oauth-providers.ts +44 -5
  303. package/src/runtime/routes/platform-routes.ts +81 -5
  304. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +6 -4
  305. package/src/runtime/routes/playground/force-compact.ts +1 -1
  306. package/src/runtime/routes/rename-conversation-routes.ts +5 -0
  307. package/src/runtime/routes/schedule-routes.ts +152 -42
  308. package/src/runtime/routes/secret-routes.ts +14 -2
  309. package/src/runtime/routes/skills-routes.ts +43 -14
  310. package/src/runtime/routes/tool-call-confirmation-enrichment.test.ts +161 -0
  311. package/src/runtime/routes/tool-call-confirmation-enrichment.ts +107 -0
  312. package/src/runtime/routes/trust-rules-routes.ts +26 -2
  313. package/src/runtime/routes/tts-routes.ts +35 -0
  314. package/src/runtime/routes/types.ts +66 -8
  315. package/src/runtime/routes/usage-routes.ts +47 -39
  316. package/src/runtime/routes/webhook-routes.ts +41 -2
  317. package/src/runtime/routes/workspace-routes.ts +4 -0
  318. package/src/runtime/services/__tests__/analyze-conversation.test.ts +6 -0
  319. package/src/runtime/services/analyze-conversation.ts +2 -2
  320. package/src/schedule/schedule-store.ts +20 -1
  321. package/src/schedule/schedule-usage-store.ts +83 -0
  322. package/src/schedule/scheduler.ts +12 -5
  323. package/src/skills/catalog-files.ts +2 -2
  324. package/src/skills/catalog-install.ts +3 -0
  325. package/src/skills/categories-cache.ts +118 -0
  326. package/src/skills/clawhub-files.ts +1 -2
  327. package/src/skills/skillssh-files.ts +1 -2
  328. package/src/telemetry/types.ts +29 -1
  329. package/src/telemetry/usage-telemetry-reporter.test.ts +112 -3
  330. package/src/telemetry/usage-telemetry-reporter.ts +57 -2
  331. package/src/tools/executor.ts +1 -53
  332. package/src/tools/network/__tests__/web-search-metadata.test.ts +7 -1
  333. package/src/tools/network/__tests__/web-search.test.ts +11 -3
  334. package/src/tools/network/web-search-error.test.ts +248 -0
  335. package/src/tools/network/web-search-error.ts +267 -0
  336. package/src/tools/network/web-search.ts +207 -48
  337. package/src/tools/schedule/create.ts +2 -0
  338. package/src/tools/terminal/safe-env.ts +10 -1
  339. package/src/tools/ui-surface/definitions.ts +9 -1
  340. package/src/tts/__tests__/provider-catalog-consistency.test.ts +85 -1
  341. package/src/tts/provider-catalog.ts +76 -1
  342. package/src/util/mutex.ts +47 -0
  343. package/src/workspace/git-service.ts +1 -42
  344. package/src/workspace/migrations/095-bump-heartbeat-interval-30m-to-60m.ts +51 -0
  345. package/src/workspace/migrations/096-reduce-quality-profile-effort.ts +72 -0
  346. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +93 -0
  347. package/src/workspace/migrations/registry.ts +6 -0
  348. package/src/__tests__/bootstrap-turn-cleanup.test.ts +0 -44
  349. package/src/__tests__/empty-response-pipeline.test.ts +0 -423
  350. package/src/__tests__/llm-call-pipeline.test.ts +0 -287
  351. package/src/__tests__/memory-retrieval-pipeline.test.ts +0 -418
  352. package/src/__tests__/persistence-pipeline.test.ts +0 -503
  353. package/src/__tests__/title-generate-pipeline.test.ts +0 -211
  354. package/src/__tests__/token-estimate-pipeline.test.ts +0 -479
  355. package/src/__tests__/tool-error-pipeline.test.ts +0 -241
  356. package/src/__tests__/tool-execute-pipeline.test.ts +0 -417
  357. package/src/__tests__/tool-result-truncate-pipeline.test.ts +0 -341
  358. package/src/daemon/bootstrap-turn-cleanup.ts +0 -45
  359. package/src/gallery/default-gallery.ts +0 -1359
  360. package/src/gallery/gallery-manifest.ts +0 -28
  361. package/src/home/feature-gate.ts +0 -22
  362. package/src/plugins/defaults/empty-response/middlewares/emptyResponse.ts +0 -22
  363. package/src/plugins/defaults/empty-response/terminal.ts +0 -106
  364. package/src/plugins/defaults/injectors/package.json +0 -15
  365. package/src/plugins/defaults/llm-call/middlewares/llmCall.ts +0 -17
  366. package/src/plugins/defaults/llm-call/package.json +0 -15
  367. package/src/plugins/defaults/llm-call/register.ts +0 -45
  368. package/src/plugins/defaults/memory-retrieval/middlewares/memoryRetrieval.ts +0 -17
  369. package/src/plugins/defaults/memory-retrieval/package.json +0 -15
  370. package/src/plugins/defaults/memory-retrieval/register.ts +0 -181
  371. package/src/plugins/defaults/persistence/middlewares/persistence.ts +0 -19
  372. package/src/plugins/defaults/persistence/package.json +0 -15
  373. package/src/plugins/defaults/persistence/register.ts +0 -38
  374. package/src/plugins/defaults/persistence/terminal.ts +0 -83
  375. package/src/plugins/defaults/title-generate/terminal.ts +0 -31
  376. package/src/plugins/defaults/token-estimate/middlewares/tokenEstimate.ts +0 -23
  377. package/src/plugins/defaults/token-estimate/package.json +0 -15
  378. package/src/plugins/defaults/token-estimate/register.ts +0 -34
  379. package/src/plugins/defaults/token-estimate/terminal.ts +0 -40
  380. package/src/plugins/defaults/tool-error/middlewares/toolError.ts +0 -21
  381. package/src/plugins/defaults/tool-error/terminal.ts +0 -47
  382. package/src/plugins/defaults/tool-execute/middlewares/toolExecute.ts +0 -23
  383. package/src/plugins/defaults/tool-execute/package.json +0 -15
  384. package/src/plugins/defaults/tool-execute/register.ts +0 -49
  385. package/src/plugins/defaults/tool-result-truncate/middlewares/toolResultTruncate.ts +0 -23
  386. package/src/plugins/defaults/tool-result-truncate/types.ts +0 -22
  387. package/src/skills/category-inference.ts +0 -111
@@ -72,6 +72,7 @@ const CONSOLIDATE_CHECKPOINT_KEY = "memory_v2_consolidate_last_run";
72
72
 
73
73
  function buildConfig(overrides: {
74
74
  v2Enabled?: boolean;
75
+ consolidationEnabled?: boolean;
75
76
  intervalHours?: number;
76
77
  maxBufferLines?: number | null;
77
78
  }) {
@@ -79,6 +80,13 @@ function buildConfig(overrides: {
79
80
  if (overrides.v2Enabled !== undefined) {
80
81
  partial.memory.v2.enabled = overrides.v2Enabled;
81
82
  }
83
+ if (overrides.consolidationEnabled !== undefined) {
84
+ (
85
+ partial.memory.v2 as typeof partial.memory.v2 & {
86
+ consolidation_enabled?: boolean;
87
+ }
88
+ ).consolidation_enabled = overrides.consolidationEnabled;
89
+ }
82
90
  if (overrides.intervalHours !== undefined) {
83
91
  partial.memory.v2.consolidation_interval_hours = overrides.intervalHours;
84
92
  }
@@ -110,6 +118,15 @@ function countPendingJobs(type: string): number {
110
118
  .all().length;
111
119
  }
112
120
 
121
+ function consolidationJobPayloads(): Record<string, unknown>[] {
122
+ return getDb()
123
+ .select({ payload: memoryJobs.payload })
124
+ .from(memoryJobs)
125
+ .where(eq(memoryJobs.type, "memory_v2_consolidate"))
126
+ .all()
127
+ .map((row) => JSON.parse(row.payload) as Record<string, unknown>);
128
+ }
129
+
113
130
  // Initialize the DB once for the file; clear per-test tables in beforeEach
114
131
  // rather than tearing down the singleton, which is slow because it re-runs
115
132
  // every migration on the next access.
@@ -138,6 +155,7 @@ describe("maybeEnqueueGraphMaintenanceJobs — memory v2 consolidation", () => {
138
155
  maybeEnqueueGraphMaintenanceJobs(config, Date.now());
139
156
 
140
157
  expect(countPendingJobs("memory_v2_consolidate")).toBe(1);
158
+ expect(consolidationJobPayloads()).toEqual([{ trigger: "automatic" }]);
141
159
  // v1 entries are suppressed when v2 is active.
142
160
  expect(countPendingJobs("graph_decay")).toBe(0);
143
161
  expect(countPendingJobs("graph_consolidate")).toBe(0);
@@ -170,6 +188,7 @@ describe("maybeEnqueueGraphMaintenanceJobs — memory v2 consolidation", () => {
170
188
  maybeEnqueueGraphMaintenanceJobs(config, now);
171
189
 
172
190
  expect(countPendingJobs("memory_v2_consolidate")).toBe(1);
191
+ expect(consolidationJobPayloads()).toEqual([{ trigger: "automatic" }]);
173
192
  });
174
193
 
175
194
  test("respects a custom consolidation_interval_hours value", () => {
@@ -231,6 +250,28 @@ describe("maybeEnqueueGraphMaintenanceJobs — memory v2 consolidation", () => {
231
250
  expect(countPendingJobs("graph_narrative_refine")).toBe(1);
232
251
  expect(countPendingJobs("memory_v2_consolidate")).toBe(0);
233
252
  });
253
+
254
+ test("automatic consolidation off suppresses the v2 schedule without re-enabling v1 maintenance", () => {
255
+ const config = buildConfig({
256
+ v2Enabled: true,
257
+ consolidationEnabled: false,
258
+ intervalHours: 1,
259
+ });
260
+
261
+ deleteMemoryCheckpoint("graph_maintenance:decay:last_run");
262
+ deleteMemoryCheckpoint("graph_maintenance:consolidate:last_run");
263
+ deleteMemoryCheckpoint("graph_maintenance:pattern_scan:last_run");
264
+ deleteMemoryCheckpoint("graph_maintenance:narrative:last_run");
265
+ deleteMemoryCheckpoint(CONSOLIDATE_CHECKPOINT_KEY);
266
+
267
+ maybeEnqueueGraphMaintenanceJobs(config, Date.now());
268
+
269
+ expect(countPendingJobs("memory_v2_consolidate")).toBe(0);
270
+ expect(countPendingJobs("graph_decay")).toBe(0);
271
+ expect(countPendingJobs("graph_consolidate")).toBe(0);
272
+ expect(countPendingJobs("graph_pattern_scan")).toBe(0);
273
+ expect(countPendingJobs("graph_narrative_refine")).toBe(0);
274
+ });
234
275
  });
235
276
 
236
277
  describe("maybeEnqueueGraphMaintenanceJobs — buffer-size trigger", () => {
@@ -369,4 +410,19 @@ describe("maybeEnqueueGraphMaintenanceJobs — buffer-size trigger", () => {
369
410
 
370
411
  expect(countPendingJobs("memory_v2_consolidate")).toBe(0);
371
412
  });
413
+
414
+ test("size trigger inert when automatic consolidation is disabled", () => {
415
+ const config = buildConfig({
416
+ v2Enabled: true,
417
+ consolidationEnabled: false,
418
+ intervalHours: 1,
419
+ maxBufferLines: 1,
420
+ });
421
+
422
+ writeBuffer(100);
423
+
424
+ maybeEnqueueGraphMaintenanceJobs(config, Date.now());
425
+
426
+ expect(countPendingJobs("memory_v2_consolidate")).toBe(0);
427
+ });
372
428
  });
@@ -0,0 +1,94 @@
1
+ import { and, asc, eq, gt, or } from "drizzle-orm";
2
+ import { v4 as uuid } from "uuid";
3
+
4
+ import { getConfig } from "../config/loader.js";
5
+ import { getDb } from "./db-connection.js";
6
+ import { authFallbackEvents } from "./schema.js";
7
+
8
+ /** A single aggregated auth-fallback count for one (guard, path, failure_kind). */
9
+ export interface AuthFallbackCount {
10
+ guard: string;
11
+ path: string;
12
+ failureKind: string;
13
+ count: number;
14
+ }
15
+
16
+ /** A persisted auth-fallback event row. */
17
+ export interface AuthFallbackEvent {
18
+ id: string;
19
+ createdAt: number;
20
+ guard: string;
21
+ path: string;
22
+ failureKind: string;
23
+ count: number;
24
+ windowStart: number;
25
+ windowEnd: number;
26
+ }
27
+
28
+ /**
29
+ * Record a batch of aggregated auth-fallback counts forwarded by the gateway —
30
+ * one row per count entry, all sharing the same flush window. Returns the
31
+ * number of rows recorded, or 0 when usage data collection is disabled (the
32
+ * counts are dropped to honor the opt-out, matching the rest of telemetry).
33
+ */
34
+ export function recordAuthFallbackCounts(
35
+ windowStart: number,
36
+ windowEnd: number,
37
+ counts: AuthFallbackCount[],
38
+ ): number {
39
+ if (!getConfig().collectUsageData) return 0;
40
+ if (counts.length === 0) return 0;
41
+ const db = getDb();
42
+ const createdAt = Date.now();
43
+ const rows = counts.map((c) => ({
44
+ id: uuid(),
45
+ createdAt,
46
+ guard: c.guard,
47
+ path: c.path,
48
+ failureKind: c.failureKind,
49
+ count: c.count,
50
+ windowStart,
51
+ windowEnd,
52
+ }));
53
+ db.insert(authFallbackEvents).values(rows).run();
54
+ return rows.length;
55
+ }
56
+
57
+ /**
58
+ * Query auth-fallback events that haven't been reported to telemetry yet.
59
+ * Uses a compound cursor (createdAt + id) for reliable watermarking.
60
+ */
61
+ export function queryUnreportedAuthFallbackEvents(
62
+ afterCreatedAt: number,
63
+ afterId: string | undefined,
64
+ limit: number,
65
+ ): AuthFallbackEvent[] {
66
+ const db = getDb();
67
+ const rows = db
68
+ .select({
69
+ id: authFallbackEvents.id,
70
+ createdAt: authFallbackEvents.createdAt,
71
+ guard: authFallbackEvents.guard,
72
+ path: authFallbackEvents.path,
73
+ failureKind: authFallbackEvents.failureKind,
74
+ count: authFallbackEvents.count,
75
+ windowStart: authFallbackEvents.windowStart,
76
+ windowEnd: authFallbackEvents.windowEnd,
77
+ })
78
+ .from(authFallbackEvents)
79
+ .where(
80
+ afterId
81
+ ? or(
82
+ gt(authFallbackEvents.createdAt, afterCreatedAt),
83
+ and(
84
+ eq(authFallbackEvents.createdAt, afterCreatedAt),
85
+ gt(authFallbackEvents.id, afterId),
86
+ ),
87
+ )
88
+ : gt(authFallbackEvents.createdAt, afterCreatedAt),
89
+ )
90
+ .orderBy(asc(authFallbackEvents.createdAt), asc(authFallbackEvents.id))
91
+ .limit(limit)
92
+ .all();
93
+ return rows;
94
+ }
@@ -10,7 +10,9 @@
10
10
  import { getConfiguredProvider } from "../providers/provider-send-message.js";
11
11
  import type { Provider } from "../providers/types.js";
12
12
  import { runBtwSidechain } from "../runtime/btw-sidechain.js";
13
+ import { publishConversationTitleChanged } from "../runtime/sync/resource-sync-events.js";
13
14
  import { getLogger } from "../util/logger.js";
15
+ import { Mutex } from "../util/mutex.js";
14
16
  import {
15
17
  getConversation,
16
18
  getMessages,
@@ -93,8 +95,6 @@ export interface GenerateTitleParams {
93
95
  userMessage?: string;
94
96
  /** Assistant response text (first turn). */
95
97
  assistantResponse?: string;
96
- /** Callback to emit title update events. */
97
- onTitleUpdated?: (title: string) => void;
98
98
  /** Abort signal. */
99
99
  signal?: AbortSignal;
100
100
  }
@@ -106,14 +106,8 @@ export interface GenerateTitleParams {
106
106
  export async function generateAndPersistConversationTitle(
107
107
  params: GenerateTitleParams,
108
108
  ): Promise<{ title: string; updated: boolean }> {
109
- const {
110
- conversationId,
111
- context,
112
- userMessage,
113
- assistantResponse,
114
- onTitleUpdated,
115
- signal,
116
- } = params;
109
+ const { conversationId, context, userMessage, assistantResponse, signal } =
110
+ params;
117
111
 
118
112
  // Check current title is replaceable
119
113
  const conversation = getConversation(conversationId);
@@ -127,7 +121,7 @@ export async function generateAndPersistConversationTitle(
127
121
  // No provider available — fall back to context-derived title or untitled
128
122
  const fallback = deriveFallbackTitle(context) ?? UNTITLED_FALLBACK;
129
123
  updateConversationTitle(conversationId, fallback, 1);
130
- onTitleUpdated?.(fallback);
124
+ publishConversationTitleChanged(conversationId, fallback);
131
125
  return { title: fallback, updated: true };
132
126
  }
133
127
 
@@ -139,7 +133,7 @@ export async function generateAndPersistConversationTitle(
139
133
  tools: [],
140
134
  callSite: "conversationTitle",
141
135
  signal,
142
- timeoutMs: 10_000,
136
+ timeoutMs: 15_000,
143
137
  });
144
138
  const title = normalizeTitle(result.text);
145
139
  if (title) {
@@ -150,7 +144,7 @@ export async function generateAndPersistConversationTitle(
150
144
  }
151
145
 
152
146
  updateConversationTitle(conversationId, title, 1);
153
- onTitleUpdated?.(title);
147
+ publishConversationTitleChanged(conversationId, title);
154
148
  log.info({ conversationId, title }, "Auto-generated conversation title");
155
149
  return { title, updated: true };
156
150
  }
@@ -167,36 +161,61 @@ export async function generateAndPersistConversationTitle(
167
161
 
168
162
  const fallback = deriveFallbackTitle(context) ?? UNTITLED_FALLBACK;
169
163
  updateConversationTitle(conversationId, fallback, 1);
170
- onTitleUpdated?.(fallback);
164
+ publishConversationTitleChanged(conversationId, fallback);
171
165
  return { title: fallback, updated: true };
172
166
  }
173
167
 
168
+ // ── Serial title-generation queue ────────────────────────────────────
169
+
170
+ /**
171
+ * Each title generation makes an LLM call. Without serialization, burst
172
+ * conversation creation (e.g. 5 new chats in quick succession) fires N
173
+ * concurrent requests that can hit provider rate limits or contend for
174
+ * API capacity, causing later calls to time out and fall back to
175
+ * "Untitled Conversation".
176
+ *
177
+ * A serial queue ensures at most one title-generation LLM call is
178
+ * in-flight at a time. Each call is lightweight (~1–3 s for a ≤5-word
179
+ * title), so the added serial latency is modest and invisible to the
180
+ * user (the UI shows "Generating title…" as a placeholder during the
181
+ * wait). Both initial generation and second-pass regeneration share
182
+ * this queue since they hit the same provider.
183
+ */
184
+ export const titleMutex = new Mutex();
185
+
174
186
  /**
175
187
  * Fire-and-forget wrapper for title generation. Failures are logged
176
188
  * but do not propagate. On failure, replaces loading placeholder with
177
189
  * a stable fallback title so loading state is never permanent.
190
+ *
191
+ * Calls are serialized via {@link titleMutex} so burst conversation
192
+ * creation does not overwhelm the LLM provider.
178
193
  */
179
194
  export function queueGenerateConversationTitle(
180
195
  params: GenerateTitleParams,
181
196
  ): void {
182
- generateAndPersistConversationTitle(params).catch((err) => {
183
- log.warn(
184
- { err, conversationId: params.conversationId },
185
- "Failed to generate conversation title (non-fatal)",
186
- );
187
- // Replace loading placeholder with stable fallback
188
- try {
189
- const conversation = getConversation(params.conversationId);
190
- if (conversation && conversation.title === GENERATING_TITLE) {
191
- const fallback =
192
- deriveFallbackTitle(params.context) ?? UNTITLED_FALLBACK;
193
- updateConversationTitle(params.conversationId, fallback);
194
- params.onTitleUpdated?.(fallback);
197
+ void titleMutex
198
+ .withLock(async () => {
199
+ await generateAndPersistConversationTitle(params);
200
+ })
201
+ .catch((err) => {
202
+ log.warn(
203
+ { err, conversationId: params.conversationId },
204
+ "Failed to generate conversation title (non-fatal)",
205
+ );
206
+ // Replace loading placeholder with stable fallback
207
+ try {
208
+ const conversation = getConversation(params.conversationId);
209
+ if (conversation && conversation.title === GENERATING_TITLE) {
210
+ const fallback =
211
+ deriveFallbackTitle(params.context) ?? UNTITLED_FALLBACK;
212
+ updateConversationTitle(params.conversationId, fallback);
213
+ publishConversationTitleChanged(params.conversationId, fallback);
214
+ }
215
+ } catch {
216
+ // Best-effort
195
217
  }
196
- } catch {
197
- // Best-effort
198
- }
199
- });
218
+ });
200
219
  }
201
220
 
202
221
  // ── Title regeneration (second pass) ─────────────────────────────────
@@ -204,7 +223,6 @@ export function queueGenerateConversationTitle(
204
223
  export interface RegenerateTitleParams {
205
224
  conversationId: string;
206
225
  provider?: Provider;
207
- onTitleUpdated?: (title: string) => void;
208
226
  signal?: AbortSignal;
209
227
  }
210
228
 
@@ -216,7 +234,7 @@ export interface RegenerateTitleParams {
216
234
  export async function regenerateConversationTitle(
217
235
  params: RegenerateTitleParams,
218
236
  ): Promise<{ title: string; updated: boolean }> {
219
- const { conversationId, onTitleUpdated, signal } = params;
237
+ const { conversationId, signal } = params;
220
238
 
221
239
  const conversation = getConversation(conversationId);
222
240
  if (!conversation || !conversation.isAutoTitle) {
@@ -249,7 +267,7 @@ export async function regenerateConversationTitle(
249
267
  tools: [],
250
268
  callSite: "conversationTitle",
251
269
  signal,
252
- timeoutMs: 10_000,
270
+ timeoutMs: 15_000,
253
271
  });
254
272
  const title = normalizeTitle(result.text);
255
273
  if (title) {
@@ -260,7 +278,7 @@ export async function regenerateConversationTitle(
260
278
  }
261
279
 
262
280
  updateConversationTitle(conversationId, title, 1);
263
- onTitleUpdated?.(title);
281
+ publishConversationTitleChanged(conversationId, title);
264
282
  log.info(
265
283
  { conversationId, title },
266
284
  "Re-generated conversation title (second pass)",
@@ -273,16 +291,22 @@ export async function regenerateConversationTitle(
273
291
 
274
292
  /**
275
293
  * Fire-and-forget wrapper for title regeneration.
294
+ *
295
+ * Serialized via the same {@link titleMutex} as initial generation.
276
296
  */
277
297
  export function queueRegenerateConversationTitle(
278
298
  params: RegenerateTitleParams,
279
299
  ): void {
280
- regenerateConversationTitle(params).catch((err) => {
281
- log.warn(
282
- { err, conversationId: params.conversationId },
283
- "Failed to regenerate conversation title (non-fatal)",
284
- );
285
- });
300
+ void titleMutex
301
+ .withLock(async () => {
302
+ await regenerateConversationTitle(params);
303
+ })
304
+ .catch((err) => {
305
+ log.warn(
306
+ { err, conversationId: params.conversationId },
307
+ "Failed to regenerate conversation title (non-fatal)",
308
+ );
309
+ });
286
310
  }
287
311
 
288
312
  // ── Internal helpers ─────────────────────────────────────────────────
@@ -18,6 +18,7 @@ import {
18
18
  addCoreColumns,
19
19
  createApprovalPromptTsTrackerTable,
20
20
  createAssistantInboxTables,
21
+ createAuthFallbackEventsTable,
21
22
  createCallSessionsTables,
22
23
  createCanonicalGuardianTables,
23
24
  createChannelGuardianTables,
@@ -188,6 +189,7 @@ import {
188
189
  migrateScheduleReuseConversation,
189
190
  migrateScheduleScriptColumn,
190
191
  migrateScheduleScriptTimeout,
192
+ migrateScheduleSourceConversation,
191
193
  migrateScheduleWakeConversationId,
192
194
  migrateSchemaIndexesAndColumns,
193
195
  migrateScrubCorruptedImageAttachments,
@@ -473,7 +475,9 @@ export function initializeDb(): void {
473
475
  migrateLlmUsageEventsAddAssistantVersion,
474
476
  migrateAddMemoryV3Selections,
475
477
  migrateScheduleScriptTimeout,
478
+ migrateScheduleSourceConversation,
476
479
  migrateMessagesRoleCreatedAtIndex,
480
+ createAuthFallbackEventsTable,
477
481
  ];
478
482
 
479
483
  // Run each migration step, catching and logging individual failures so one
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Tests for the live, per-conversation {@link ConversationGraphMemory} registry
3
+ * (`getLiveGraphMemory`). The registry lets memory-domain code that only knows
4
+ * a conversation id — notably the post-compaction re-injection hook — reach the
5
+ * same in-memory handle the turn's retrieval mutated, without the agent loop
6
+ * threading the handle through its generic context.
7
+ */
8
+ import { describe, expect, mock, test } from "bun:test";
9
+
10
+ import { createMockLoggerModule } from "../../../__tests__/helpers/mock-logger.js";
11
+
12
+ mock.module("../../../util/logger.js", () => createMockLoggerModule());
13
+
14
+ const { ConversationGraphMemory, getLiveGraphMemory } =
15
+ await import("../conversation-graph-memory.js");
16
+
17
+ describe("ConversationGraphMemory live registry", () => {
18
+ test("a constructed handle is discoverable by its conversation id", () => {
19
+ /**
20
+ * Tests that constructing a handle registers it so it can be looked up.
21
+ */
22
+
23
+ // GIVEN a conversation id
24
+ const conversationId = `conv-registry-${crypto.randomUUID()}`;
25
+
26
+ // WHEN a graph handle is constructed for it
27
+ const handle = new ConversationGraphMemory(conversationId);
28
+
29
+ // THEN the registry returns that exact instance
30
+ expect(getLiveGraphMemory(conversationId)).toBe(handle);
31
+ });
32
+
33
+ test("dispose removes the handle from the registry", () => {
34
+ /**
35
+ * Tests that disposing a handle unregisters it.
36
+ */
37
+
38
+ // GIVEN a registered graph handle
39
+ const conversationId = `conv-registry-${crypto.randomUUID()}`;
40
+ const handle = new ConversationGraphMemory(conversationId);
41
+
42
+ // WHEN it is disposed
43
+ handle.dispose();
44
+
45
+ // THEN the registry no longer resolves the conversation id
46
+ expect(getLiveGraphMemory(conversationId)).toBeUndefined();
47
+ });
48
+
49
+ test("missing id and undefined id both resolve to undefined", () => {
50
+ /**
51
+ * Tests the lookup's absence handling for unknown and undefined keys.
52
+ */
53
+
54
+ // GIVEN no handle registered for these keys
55
+ // WHEN the registry is queried with an unknown id and with undefined
56
+ // THEN both resolve to undefined (the hook treats absence as "no graph")
57
+ expect(
58
+ getLiveGraphMemory(`conv-never-${crypto.randomUUID()}`),
59
+ ).toBeUndefined();
60
+ expect(getLiveGraphMemory(undefined)).toBeUndefined();
61
+ });
62
+
63
+ test("recreating for the same id replaces the registered handle", () => {
64
+ /**
65
+ * Tests that the latest constructed handle wins for a conversation id.
66
+ */
67
+
68
+ // GIVEN a handle already registered for a conversation id
69
+ const conversationId = `conv-registry-${crypto.randomUUID()}`;
70
+ new ConversationGraphMemory(conversationId);
71
+
72
+ // WHEN a second handle is constructed for the same id
73
+ const recreated = new ConversationGraphMemory(conversationId);
74
+
75
+ // THEN the registry resolves to the most recent instance
76
+ expect(getLiveGraphMemory(conversationId)).toBe(recreated);
77
+ });
78
+
79
+ test("disposing a stale handle does not evict the current one", () => {
80
+ /**
81
+ * Tests the dispose guard during an eviction + recreation race: a stale
82
+ * handle must not delete the live entry that now points at a newer handle.
83
+ */
84
+
85
+ // GIVEN a superseded (stale) handle and the current handle for one id
86
+ const conversationId = `conv-registry-${crypto.randomUUID()}`;
87
+ const stale = new ConversationGraphMemory(conversationId);
88
+ const current = new ConversationGraphMemory(conversationId);
89
+
90
+ // WHEN the stale handle is disposed
91
+ stale.dispose();
92
+
93
+ // THEN the registry still resolves to the current handle
94
+ expect(getLiveGraphMemory(conversationId)).toBe(current);
95
+ });
96
+
97
+ test("records and exposes the PKB query vector pair from the registry", () => {
98
+ /**
99
+ * Tests that the dense/sparse PKB query pair recorded during retrieval is
100
+ * readable off the same live handle the PKB-reminder injector looks up.
101
+ */
102
+
103
+ // GIVEN a registered graph handle with no recorded vectors yet
104
+ const conversationId = `conv-registry-${crypto.randomUUID()}`;
105
+ const handle = new ConversationGraphMemory(conversationId);
106
+ expect(handle.pkbQueryVector).toBeUndefined();
107
+ expect(handle.pkbSparseVector).toBeUndefined();
108
+
109
+ // WHEN a retrieval records the turn's dense/sparse pair
110
+ const dense = [0.1, 0.2, 0.3];
111
+ const sparse = { indices: [0, 2], values: [0.5, 0.9] };
112
+ handle.recordPkbQueryVectors(dense, sparse);
113
+
114
+ // THEN a registry consumer reads back the same pair by conversation id
115
+ const looked = getLiveGraphMemory(conversationId);
116
+ expect(looked?.pkbQueryVector).toBe(dense);
117
+ expect(looked?.pkbSparseVector).toBe(sparse);
118
+ });
119
+ });
@@ -62,6 +62,31 @@ const ESTIMATED_IMAGE_TOKENS = 1000;
62
62
  // Per-conversation state
63
63
  // ---------------------------------------------------------------------------
64
64
 
65
+ /**
66
+ * Registry of the live, per-conversation graph handles keyed by conversation
67
+ * id. A handle registers itself on construction and removes itself on
68
+ * {@link ConversationGraphMemory.dispose}, so memory-domain code that only
69
+ * knows a conversation id (e.g. the post-compaction re-injection hook) can
70
+ * reach the same in-memory handle the turn's retrieval used — its live
71
+ * `tracker` / cached-node state, which a DB-reconstructed handle would not
72
+ * carry. Not a general service locator: it holds only the graph handle, and
73
+ * the daemon's `Conversation` remains the owner of the instance's lifecycle.
74
+ */
75
+ const liveByConversation = new Map<string, ConversationGraphMemory>();
76
+
77
+ /**
78
+ * Look up the live {@link ConversationGraphMemory} for a conversation, or
79
+ * `undefined` when none is registered (no active conversation, or a context
80
+ * with no conversation id). Returns the same instance the turn's retrieval
81
+ * mutated, so cached-node re-tracking operates on real state.
82
+ */
83
+ export function getLiveGraphMemory(
84
+ conversationId: string | undefined,
85
+ ): ConversationGraphMemory | undefined {
86
+ if (!conversationId) return undefined;
87
+ return liveByConversation.get(conversationId);
88
+ }
89
+
65
90
  /**
66
91
  * Manages memory graph state for a single conversation.
67
92
  * Create one per Conversation instance. Persists across turns.
@@ -75,9 +100,24 @@ export class ConversationGraphMemory {
75
100
  private lastInjectedBlock: string | null = null;
76
101
  private lastInjectedNodeIds: string[] = [];
77
102
  private lastInjectedImages: Map<string, ResolvedImage> = new Map();
103
+ private lastPkbQueryVector: number[] | undefined;
104
+ private lastPkbSparseVector: QdrantSparseVector | undefined;
78
105
 
79
106
  constructor(conversationId: string) {
80
107
  this.conversationId = conversationId;
108
+ liveByConversation.set(conversationId, this);
109
+ }
110
+
111
+ /**
112
+ * Remove this handle from the live registry. Called from
113
+ * `Conversation.dispose`. Guards against clobbering a newer handle for the
114
+ * same conversation (eviction + recreation) by only deleting the entry when
115
+ * it still points at this instance.
116
+ */
117
+ dispose(): void {
118
+ if (liveByConversation.get(this.conversationId) === this) {
119
+ liveByConversation.delete(this.conversationId);
120
+ }
81
121
  }
82
122
 
83
123
  /**
@@ -305,6 +345,31 @@ export class ConversationGraphMemory {
305
345
  this.tracker.add(this.lastInjectedNodeIds);
306
346
  }
307
347
 
348
+ /**
349
+ * Record the dense/sparse query-vector pair this turn's retrieval produced
350
+ * for PKB hybrid search. The PKB-reminder injector reuses the same
351
+ * embedding (looked up by conversation id via {@link getLiveGraphMemory})
352
+ * rather than receiving it threaded through the agent loop, so the vectors
353
+ * stay owned by the memory-retrieval domain that computes them.
354
+ */
355
+ recordPkbQueryVectors(
356
+ dense: number[] | undefined,
357
+ sparse: QdrantSparseVector | undefined,
358
+ ): void {
359
+ this.lastPkbQueryVector = dense;
360
+ this.lastPkbSparseVector = sparse;
361
+ }
362
+
363
+ /** Dense PKB query vector from this turn's retrieval, or `undefined`. */
364
+ get pkbQueryVector(): number[] | undefined {
365
+ return this.lastPkbQueryVector;
366
+ }
367
+
368
+ /** Sparse PKB query vector paired with {@link pkbQueryVector}. */
369
+ get pkbSparseVector(): QdrantSparseVector | undefined {
370
+ return this.lastPkbSparseVector;
371
+ }
372
+
308
373
  /**
309
374
  * Main entry point — called on every turn before the LLM sees the messages.
310
375
  *
@@ -79,6 +79,11 @@ export const SLOW_LLM_JOB_TYPES: MemoryJobType[] = [
79
79
  "graph_bootstrap",
80
80
  ];
81
81
 
82
+ export const MEMORY_V2_CONSOLIDATION_JOB_TRIGGERS = {
83
+ automatic: "automatic",
84
+ manual: "manual",
85
+ } as const;
86
+
82
87
  /** Returns `false` only when `config.memory.enabled` is explicitly `false`; defaults to `true` on missing config or load errors. */
83
88
  export function isMemoryEnabled(): boolean {
84
89
  try {
@@ -337,6 +342,34 @@ export function hasActiveJobOfType(type: MemoryJobType): boolean {
337
342
  );
338
343
  }
339
344
 
345
+ export function isAutomaticConsolidationJob(job: MemoryJob): boolean {
346
+ return job.payload.trigger !== MEMORY_V2_CONSOLIDATION_JOB_TRIGGERS.manual;
347
+ }
348
+
349
+ export function cancelPendingAutomaticConsolidationJobs(): number {
350
+ const db = getDb();
351
+ db.update(memoryJobs)
352
+ .set({
353
+ status: "failed",
354
+ lastError: "automatic_consolidation_disabled",
355
+ updatedAt: Date.now(),
356
+ })
357
+ .where(
358
+ and(
359
+ eq(memoryJobs.type, "memory_v2_consolidate"),
360
+ eq(memoryJobs.status, "pending"),
361
+ or(
362
+ sql`json_extract(${memoryJobs.payload}, '$.trigger') = ${MEMORY_V2_CONSOLIDATION_JOB_TRIGGERS.automatic}`,
363
+ // Legacy rows predate trigger markers and are indistinguishable from
364
+ // automatic enqueues, so disabling the schedule treats them as auto.
365
+ sql`json_extract(${memoryJobs.payload}, '$.trigger') IS NULL`,
366
+ ),
367
+ ),
368
+ )
369
+ .run();
370
+ return rawChanges();
371
+ }
372
+
340
373
  export function enqueuePruneOldLlmRequestLogsJob(retentionMs?: number): string {
341
374
  const db = getDb();
342
375
  const existing = db