@vellumai/assistant 0.8.3 → 0.8.4

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 (342) hide show
  1. package/docker-entrypoint.sh +0 -1
  2. package/node_modules/@vellumai/gateway-client/src/types.ts +2 -0
  3. package/openapi.yaml +610 -16
  4. package/package.json +1 -1
  5. package/src/__tests__/agent-loop-exit-reason.test.ts +4 -5
  6. package/src/__tests__/agent-loop-override-profile.test.ts +1 -1
  7. package/src/__tests__/agent-loop.test.ts +88 -3
  8. package/src/__tests__/anthropic-provider.test.ts +272 -0
  9. package/src/__tests__/approval-cascade.test.ts +1 -1
  10. package/src/__tests__/background-workers-disk-pressure.test.ts +2 -1
  11. package/src/__tests__/channel-delivery-store.test.ts +193 -0
  12. package/src/__tests__/channel-reply-delivery.test.ts +284 -5
  13. package/src/__tests__/channel-retry-sweep.test.ts +274 -1
  14. package/src/__tests__/compaction-events.test.ts +1 -1
  15. package/src/__tests__/compactor-preserved-tail-count.test.ts +110 -0
  16. package/src/__tests__/config-watcher.test.ts +1 -1
  17. package/src/__tests__/context-token-estimator.test.ts +91 -1
  18. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  19. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +54 -3
  20. package/src/__tests__/conversation-agent-loop-overflow.test.ts +31 -6
  21. package/src/__tests__/conversation-agent-loop.test.ts +25 -7
  22. package/src/__tests__/conversation-app-control-lifecycle.test.ts +1 -1
  23. package/src/__tests__/conversation-clean-command.test.ts +137 -0
  24. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  25. package/src/__tests__/conversation-fork-crud.test.ts +161 -0
  26. package/src/__tests__/conversation-lifecycle.test.ts +1 -1
  27. package/src/__tests__/conversation-load-cleaned-at.test.ts +279 -0
  28. package/src/__tests__/conversation-load-history-repair.test.ts +1 -1
  29. package/src/__tests__/conversation-pairing.test.ts +2 -2
  30. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  31. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  32. package/src/__tests__/conversation-queue.test.ts +1 -1
  33. package/src/__tests__/conversation-runtime-assembly.test.ts +264 -81
  34. package/src/__tests__/conversation-seed-composer.test.ts +66 -4
  35. package/src/__tests__/conversation-slash-commands.test.ts +36 -8
  36. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  37. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  38. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  39. package/src/__tests__/conversation-surfaces-task-progress.test.ts +220 -0
  40. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  41. package/src/__tests__/conversation-workspace-injection.test.ts +5 -1
  42. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +5 -1
  43. package/src/__tests__/credential-security-invariants.test.ts +6 -0
  44. package/src/__tests__/cu-unified-flow.test.ts +10 -1
  45. package/src/__tests__/dm-backfill.test.ts +64 -0
  46. package/src/__tests__/dm-persistence.test.ts +33 -0
  47. package/src/__tests__/document-find-replace.test.ts +501 -0
  48. package/src/__tests__/first-greeting.test.ts +23 -2
  49. package/src/__tests__/headless-browser-navigate.test.ts +172 -0
  50. package/src/__tests__/host-bash-proxy.test.ts +6 -0
  51. package/src/__tests__/host-browser-proxy.test.ts +10 -0
  52. package/src/__tests__/host-cu-proxy.test.ts +8 -1
  53. package/src/__tests__/host-file-proxy.test.ts +8 -1
  54. package/src/__tests__/host-transfer-proxy.test.ts +8 -1
  55. package/src/__tests__/identity-routes.test.ts +57 -0
  56. package/src/__tests__/inbound-slack-persistence.test.ts +3 -0
  57. package/src/__tests__/injector-chain.test.ts +2 -0
  58. package/src/__tests__/injector-document-comments.test.ts +378 -0
  59. package/src/__tests__/injector-pkb-v2-silenced.test.ts +4 -25
  60. package/src/__tests__/list-messages-attachments.test.ts +21 -17
  61. package/src/__tests__/list-messages-hidden-metadata.test.ts +217 -0
  62. package/src/__tests__/list-messages-page-latest.test.ts +130 -14
  63. package/src/__tests__/list-messages-tool-merge.test.ts +17 -16
  64. package/src/__tests__/llm-context-normalization.test.ts +0 -2
  65. package/src/__tests__/llm-resolver.test.ts +85 -1
  66. package/src/__tests__/log-export-routes.test.ts +99 -2
  67. package/src/__tests__/message-queue-steer.test.ts +114 -0
  68. package/src/__tests__/openai-provider.test.ts +105 -0
  69. package/src/__tests__/openai-responses-provider.test.ts +4 -4
  70. package/src/__tests__/outbound-slack-persistence.test.ts +187 -20
  71. package/src/__tests__/pending-interactions-resolved-event.test.ts +190 -0
  72. package/src/__tests__/platform.test.ts +0 -3
  73. package/src/__tests__/plugin-source-watcher.test.ts +302 -0
  74. package/src/__tests__/process-message-background-slack.test.ts +1 -51
  75. package/src/__tests__/process-message-display-content.test.ts +21 -16
  76. package/src/__tests__/server-history-render.test.ts +83 -4
  77. package/src/__tests__/steer-tool-repair.test.ts +249 -0
  78. package/src/__tests__/system-prompt.test.ts +51 -28
  79. package/src/__tests__/terminal-tools.test.ts +11 -1
  80. package/src/__tests__/thinking-block-replay.test.ts +113 -0
  81. package/src/__tests__/thread-backfill.test.ts +370 -22
  82. package/src/__tests__/tool-executor.test.ts +90 -1
  83. package/src/__tests__/tool-result-metadata-plumbing.test.ts +167 -0
  84. package/src/__tests__/twilio-routes.test.ts +1 -1
  85. package/src/__tests__/web-fetch.test.ts +2 -2
  86. package/src/__tests__/workspace-git-service.test.ts +88 -5
  87. package/src/__tests__/workspace-migration-088-deprecate-background-conversation-override.test.ts +158 -0
  88. package/src/agent/attachments.ts +1 -0
  89. package/src/agent/loop.ts +57 -20
  90. package/src/background-wake/next-wake.test.ts +289 -0
  91. package/src/background-wake/next-wake.ts +172 -0
  92. package/src/browser/operations.ts +15 -0
  93. package/src/cli/commands/__tests__/conversations-slack.test.ts +572 -0
  94. package/src/cli/commands/__tests__/memory-v2.test.ts +9 -12
  95. package/src/cli/commands/conversations.ts +128 -1
  96. package/src/cli/commands/inference-providers.ts +147 -1
  97. package/src/cli/commands/memory-v2.ts +308 -0
  98. package/src/cli/commands/notifications.ts +24 -2
  99. package/src/cli/utils/conversation-id.ts +17 -5
  100. package/src/config/bundled-skills/app-builder/SKILL.md +2 -2
  101. package/src/config/bundled-skills/document-editor/SKILL.md +115 -0
  102. package/src/config/bundled-skills/document-editor/TOOLS.json +240 -0
  103. package/src/config/bundled-skills/document-editor/tools/comment-list.ts +12 -0
  104. package/src/config/bundled-skills/document-editor/tools/comment-reply.ts +12 -0
  105. package/src/config/bundled-skills/document-editor/tools/comment-resolve.ts +12 -0
  106. package/src/config/bundled-skills/document-editor/tools/document-find.ts +12 -0
  107. package/src/config/bundled-skills/document-editor/tools/document-replace-text.ts +12 -0
  108. package/src/config/bundled-skills/media-processing/SKILL.md +8 -0
  109. package/src/config/bundled-skills/schedule/SKILL.md +8 -0
  110. package/src/config/bundled-tool-registry.ts +22 -12
  111. package/src/config/call-site-defaults.ts +19 -0
  112. package/src/config/feature-flag-registry.json +99 -3
  113. package/src/config/llm-resolver.ts +16 -2
  114. package/src/config/schemas/__tests__/memory-v2.test.ts +4 -0
  115. package/src/config/schemas/call-site-catalog.ts +21 -0
  116. package/src/config/schemas/llm.ts +3 -0
  117. package/src/config/schemas/memory-v2.ts +48 -1
  118. package/src/context/compactor.ts +8 -1
  119. package/src/context/token-estimator.ts +47 -4
  120. package/src/context/window-manager.ts +25 -0
  121. package/src/credential-health/credential-health-service.ts +34 -19
  122. package/src/daemon/__tests__/conversation-tool-setup.test.ts +66 -6
  123. package/src/daemon/__tests__/native-web-search-metadata.test.ts +357 -0
  124. package/src/daemon/__tests__/web-search-status-text.test.ts +287 -0
  125. package/src/daemon/conversation-agent-loop-handlers.ts +153 -23
  126. package/src/daemon/conversation-agent-loop.ts +223 -54
  127. package/src/daemon/conversation-lifecycle.ts +142 -116
  128. package/src/daemon/conversation-messaging.ts +3 -0
  129. package/src/daemon/conversation-process.ts +273 -0
  130. package/src/daemon/conversation-queue-manager.ts +14 -0
  131. package/src/daemon/conversation-runtime-assembly.ts +135 -75
  132. package/src/daemon/conversation-slash.ts +37 -5
  133. package/src/daemon/conversation-surfaces.ts +45 -2
  134. package/src/daemon/conversation-tool-setup.ts +7 -0
  135. package/src/daemon/conversation.ts +42 -5
  136. package/src/daemon/first-greeting.ts +10 -0
  137. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +498 -0
  138. package/src/daemon/handlers/config-a2a.ts +160 -0
  139. package/src/daemon/handlers/config-model.test.ts +1 -0
  140. package/src/daemon/handlers/conversations.ts +79 -0
  141. package/src/daemon/handlers/shared.ts +92 -29
  142. package/src/daemon/host-bash-proxy.ts +1 -1
  143. package/src/daemon/host-cu-proxy.ts +1 -1
  144. package/src/daemon/host-file-proxy.ts +1 -1
  145. package/src/daemon/host-transfer-proxy.ts +1 -1
  146. package/src/daemon/lifecycle.ts +18 -4
  147. package/src/daemon/message-protocol.ts +4 -0
  148. package/src/daemon/message-types/conversations.ts +8 -0
  149. package/src/daemon/message-types/document-comments.ts +50 -0
  150. package/src/daemon/message-types/messages.ts +68 -1
  151. package/src/daemon/message-types/surfaces.ts +3 -1
  152. package/src/daemon/message-types/web-activity.ts +57 -0
  153. package/src/daemon/plugin-source-watcher.ts +135 -3
  154. package/src/daemon/process-message.ts +69 -12
  155. package/src/daemon/query-complexity-router.ts +75 -0
  156. package/src/daemon/trust-context.ts +6 -0
  157. package/src/documents/document-comments-store.test.ts +338 -0
  158. package/src/documents/document-comments-store.ts +237 -0
  159. package/src/documents/document-store.ts +202 -0
  160. package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
  161. package/src/heartbeat/heartbeat-service.ts +1 -0
  162. package/src/home/__tests__/suggested-prompts.test.ts +33 -2
  163. package/src/home/feed-types.ts +6 -1
  164. package/src/home/home-content-refresh.ts +52 -0
  165. package/src/home/home-greeting-cache.ts +69 -0
  166. package/src/home/home-greeting.ts +94 -0
  167. package/src/home/suggested-prompts.ts +177 -9
  168. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +135 -2
  169. package/src/memory/__tests__/memory-retrospective-job.test.ts +320 -6
  170. package/src/memory/conversation-crud.ts +133 -43
  171. package/src/memory/db-init.ts +16 -0
  172. package/src/memory/delivery-crud.ts +41 -0
  173. package/src/memory/delivery-status.ts +141 -15
  174. package/src/memory/external-conversation-store.ts +32 -1
  175. package/src/memory/jobs-worker.ts +21 -1
  176. package/src/memory/memory-retrospective-constants.ts +28 -0
  177. package/src/memory/memory-retrospective-enqueue.ts +3 -2
  178. package/src/memory/memory-retrospective-job.ts +408 -18
  179. package/src/memory/memory-retrospective-startup-cleanup.ts +3 -3
  180. package/src/memory/memory-v2-activation-log-store.ts +26 -8
  181. package/src/memory/migrations/100-core-tables.ts +1 -0
  182. package/src/memory/migrations/109-external-conversation-bindings.ts +1 -0
  183. package/src/memory/migrations/253-conversation-last-notified-profile.ts +15 -0
  184. package/src/memory/migrations/253-document-comments.ts +47 -0
  185. package/src/memory/migrations/254-external-conversation-binding-chat-name.ts +43 -0
  186. package/src/memory/migrations/255-channel-inbound-delivery-attempts.ts +24 -0
  187. package/src/memory/migrations/256-memory-v2-injection-events.ts +113 -0
  188. package/src/memory/migrations/257-strip-base-url-non-openai-compatible.ts +22 -0
  189. package/src/memory/migrations/258-onboarding-events-prior-assistants.ts +13 -0
  190. package/src/memory/migrations/259-conversation-cleaned-at.ts +33 -0
  191. package/src/memory/migrations/index.ts +17 -0
  192. package/src/memory/migrations/registry.ts +25 -0
  193. package/src/memory/onboarding-events-store.ts +7 -0
  194. package/src/memory/schema/calls.ts +1 -0
  195. package/src/memory/schema/conversations.ts +3 -0
  196. package/src/memory/schema/infrastructure.ts +1 -0
  197. package/src/memory/v2/__tests__/injection-events.test.ts +318 -0
  198. package/src/memory/v2/__tests__/injection.test.ts +31 -14
  199. package/src/memory/v2/__tests__/page-index.test.ts +365 -1
  200. package/src/memory/v2/__tests__/router.test.ts +489 -1
  201. package/src/memory/v2/consolidation-job.ts +14 -0
  202. package/src/memory/v2/injection-events.ts +101 -0
  203. package/src/memory/v2/injection.ts +21 -10
  204. package/src/memory/v2/page-index.ts +209 -7
  205. package/src/memory/v2/page-store.ts +18 -0
  206. package/src/memory/v2/router.ts +209 -55
  207. package/src/messaging/providers/index.ts +7 -1
  208. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +329 -3
  209. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +34 -1
  210. package/src/messaging/providers/slack/adapter.ts +178 -25
  211. package/src/messaging/providers/slack/api.test.ts +54 -0
  212. package/src/messaging/providers/slack/api.ts +119 -3
  213. package/src/messaging/providers/slack/client.ts +12 -0
  214. package/src/messaging/providers/slack/deep-link.ts +20 -1
  215. package/src/messaging/providers/slack/message-metadata.test.ts +48 -0
  216. package/src/messaging/providers/slack/message-metadata.ts +156 -0
  217. package/src/messaging/providers/slack/render-transcript.test.ts +107 -75
  218. package/src/messaging/providers/slack/render-transcript.ts +176 -49
  219. package/src/messaging/providers/slack/send.test.ts +77 -0
  220. package/src/messaging/providers/slack/send.ts +8 -2
  221. package/src/messaging/providers/slack/types.ts +14 -0
  222. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +4 -1
  223. package/src/notifications/__tests__/home-feed-side-effect.test.ts +116 -54
  224. package/src/notifications/conversation-seed-composer.ts +14 -2
  225. package/src/notifications/deferred-emit.ts +135 -0
  226. package/src/notifications/emit-signal.ts +9 -1
  227. package/src/notifications/home-feed-side-effect.ts +60 -30
  228. package/src/oauth/connect-orchestrator.ts +3 -0
  229. package/src/oauth/credential-token-resolver.ts +2 -0
  230. package/src/oauth/manual-token-connection.ts +19 -0
  231. package/src/oauth/oauth-store.ts +12 -0
  232. package/src/oauth/seed-providers.ts +22 -0
  233. package/src/permissions/prompter.ts +5 -2
  234. package/src/permissions/secret-prompter.ts +4 -1
  235. package/src/plugins/defaults/injectors.ts +82 -9
  236. package/src/prompts/__tests__/system-prompt.test.ts +46 -2
  237. package/src/prompts/normalize-onboarding.ts +40 -0
  238. package/src/prompts/sections.ts +32 -14
  239. package/src/prompts/system-prompt.ts +105 -68
  240. package/src/prompts/template-detection.ts +37 -0
  241. package/src/prompts/templates/BOOTSTRAP-CONTENT-AUTOMATION.md +141 -0
  242. package/src/prompts/templates/BOOTSTRAP.md +8 -0
  243. package/src/prompts/templates/VOICE.md +3 -0
  244. package/src/prompts/templates/system-sections.ts +53 -3
  245. package/src/providers/anthropic/client.ts +132 -5
  246. package/src/providers/fireworks/client.ts +20 -2
  247. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +342 -0
  248. package/src/providers/inference/__tests__/base-url-security.test.ts +189 -0
  249. package/src/providers/inference/__tests__/codex-token-refresh.test.ts +254 -0
  250. package/src/providers/inference/adapter-factory.ts +15 -1
  251. package/src/providers/inference/auth.ts +3 -3
  252. package/src/providers/inference/codex-token-refresh.ts +128 -0
  253. package/src/providers/inference/resolve-auth.ts +49 -6
  254. package/src/providers/model-catalog.ts +48 -1
  255. package/src/providers/openai/chat-completions-provider.ts +57 -20
  256. package/src/providers/openai/responses-provider.ts +9 -3
  257. package/src/providers/openrouter/client.ts +5 -1
  258. package/src/providers/types.ts +25 -0
  259. package/src/runtime/__tests__/agent-wake.test.ts +214 -0
  260. package/src/runtime/__tests__/background-job-runner.test.ts +128 -0
  261. package/src/runtime/agent-wake.ts +151 -56
  262. package/src/runtime/auth/route-policy.ts +7 -3
  263. package/src/runtime/background-job-runner.ts +26 -0
  264. package/src/runtime/channel-reply-delivery.ts +182 -47
  265. package/src/runtime/channel-retry-sweep.ts +141 -16
  266. package/src/runtime/http-types.ts +7 -4
  267. package/src/runtime/pending-interactions.ts +51 -8
  268. package/src/runtime/routes/__tests__/content-source-routes.test.ts +162 -0
  269. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +55 -1
  270. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +14 -0
  271. package/src/runtime/routes/__tests__/memory-v2-simulate-route.test.ts +271 -0
  272. package/src/runtime/routes/__tests__/sanity-routes.test.ts +280 -0
  273. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +266 -0
  274. package/src/runtime/routes/approval-routes.ts +4 -1
  275. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +246 -0
  276. package/src/runtime/routes/content-source-routes.ts +78 -0
  277. package/src/runtime/routes/conversation-cli-routes.ts +146 -1
  278. package/src/runtime/routes/conversation-query-routes.ts +60 -1
  279. package/src/runtime/routes/conversation-routes.ts +281 -76
  280. package/src/runtime/routes/document-comments-routes.ts +287 -0
  281. package/src/runtime/routes/documents-routes.ts +33 -0
  282. package/src/runtime/routes/home-feed-routes.ts +6 -3
  283. package/src/runtime/routes/host-app-control-routes.ts +1 -1
  284. package/src/runtime/routes/host-browser-routes.ts +8 -1
  285. package/src/runtime/routes/identity-routes.ts +21 -0
  286. package/src/runtime/routes/inbound-message-handler.ts +288 -58
  287. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +365 -6
  288. package/src/runtime/routes/inbound-stages/background-dispatch.ts +283 -82
  289. package/src/runtime/routes/index.ts +12 -4
  290. package/src/runtime/routes/inference-provider-connection-routes.ts +63 -7
  291. package/src/runtime/routes/integrations/a2a.ts +60 -1
  292. package/src/runtime/routes/log-export-routes.ts +39 -0
  293. package/src/runtime/routes/memory-v2-routes.ts +217 -0
  294. package/src/runtime/routes/notification-routes.ts +19 -2
  295. package/src/runtime/routes/question-routes.ts +4 -1
  296. package/src/runtime/routes/sanity-routes.ts +159 -0
  297. package/src/runtime/routes/slack-channel-routes.ts +187 -0
  298. package/src/runtime/services/conversation-serializer.ts +30 -4
  299. package/src/schedule/integration-status.ts +3 -1
  300. package/src/security/__tests__/oauth2-device-code.test.ts +479 -0
  301. package/src/security/oauth2-device-code.ts +307 -0
  302. package/src/security/oauth2.ts +26 -9
  303. package/src/security/secure-keys.ts +5 -0
  304. package/src/skills/catalog-install.ts +6 -2
  305. package/src/tools/browser/__tests__/pinned-tabs.test.ts +80 -0
  306. package/src/tools/browser/browser-execution.ts +93 -0
  307. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +28 -0
  308. package/src/tools/browser/cdp-client/__tests__/types.test.ts +1 -0
  309. package/src/tools/browser/cdp-client/cdp-inspect-client.ts +10 -0
  310. package/src/tools/browser/cdp-client/extension-cdp-client.ts +15 -1
  311. package/src/tools/browser/cdp-client/factory.ts +87 -3
  312. package/src/tools/browser/cdp-client/local-cdp-client.ts +9 -0
  313. package/src/tools/browser/cdp-client/types.ts +36 -0
  314. package/src/tools/browser/pinned-tabs.ts +90 -0
  315. package/src/tools/document/document-comment-tool.test.ts +379 -0
  316. package/src/tools/document/document-comment-tool.ts +156 -0
  317. package/src/tools/document/document-tool.ts +128 -2
  318. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +229 -0
  319. package/src/tools/network/__tests__/web-search-metadata.test.ts +346 -0
  320. package/src/tools/network/domain-normalize.ts +17 -0
  321. package/src/tools/network/web-fetch.ts +213 -64
  322. package/src/tools/network/web-search.ts +191 -66
  323. package/src/tools/terminal/safe-env.ts +3 -2
  324. package/src/tools/tool-approval-handler.ts +19 -12
  325. package/src/tools/types.ts +4 -0
  326. package/src/tools/ui-surface/definitions.ts +3 -1
  327. package/src/types/onboarding-context.ts +4 -0
  328. package/src/util/__tests__/favicon.test.ts +84 -0
  329. package/src/util/favicon.ts +40 -0
  330. package/src/util/platform.ts +0 -5
  331. package/src/workspace/git-service.ts +75 -4
  332. package/src/workspace/migrations/088-deprecate-background-conversation-override.ts +103 -0
  333. package/src/workspace/migrations/registry.ts +2 -0
  334. package/src/config/bundled-skills/document/SKILL.md +0 -54
  335. package/src/config/bundled-skills/document/TOOLS.json +0 -106
  336. package/src/daemon/seed-files.ts +0 -18
  337. package/src/runtime/routes/interface-routes.ts +0 -43
  338. /package/src/config/bundled-skills/{document → document-editor}/tools/document-create.ts +0 -0
  339. /package/src/config/bundled-skills/{document → document-editor}/tools/document-delete.ts +0 -0
  340. /package/src/config/bundled-skills/{document → document-editor}/tools/document-list.ts +0 -0
  341. /package/src/config/bundled-skills/{document → document-editor}/tools/document-read.ts +0 -0
  342. /package/src/config/bundled-skills/{document → document-editor}/tools/document-update.ts +0 -0
@@ -73,6 +73,26 @@ mock.module("../skill-store.js", () => ({
73
73
  listSkillEntries: () => skillState.entries,
74
74
  }));
75
75
 
76
+ // Stub `computeInjectionScores` so tier-2 tests can dictate scores
77
+ // without spinning up a real bun:sqlite db. Real production wiring is
78
+ // covered by the splitTier2 unit tests in page-index.test.ts and the
79
+ // score-formula tests in injection-events.test.ts.
80
+ const scoresStub = new Map<string, number>();
81
+ mock.module("../injection-events.js", () => ({
82
+ computeInjectionScores: (
83
+ _db: unknown,
84
+ slugs: readonly string[],
85
+ _now: number,
86
+ ): Map<string, number> => {
87
+ const out = new Map<string, number>();
88
+ for (const slug of slugs) {
89
+ const score = scoresStub.get(slug);
90
+ if (score !== undefined && score > 0) out.set(slug, score);
91
+ }
92
+ return out;
93
+ },
94
+ }));
95
+
76
96
  // Provider stub. Each test sets `providerStub` to control the response;
77
97
  // `null` simulates "no configured provider available".
78
98
  let providerStub: Provider | null = null;
@@ -112,6 +132,7 @@ beforeEach(() => {
112
132
  providerStub = null;
113
133
  providerCalls.length = 0;
114
134
  warnLogs.length = 0;
135
+ scoresStub.clear();
115
136
  invalidatePageIndex();
116
137
  });
117
138
 
@@ -194,7 +215,12 @@ function makePage(
194
215
  // fields the router actually reads. Cast through `as unknown` because the
195
216
  // production type is a heavy nested schema; we only exercise the v2.router
196
217
  // branch in this test file.
197
- function makeConfig(overrides?: { maxPageIds?: number }) {
218
+ function makeConfig(overrides?: {
219
+ maxPageIds?: number;
220
+ batchSize?: number | null;
221
+ tier1Size?: number | null;
222
+ tier2Size?: number | null;
223
+ }) {
198
224
  return {
199
225
  memory: {
200
226
  v2: {
@@ -202,6 +228,9 @@ function makeConfig(overrides?: { maxPageIds?: number }) {
202
228
  router: {
203
229
  enabled: true,
204
230
  max_page_ids: overrides?.maxPageIds ?? 25,
231
+ batch_size: overrides?.batchSize ?? null,
232
+ tier1_size: overrides?.tier1Size ?? null,
233
+ tier2_size: overrides?.tier2Size ?? null,
205
234
  },
206
235
  },
207
236
  },
@@ -231,6 +260,7 @@ describe("runRouter — early bails", () => {
231
260
 
232
261
  expect(result).toEqual({
233
262
  selectedSlugs: [],
263
+ sourceBySlug: new Map(),
234
264
  failureReason: "empty_index",
235
265
  });
236
266
  // Provider must NOT be invoked when there is nothing to route.
@@ -286,6 +316,7 @@ describe("runRouter — successful tool_use", () => {
286
316
 
287
317
  expect(result).toEqual({
288
318
  selectedSlugs: [],
319
+ sourceBySlug: new Map(),
289
320
  failureReason: null,
290
321
  });
291
322
  });
@@ -529,3 +560,460 @@ describe("runRouter — failure modes", () => {
529
560
  expect(providerCalls[0].options?.signal).toBe(controller.signal);
530
561
  });
531
562
  });
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // Batched routing (config.memory.v2.router.batch_size).
566
+ // ---------------------------------------------------------------------------
567
+
568
+ describe("runRouter — batched (batch_size set)", () => {
569
+ beforeEach(async () => {
570
+ // 5 pages → at batch_size=2 we get ceil(5/2)=3 batches.
571
+ await writePage(workspaceDir, makePage("alpha", { summary: "A" }));
572
+ await writePage(workspaceDir, makePage("bravo", { summary: "B" }));
573
+ await writePage(workspaceDir, makePage("charlie", { summary: "C" }));
574
+ await writePage(workspaceDir, makePage("delta", { summary: "D" }));
575
+ await writePage(workspaceDir, makePage("echo", { summary: "E" }));
576
+ });
577
+
578
+ test("fires one provider call per batch in parallel", async () => {
579
+ // Every batch returns its local id 1 → at most 3 distinct slugs in the
580
+ // union (one per batch), but we don't assert WHICH slugs the FNV
581
+ // bucketing picks; just that the provider was called once per batch.
582
+ providerStub = makeProvider(toolUseResponse([1]));
583
+
584
+ const result = await runRouter({
585
+ workspaceDir,
586
+ ...COMMON_PARAMS,
587
+ config: makeConfig({ batchSize: 2 }),
588
+ });
589
+
590
+ expect(result.failureReason).toBeNull();
591
+ expect(providerCalls.length).toBeGreaterThan(1);
592
+ expect(providerCalls.length).toBeLessThanOrEqual(3);
593
+ // Every batch picked its own local id 1 → distinct slugs in union.
594
+ expect(result.selectedSlugs.length).toBe(providerCalls.length);
595
+ expect(new Set(result.selectedSlugs).size).toBe(
596
+ result.selectedSlugs.length,
597
+ );
598
+ });
599
+
600
+ test("each batch's system prompt contains only its own subset of slugs", async () => {
601
+ providerStub = makeProvider(toolUseResponse([1]));
602
+ await runRouter({
603
+ workspaceDir,
604
+ ...COMMON_PARAMS,
605
+ config: makeConfig({ batchSize: 2 }),
606
+ });
607
+
608
+ // Across all batch calls, every slug appears in exactly one prompt.
609
+ const allSlugs = ["alpha", "bravo", "charlie", "delta", "echo"];
610
+ const appearances = new Map<string, number>(allSlugs.map((s) => [s, 0]));
611
+ for (const call of providerCalls) {
612
+ for (const slug of allSlugs) {
613
+ if (call.systemPrompt?.includes(slug)) {
614
+ appearances.set(slug, (appearances.get(slug) ?? 0) + 1);
615
+ }
616
+ }
617
+ }
618
+ for (const slug of allSlugs) {
619
+ expect(appearances.get(slug)).toBe(1);
620
+ }
621
+ });
622
+
623
+ test("union of selected slugs is deduplicated across batches", async () => {
624
+ // Every batch returns its local id 1. Same slug could appear in only
625
+ // one batch (since each slug lives in exactly one batch), so the union
626
+ // is naturally unique. Sanity-check the dedup path with a 2-call response.
627
+ providerStub = makeProvider(toolUseResponse([1, 1]));
628
+ const result = await runRouter({
629
+ workspaceDir,
630
+ ...COMMON_PARAMS,
631
+ config: makeConfig({ batchSize: 2 }),
632
+ });
633
+ expect(result.failureReason).toBeNull();
634
+ expect(new Set(result.selectedSlugs).size).toBe(
635
+ result.selectedSlugs.length,
636
+ );
637
+ });
638
+
639
+ test("priorEverInjected is filtered to the batch's own slugs as local IDs", async () => {
640
+ providerStub = makeProvider(toolUseResponse([1]));
641
+ await runRouter({
642
+ workspaceDir,
643
+ ...COMMON_PARAMS,
644
+ priorEverInjected: [
645
+ { slug: "alpha", turn: 1 },
646
+ { slug: "echo", turn: 1 },
647
+ ],
648
+ config: makeConfig({ batchSize: 2 }),
649
+ });
650
+
651
+ // Exactly the batches containing alpha or echo should mention any
652
+ // already_injected_id; other batches should have an empty list.
653
+ for (const call of providerCalls) {
654
+ const text =
655
+ (call.messages[0].content as Array<{ text?: string }>)[1]?.text ?? "";
656
+ const hasAlpha = call.systemPrompt?.includes("alpha");
657
+ const hasEcho = call.systemPrompt?.includes("echo");
658
+ const expectsId = hasAlpha || hasEcho;
659
+ // Block contents: "<already_injected_ids>\n{ids}\n</already_injected_ids>"
660
+ const match = text.match(
661
+ /<already_injected_ids>\n([^\n]*)\n<\/already_injected_ids>/,
662
+ );
663
+ const idsStr = match?.[1] ?? "";
664
+ if (expectsId) {
665
+ expect(idsStr.trim().length).toBeGreaterThan(0);
666
+ } else {
667
+ expect(idsStr.trim()).toBe("");
668
+ }
669
+ }
670
+ });
671
+
672
+ test("partial failure: one batch fails, others succeed → union returned with success", async () => {
673
+ let callCount = 0;
674
+ providerStub = {
675
+ name: "partial-failure",
676
+ sendMessage: async (messages, tools, systemPrompt, options) => {
677
+ callCount += 1;
678
+ providerCalls.push({ messages, tools, systemPrompt, options });
679
+ if (callCount === 1) throw new Error("batch 1 boom");
680
+ return toolUseResponse([1]);
681
+ },
682
+ };
683
+
684
+ const result = await runRouter({
685
+ workspaceDir,
686
+ ...COMMON_PARAMS,
687
+ config: makeConfig({ batchSize: 2 }),
688
+ });
689
+
690
+ expect(result.failureReason).toBeNull();
691
+ expect(result.selectedSlugs.length).toBeGreaterThan(0);
692
+ expect(providerCalls.length).toBeGreaterThan(1);
693
+ });
694
+
695
+ test("all batches fail → unified failure with first batch's reason", async () => {
696
+ providerStub = {
697
+ name: "all-fail",
698
+ sendMessage: async () => {
699
+ throw new Error("all batches boom");
700
+ },
701
+ };
702
+
703
+ const result = await runRouter({
704
+ workspaceDir,
705
+ ...COMMON_PARAMS,
706
+ config: makeConfig({ batchSize: 2 }),
707
+ });
708
+
709
+ expect(result.failureReason).toBe("api_error");
710
+ expect(result.selectedSlugs).toEqual([]);
711
+ });
712
+
713
+ test("union across batches is truncated to global max_page_ids", async () => {
714
+ // 5 pages, batch_size=1 → 5 batches, each picks its own local id 1.
715
+ // Without a global cap the union would be 5; max_page_ids=2 forces
716
+ // truncation back to 2.
717
+ providerStub = makeProvider(toolUseResponse([1]));
718
+
719
+ const result = await runRouter({
720
+ workspaceDir,
721
+ ...COMMON_PARAMS,
722
+ config: makeConfig({ batchSize: 1, maxPageIds: 2 }),
723
+ });
724
+
725
+ expect(result.failureReason).toBeNull();
726
+ expect(result.selectedSlugs).toHaveLength(2);
727
+ // sourceBySlug must stay aligned with the truncated selection.
728
+ expect(result.sourceBySlug.size).toBe(2);
729
+ for (const slug of result.selectedSlugs) {
730
+ expect(result.sourceBySlug.get(slug)).toBeDefined();
731
+ }
732
+ const warned = warnLogs.some((l) =>
733
+ JSON.stringify(l.args).includes("union across batches exceeded"),
734
+ );
735
+ expect(warned).toBe(true);
736
+ });
737
+
738
+ test("batch_size larger than index size is single batch (same as v3)", async () => {
739
+ providerStub = makeProvider(toolUseResponse([1]));
740
+ const result = await runRouter({
741
+ workspaceDir,
742
+ ...COMMON_PARAMS,
743
+ config: makeConfig({ batchSize: 1000 }),
744
+ });
745
+ expect(result.failureReason).toBeNull();
746
+ expect(providerCalls).toHaveLength(1);
747
+ });
748
+ });
749
+
750
+ // ---------------------------------------------------------------------------
751
+ // Tier 1 (recently modified) splitting.
752
+ // ---------------------------------------------------------------------------
753
+
754
+ const { utimes } = await import("node:fs/promises");
755
+
756
+ describe("runRouter — tier 1 (recently modified)", () => {
757
+ async function setMtime(slug: string, epochMs: number): Promise<void> {
758
+ const seconds = epochMs / 1000;
759
+ await utimes(
760
+ join(workspaceDir, "memory", "concepts", `${slug}.md`),
761
+ seconds,
762
+ seconds,
763
+ );
764
+ }
765
+
766
+ beforeEach(async () => {
767
+ await writePage(workspaceDir, makePage("alpha", { summary: "A" }));
768
+ await writePage(workspaceDir, makePage("bravo", { summary: "B" }));
769
+ await writePage(workspaceDir, makePage("charlie", { summary: "C" }));
770
+ await writePage(workspaceDir, makePage("delta", { summary: "D" }));
771
+ await writePage(workspaceDir, makePage("echo", { summary: "E" }));
772
+ });
773
+
774
+ test("tier1_size + batch_size both null is the v3 single-batch path", async () => {
775
+ providerStub = makeProvider(toolUseResponse([1]));
776
+ const result = await runRouter({
777
+ workspaceDir,
778
+ ...COMMON_PARAMS,
779
+ config: makeConfig(),
780
+ });
781
+ expect(result.failureReason).toBeNull();
782
+ expect(providerCalls).toHaveLength(1);
783
+ });
784
+
785
+ test("tier1_size=2 + batch_size=null produces 2 batches (tier1 + rest)", async () => {
786
+ providerStub = makeProvider(toolUseResponse([1]));
787
+ const result = await runRouter({
788
+ workspaceDir,
789
+ ...COMMON_PARAMS,
790
+ config: makeConfig({ tier1Size: 2 }),
791
+ });
792
+ expect(result.failureReason).toBeNull();
793
+ expect(providerCalls).toHaveLength(2);
794
+ });
795
+
796
+ test("tier 1 contains the most recently modified pages", async () => {
797
+ // Stamp distinct mtimes so the ordering is unambiguous.
798
+ await setMtime("alpha", 1_000_000);
799
+ await setMtime("bravo", 5_000_000); // most recent
800
+ await setMtime("charlie", 2_000_000);
801
+ await setMtime("delta", 4_000_000); // 2nd most recent
802
+ await setMtime("echo", 3_000_000);
803
+
804
+ providerStub = makeProvider(toolUseResponse([1]));
805
+ await runRouter({
806
+ workspaceDir,
807
+ ...COMMON_PARAMS,
808
+ config: makeConfig({ tier1Size: 2 }),
809
+ });
810
+
811
+ // Tier 1 is the first provider call. Match `[N] slug` lines specifically
812
+ // — string-search on slug name alone would false-positive on prompt
813
+ // template text that may mention the same words.
814
+ const tier1Prompt = providerCalls[0].systemPrompt ?? "";
815
+ const indexedSlugs = new Set(
816
+ [...tier1Prompt.matchAll(/^\[\d+\] (\S+)/gm)].map((m) => m[1]),
817
+ );
818
+ expect(indexedSlugs).toEqual(new Set(["bravo", "delta"]));
819
+ });
820
+
821
+ test("tier1_size=2 + batch_size=2 puts every slug in exactly one batch", async () => {
822
+ providerStub = makeProvider(toolUseResponse([1]));
823
+ await runRouter({
824
+ workspaceDir,
825
+ ...COMMON_PARAMS,
826
+ config: makeConfig({ tier1Size: 2, batchSize: 2 }),
827
+ });
828
+ // 5 pages, tier1=2, rest=3 → 1 tier1 batch + 1-or-2 tier3 batches
829
+ // depending on whether FNV hash distributes the 3 rest slugs into both
830
+ // buckets. The empty-batch filter drops a bucket that lands empty, so
831
+ // the strong invariant is "every slug appears in exactly one batch."
832
+ expect(providerCalls.length).toBeGreaterThanOrEqual(2);
833
+ expect(providerCalls.length).toBeLessThanOrEqual(3);
834
+
835
+ const allSlugs = ["alpha", "bravo", "charlie", "delta", "echo"];
836
+ const appearances = new Map<string, number>(
837
+ allSlugs.map((s) => [s, 0] as [string, number]),
838
+ );
839
+ for (const call of providerCalls) {
840
+ for (const slug of allSlugs) {
841
+ if (call.systemPrompt?.includes(slug)) {
842
+ appearances.set(slug, (appearances.get(slug) ?? 0) + 1);
843
+ }
844
+ }
845
+ }
846
+ for (const slug of allSlugs) {
847
+ expect(appearances.get(slug)).toBe(1);
848
+ }
849
+ });
850
+
851
+ test("tier1_size >= total pages → single tier 1 batch, no rest", async () => {
852
+ providerStub = makeProvider(toolUseResponse([1]));
853
+ await runRouter({
854
+ workspaceDir,
855
+ ...COMMON_PARAMS,
856
+ config: makeConfig({ tier1Size: 100 }),
857
+ });
858
+ // 5 pages, tier1_size=100 → only tier 1 fires; the empty rest is dropped.
859
+ expect(providerCalls).toHaveLength(1);
860
+ });
861
+ });
862
+
863
+ // ---------------------------------------------------------------------------
864
+ // Tier 2 (highest-EMA) splitting.
865
+ // ---------------------------------------------------------------------------
866
+
867
+ describe("runRouter — tier 2 (highest EMA)", () => {
868
+ // Any non-null value passes the `params.database` check in the orchestrator;
869
+ // the real db is never touched because computeInjectionScores is mocked.
870
+ const stubDb = {} as Parameters<typeof runRouter>[0]["database"];
871
+
872
+ beforeEach(async () => {
873
+ await writePage(workspaceDir, makePage("alpha", { summary: "A" }));
874
+ await writePage(workspaceDir, makePage("bravo", { summary: "B" }));
875
+ await writePage(workspaceDir, makePage("charlie", { summary: "C" }));
876
+ await writePage(workspaceDir, makePage("delta", { summary: "D" }));
877
+ await writePage(workspaceDir, makePage("echo", { summary: "E" }));
878
+ });
879
+
880
+ test("tier2_size + tier1_size both null is the v3 single-batch path", async () => {
881
+ providerStub = makeProvider(toolUseResponse([1]));
882
+ await runRouter({
883
+ workspaceDir,
884
+ ...COMMON_PARAMS,
885
+ config: makeConfig(),
886
+ database: stubDb,
887
+ });
888
+ expect(providerCalls).toHaveLength(1);
889
+ });
890
+
891
+ test("tier2_size=2 produces 2 batches (tier 2 + rest)", async () => {
892
+ scoresStub.set("alpha", 1.0);
893
+ scoresStub.set("bravo", 5.0);
894
+ scoresStub.set("delta", 4.0);
895
+
896
+ providerStub = makeProvider(toolUseResponse([1]));
897
+ await runRouter({
898
+ workspaceDir,
899
+ ...COMMON_PARAMS,
900
+ config: makeConfig({ tier2Size: 2 }),
901
+ database: stubDb,
902
+ });
903
+ expect(providerCalls.length).toBe(2);
904
+
905
+ // Tier 2 is the first batch (no tier 1 in this test). Its prompt should
906
+ // contain exactly the top-2 by score: bravo (5) and delta (4).
907
+ const tier2Prompt = providerCalls[0].systemPrompt ?? "";
908
+ const indexedSlugs = new Set(
909
+ [...tier2Prompt.matchAll(/^\[\d+\] (\S+)/gm)].map((m) => m[1]),
910
+ );
911
+ expect(indexedSlugs).toEqual(new Set(["bravo", "delta"]));
912
+ });
913
+
914
+ test("tier 1 then tier 2 then rest — three batches in that order", async () => {
915
+ // Pin mtimes so tier 1 is deterministic: alpha + bravo are the two
916
+ // most recent (highest mtime values). Tier 2 runs on the rest
917
+ // (charlie, delta, echo) using scores we control.
918
+ const { utimes } = await import("node:fs/promises");
919
+ const stamp = async (slug: string, s: number) =>
920
+ utimes(join(workspaceDir, "memory", "concepts", `${slug}.md`), s, s);
921
+ await stamp("alpha", 9000);
922
+ await stamp("bravo", 8000);
923
+ await stamp("charlie", 3000);
924
+ await stamp("delta", 2000);
925
+ await stamp("echo", 1000);
926
+ invalidatePageIndex();
927
+
928
+ scoresStub.set("charlie", 2.0);
929
+ scoresStub.set("delta", 3.0);
930
+ // echo has no score → ineligible for tier 2.
931
+
932
+ providerStub = makeProvider(toolUseResponse([1]));
933
+ await runRouter({
934
+ workspaceDir,
935
+ ...COMMON_PARAMS,
936
+ config: makeConfig({ tier1Size: 2, tier2Size: 2 }),
937
+ database: stubDb,
938
+ });
939
+ expect(providerCalls.length).toBe(3);
940
+
941
+ // Tier 2 batch (index 1) contains charlie + delta. Echo went to rest.
942
+ const tier2Prompt = providerCalls[1].systemPrompt ?? "";
943
+ const tier2Slugs = new Set(
944
+ [...tier2Prompt.matchAll(/^\[\d+\] (\S+)/gm)].map((m) => m[1]),
945
+ );
946
+ expect(tier2Slugs).toEqual(new Set(["charlie", "delta"]));
947
+
948
+ // Echo (no score) must land in the rest batch, not tier 2.
949
+ const restPrompt = providerCalls[2].systemPrompt ?? "";
950
+ const restSlugs = new Set(
951
+ [...restPrompt.matchAll(/^\[\d+\] (\S+)/gm)].map((m) => m[1]),
952
+ );
953
+ expect(restSlugs).toEqual(new Set(["echo"]));
954
+ });
955
+
956
+ test("score=0 pages stay in rest even when tier2_size is large", async () => {
957
+ // Only one page has a positive score; tier2_size=100 should NOT pull in
958
+ // zero-score pages.
959
+ scoresStub.set("bravo", 5.0);
960
+
961
+ providerStub = makeProvider(toolUseResponse([1]));
962
+ await runRouter({
963
+ workspaceDir,
964
+ ...COMMON_PARAMS,
965
+ config: makeConfig({ tier2Size: 100 }),
966
+ database: stubDb,
967
+ });
968
+ expect(providerCalls.length).toBe(2);
969
+ const tier2Prompt = providerCalls[0].systemPrompt ?? "";
970
+ expect(tier2Prompt).toContain("[1] bravo");
971
+ expect(tier2Prompt).not.toMatch(/^\[\d+\] alpha/m);
972
+ });
973
+
974
+ test("sourceBySlug tags each selection with its batch tier", async () => {
975
+ scoresStub.set("bravo", 5.0);
976
+ scoresStub.set("delta", 3.0);
977
+
978
+ providerStub = makeProvider(toolUseResponse([1]));
979
+ const result = await runRouter({
980
+ workspaceDir,
981
+ ...COMMON_PARAMS,
982
+ config: makeConfig({ tier1Size: 1, tier2Size: 1 }),
983
+ database: stubDb,
984
+ });
985
+
986
+ // Every selected slug should have a tier tag — exactly one of:
987
+ // "tier1", "tier2", or "tier3:N" (N starts at 0).
988
+ for (const slug of result.selectedSlugs) {
989
+ const source = result.sourceBySlug.get(slug);
990
+ expect(source).toBeDefined();
991
+ expect(
992
+ source === "tier1" ||
993
+ source === "tier2" ||
994
+ source!.startsWith("tier3:"),
995
+ ).toBe(true);
996
+ }
997
+ // Tier 1 + tier 2 + (some tier 3 batches) ≥ 3 batches → at least one
998
+ // slug per tier should be present in the source map across the union.
999
+ const tags = new Set(result.sourceBySlug.values());
1000
+ expect(tags.has("tier1")).toBe(true);
1001
+ expect(tags.has("tier2")).toBe(true);
1002
+ });
1003
+
1004
+ test("tier2_size set without database logs a warn and skips tier 2", async () => {
1005
+ scoresStub.set("bravo", 5.0);
1006
+ providerStub = makeProvider(toolUseResponse([1]));
1007
+ await runRouter({
1008
+ workspaceDir,
1009
+ ...COMMON_PARAMS,
1010
+ config: makeConfig({ tier2Size: 2 }),
1011
+ // No database → tier 2 silently skipped.
1012
+ });
1013
+ expect(providerCalls).toHaveLength(1);
1014
+ const warned = warnLogs.some((l) =>
1015
+ JSON.stringify(l.args).includes("tier2_size set but no database"),
1016
+ );
1017
+ expect(warned).toBe(true);
1018
+ });
1019
+ });
@@ -244,6 +244,20 @@ function readBufferContent(bufferPath: string): string {
244
244
  }
245
245
  }
246
246
 
247
+ /**
248
+ * Count non-empty lines in `memory/buffer.md`. Used by the scheduler to
249
+ * implement the size-based consolidation trigger. Missing file → 0.
250
+ *
251
+ * Each entry is one line (`- [Mon D, h:mm AM/PM] …\n`), so non-empty-line
252
+ * count == entry count for a well-formed buffer; blank lines and trailing
253
+ * newlines don't inflate the count.
254
+ */
255
+ export function countBufferLines(bufferPath: string): number {
256
+ const content = readBufferContent(bufferPath);
257
+ if (content.length === 0) return 0;
258
+ return content.split("\n").filter((line) => line.trim().length > 0).length;
259
+ }
260
+
247
261
  /**
248
262
  * Atomically create the lock file with `wx` (O_CREAT | O_EXCL) flags. Returns
249
263
  * `null` on success, or the current holder string (file contents, typically
@@ -0,0 +1,101 @@
1
+ import { getLogger } from "../../util/logger.js";
2
+ import type { DrizzleDb } from "../db-connection.js";
3
+ import { getSqliteFrom } from "../db-connection.js";
4
+
5
+ const log = getLogger("memory-v2-injection-events");
6
+
7
+ /**
8
+ * Half-life of the injection-frequency decay, in milliseconds.
9
+ *
10
+ * Per the memory router v4 spec: a +1 from 3 days ago contributes 0.5; from
11
+ * 6 days ago 0.25. Decoupled from turn volume — busy and quiet days decay
12
+ * at the same wall-clock rate.
13
+ */
14
+ export const INJECTION_SCORE_HALF_LIFE_MS = 3 * 24 * 60 * 60 * 1000;
15
+
16
+ export const INJECTION_SCORE_LAMBDA =
17
+ Math.log(2) / INJECTION_SCORE_HALF_LIFE_MS;
18
+
19
+ // Events past 6 half-lives contribute <1.6% each. Reads bound the scan to
20
+ // this window so per-slug score computation stays cheap as history grows.
21
+ const READ_WINDOW_MS = 6 * INJECTION_SCORE_HALF_LIFE_MS;
22
+
23
+ function decayContribution(elapsedMs: number): number {
24
+ return Math.exp(-INJECTION_SCORE_LAMBDA * elapsedMs);
25
+ }
26
+
27
+ /**
28
+ * Append one event per slug. Best-effort — a SQLite write must never abort
29
+ * the agent turn on top of a successful routing decision the rest of the
30
+ * caller depends on.
31
+ */
32
+ export function recordInjectionEvents(
33
+ database: DrizzleDb,
34
+ slugs: readonly string[],
35
+ injectedAt: number,
36
+ ): void {
37
+ if (slugs.length === 0) return;
38
+ try {
39
+ const raw = getSqliteFrom(database);
40
+ const insert = raw.prepare(
41
+ `INSERT INTO memory_v2_injection_events (slug, injected_at) VALUES (?, ?)`,
42
+ );
43
+ const append = raw.transaction((items: readonly string[]) => {
44
+ for (const slug of items) insert.run(slug, injectedAt);
45
+ });
46
+ append(slugs);
47
+ } catch (err) {
48
+ log.warn(
49
+ { err, slugCount: slugs.length },
50
+ "failed to record injection events; continuing",
51
+ );
52
+ }
53
+ }
54
+
55
+ /** `score(now) = Σᵢ exp(-λ × (now - tᵢ))` over events within READ_WINDOW_MS. */
56
+ export function computeInjectionScore(
57
+ database: DrizzleDb,
58
+ slug: string,
59
+ now: number,
60
+ ): number {
61
+ const cutoff = now - READ_WINDOW_MS;
62
+ const raw = getSqliteFrom(database);
63
+ const rows = raw
64
+ .query(
65
+ `SELECT injected_at FROM memory_v2_injection_events
66
+ WHERE slug = ? AND injected_at >= ?`,
67
+ )
68
+ .all(slug, cutoff) as Array<{ injected_at: number }>;
69
+ let score = 0;
70
+ for (const row of rows) score += decayContribution(now - row.injected_at);
71
+ return score;
72
+ }
73
+
74
+ /**
75
+ * Batch variant of `computeInjectionScore` — single SQL pass scoped to the
76
+ * requested slugs so tier assignment doesn't issue O(M) queries. Slugs
77
+ * with no events in the read window are omitted from the result; callers
78
+ * should treat a missing entry as score 0.
79
+ */
80
+ export function computeInjectionScores(
81
+ database: DrizzleDb,
82
+ slugs: readonly string[],
83
+ now: number,
84
+ ): Map<string, number> {
85
+ const out = new Map<string, number>();
86
+ if (slugs.length === 0) return out;
87
+ const cutoff = now - READ_WINDOW_MS;
88
+ const raw = getSqliteFrom(database);
89
+ const placeholders = slugs.map(() => "?").join(",");
90
+ const rows = raw
91
+ .query(
92
+ `SELECT slug, injected_at FROM memory_v2_injection_events
93
+ WHERE slug IN (${placeholders}) AND injected_at >= ?`,
94
+ )
95
+ .all(...slugs, cutoff) as Array<{ slug: string; injected_at: number }>;
96
+ for (const row of rows) {
97
+ const prev = out.get(row.slug) ?? 0;
98
+ out.set(row.slug, prev + decayContribution(now - row.injected_at));
99
+ }
100
+ return out;
101
+ }