@vellumai/assistant 0.8.11 → 0.8.12-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +633 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-catalog-parity.test.ts +16 -0
  37. package/src/__tests__/llm-usage-store.test.ts +11 -0
  38. package/src/__tests__/log-export-workspace.test.ts +468 -3
  39. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  40. package/src/__tests__/model-intents.test.ts +1 -1
  41. package/src/__tests__/oauth-cli.test.ts +19 -8
  42. package/src/__tests__/openai-provider.test.ts +34 -0
  43. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  44. package/src/__tests__/recurrence-engine.test.ts +45 -0
  45. package/src/__tests__/schedule-routes.test.ts +34 -0
  46. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  47. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  48. package/src/__tests__/secret-fixtures.ts +20 -0
  49. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  50. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  51. package/src/__tests__/subagent-tools.test.ts +1 -0
  52. package/src/__tests__/system-prompt.test.ts +109 -11
  53. package/src/__tests__/tool-approval-handler.test.ts +85 -0
  54. package/src/__tests__/tool-audit-listener.test.ts +86 -0
  55. package/src/__tests__/tool-error-hook.test.ts +1 -0
  56. package/src/__tests__/tool-result-spool.test.ts +337 -0
  57. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  58. package/src/__tests__/validate-input.test.ts +95 -1
  59. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  60. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  61. package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
  62. package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
  63. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  64. package/src/acp/__tests__/agent-process.test.ts +315 -2
  65. package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
  66. package/src/acp/agent-process.ts +163 -34
  67. package/src/acp/prepare-agent-env.ts +55 -15
  68. package/src/agent/loop.ts +81 -24
  69. package/src/api/events/usage-progress.ts +28 -0
  70. package/src/api/index.ts +6 -0
  71. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  72. package/src/bundler/app-bundler.ts +25 -42
  73. package/src/bundler/app-compiler.ts +8 -0
  74. package/src/calls/call-controller.ts +1 -1
  75. package/src/cli/commands/plugins.ts +248 -15
  76. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  77. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  78. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  79. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  80. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  81. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
  82. package/src/cli/lib/inspect-plugin.ts +252 -0
  83. package/src/cli/lib/install-from-github.ts +214 -21
  84. package/src/cli/lib/list-installed-plugins.ts +17 -6
  85. package/src/cli/lib/plugin-artifact.ts +103 -0
  86. package/src/cli/lib/plugin-details.ts +18 -1
  87. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  88. package/src/cli/lib/upgrade-plugin.ts +225 -0
  89. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  90. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  91. package/src/config/call-site-defaults.ts +13 -2
  92. package/src/config/feature-flag-registry.json +8 -16
  93. package/src/config/loader.ts +52 -59
  94. package/src/config/schema.ts +0 -2
  95. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  96. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  97. package/src/config/schemas/llm.ts +10 -0
  98. package/src/config/schemas/memory-v2.ts +13 -0
  99. package/src/config/schemas/memory-v3.ts +92 -0
  100. package/src/config/seed-inference-profiles.ts +4 -8
  101. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  102. package/src/context/tool-result-spool.ts +104 -0
  103. package/src/credential-execution/feature-gates.ts +0 -1
  104. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  105. package/src/daemon/conversation-error.ts +6 -15
  106. package/src/daemon/conversation.ts +9 -0
  107. package/src/daemon/disk-pressure-policy.ts +0 -1
  108. package/src/daemon/lifecycle.ts +1 -20
  109. package/src/daemon/message-types/conversations.ts +2 -15
  110. package/src/daemon/trust-context.ts +1 -1
  111. package/src/events/tool-audit-listener.ts +40 -9
  112. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  113. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  114. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  115. package/src/home/home-content-refresh.ts +43 -31
  116. package/src/home/home-greeting-cache.ts +8 -1
  117. package/src/home/home-greeting.ts +13 -9
  118. package/src/home/suggested-prompts.ts +77 -24
  119. package/src/ipc/routes/trust-rules.test.ts +66 -72
  120. package/src/media/image-credentials.ts +2 -2
  121. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  122. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  123. package/src/memory/conversation-attention-store.ts +1 -0
  124. package/src/memory/conversation-bootstrap.ts +18 -9
  125. package/src/memory/conversation-crud.ts +12 -2
  126. package/src/memory/conversation-title-service.ts +53 -9
  127. package/src/memory/delivery-channels.ts +0 -69
  128. package/src/memory/graph/extraction-job.ts +0 -15
  129. package/src/memory/guardian-action-store.ts +1 -376
  130. package/src/memory/llm-usage-store.ts +5 -1
  131. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  132. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  133. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  134. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  135. package/src/memory/v2/consolidation-job.ts +93 -9
  136. package/src/memory/v2/injection.ts +53 -0
  137. package/src/memory/v2/prompts/consolidation.ts +1 -0
  138. package/src/memory/v2/static-context.ts +13 -1
  139. package/src/memory/v2/sweep-job.ts +1 -1
  140. package/src/memory/v2/types.ts +5 -0
  141. package/src/plugin-api/types.ts +7 -0
  142. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  143. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  144. package/src/plugins/defaults/index.ts +25 -0
  145. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  146. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  147. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  148. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  149. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  150. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  151. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  152. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  153. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  154. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  155. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  156. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  157. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  158. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  159. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  160. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  161. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  162. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  163. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  164. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  165. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  166. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  167. package/src/prompts/cache-boundary.ts +17 -0
  168. package/src/prompts/sections.ts +50 -17
  169. package/src/prompts/system-prompt.ts +12 -4
  170. package/src/prompts/templates/system-sections.ts +22 -0
  171. package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
  172. package/src/providers/anthropic/client.ts +74 -28
  173. package/src/providers/gemini/client.ts +5 -1
  174. package/src/providers/minimax/client.ts +9 -0
  175. package/src/providers/model-catalog.ts +28 -0
  176. package/src/providers/model-intents.ts +3 -3
  177. package/src/providers/openai/chat-completions-provider.ts +4 -2
  178. package/src/providers/openai/responses-provider.ts +7 -2
  179. package/src/providers/retry.ts +8 -0
  180. package/src/providers/types.ts +11 -0
  181. package/src/providers/unparseable-tool-args.ts +56 -0
  182. package/src/runtime/AGENTS.md +6 -0
  183. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  184. package/src/runtime/agent-wake.ts +5 -5
  185. package/src/runtime/background-job-runner.ts +2 -2
  186. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  187. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  188. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  189. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  190. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  191. package/src/runtime/pre-first-message-gate.ts +1 -1
  192. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  193. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
  194. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  195. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  196. package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
  197. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  198. package/src/runtime/routes/btw-routes.ts +0 -14
  199. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  200. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  201. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  202. package/src/runtime/routes/conversation-query-routes.ts +79 -4
  203. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  204. package/src/runtime/routes/home-feed-routes.ts +10 -0
  205. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  206. package/src/runtime/routes/identity-routes.ts +76 -20
  207. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  208. package/src/runtime/routes/log-export-routes.ts +143 -96
  209. package/src/runtime/routes/plugins-routes.ts +380 -0
  210. package/src/runtime/routes/redact-staged-export.ts +259 -0
  211. package/src/runtime/routes/schedule-routes.ts +19 -2
  212. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  213. package/src/schedule/recurrence-engine.ts +34 -0
  214. package/src/schedule/scheduler.ts +1 -0
  215. package/src/security/redact-json.ts +61 -0
  216. package/src/skills/validate-input.ts +41 -1
  217. package/src/subagent/types.ts +26 -1
  218. package/src/telemetry/types.ts +15 -1
  219. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  220. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  221. package/src/tools/apps/executors.ts +1 -1
  222. package/src/tools/skills/skill-tool-factory.ts +19 -8
  223. package/src/tools/tool-approval-handler.ts +31 -0
  224. package/src/usage/types.ts +8 -1
  225. package/src/util/platform.ts +16 -0
  226. package/src/watcher/engine.ts +1 -0
  227. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  228. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  229. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  230. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  231. package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
  232. package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
  233. package/src/workspace/migrations/registry.ts +8 -0
  234. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  235. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  236. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  237. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  238. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  239. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  240. package/src/config/schemas/updates.ts +0 -14
  241. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  242. package/src/memory/conversation-starters-cadence.ts +0 -78
  243. package/src/prompts/update-bulletin-job.ts +0 -180
  244. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -141,14 +141,32 @@ const { CUTOFF_PLACEHOLDER, CONSOLIDATION_PROMPT } =
141
141
  await import("../prompts/consolidation.js");
142
142
 
143
143
  // The handler only reads `config.memory.enabled`, `config.memory.v2.enabled`,
144
- // and `config.memory.v2.consolidation_prompt_path`, so a minimal stand-in
145
- // covers those call sites without materializing the full default config.
144
+ // `config.memory.v2.consolidation_prompt_path`, and
145
+ // `config.memory.v2.consolidation_max_entries_per_run`, so a minimal
146
+ // stand-in covers those call sites without materializing the full default
147
+ // config.
146
148
  const CONFIG = {
147
149
  memory: {
148
150
  enabled: true,
149
151
  v2: { enabled: true, consolidation_prompt_path: null },
150
152
  },
151
153
  } as Parameters<typeof memoryV2ConsolidateJob>[1];
154
+
155
+ /** CONFIG plus a per-run entry cap for the chunked-cutoff tests. */
156
+ function configWithMaxEntries(
157
+ maxEntries: number | null,
158
+ ): Parameters<typeof memoryV2ConsolidateJob>[1] {
159
+ return {
160
+ memory: {
161
+ enabled: true,
162
+ v2: {
163
+ enabled: true,
164
+ consolidation_prompt_path: null,
165
+ consolidation_max_entries_per_run: maxEntries,
166
+ },
167
+ },
168
+ } as Parameters<typeof memoryV2ConsolidateJob>[1];
169
+ }
152
170
  const CONFIG_DISABLED = {
153
171
  memory: {
154
172
  enabled: true,
@@ -205,6 +223,169 @@ beforeEach(() => {
205
223
 
206
224
  // ---------------------------------------------------------------------------
207
225
 
226
+ describe("memoryV2ConsolidateJob — chunked cutoff (consolidation_max_entries_per_run)", () => {
227
+ const FIVE_ENTRIES = [
228
+ "- [Apr 27, 9:00 AM] Alice prefers VS Code.",
229
+ "- [Apr 27, 9:01 AM] Bob takes his coffee black.",
230
+ "- [Apr 27, 9:02 AM] Carol loves jazz.",
231
+ "- [Apr 27, 9:03 AM] Dave runs marathons.",
232
+ "- [Apr 27, 9:04 AM] Erin paints watercolors.",
233
+ ].join("\n");
234
+
235
+ test("buffer over the cap → cutoff is the first over-cap entry's timestamp; overflow deferred", async () => {
236
+ writeFileSync(bufferPath(), FIVE_ENTRIES + "\n");
237
+
238
+ const outcome = await memoryV2ConsolidateJob(
239
+ makeJob(),
240
+ configWithMaxEntries(3),
241
+ );
242
+
243
+ expect(outcome.kind).toBe("invoked");
244
+ if (outcome.kind !== "invoked") throw new Error("unreachable");
245
+ expect(outcome.cutoff).toBe("Apr 27, 9:03 AM");
246
+ expect(outcome.deferredEntries).toBe(2);
247
+ expect(runnerCalls).toBe(1);
248
+ expect(runnerLastArgs!.prompt as string).toContain("Apr 27, 9:03 AM");
249
+ });
250
+
251
+ test("buffer at or under the cap → full-buffer cutoff, nothing deferred", async () => {
252
+ writeFileSync(bufferPath(), FIVE_ENTRIES + "\n");
253
+
254
+ const outcome = await memoryV2ConsolidateJob(
255
+ makeJob(),
256
+ configWithMaxEntries(5),
257
+ );
258
+
259
+ expect(outcome.kind).toBe("invoked");
260
+ if (outcome.kind !== "invoked") throw new Error("unreachable");
261
+ expect(outcome.deferredEntries).toBe(0);
262
+ // Cutoff is the run-start timestamp, not any buffer entry's.
263
+ expect(outcome.cutoff).not.toBe("Apr 27, 9:03 AM");
264
+ });
265
+
266
+ test("null cap → full buffer processed regardless of size", async () => {
267
+ writeFileSync(bufferPath(), FIVE_ENTRIES + "\n");
268
+
269
+ const outcome = await memoryV2ConsolidateJob(
270
+ makeJob(),
271
+ configWithMaxEntries(null),
272
+ );
273
+
274
+ expect(outcome.kind).toBe("invoked");
275
+ if (outcome.kind !== "invoked") throw new Error("unreachable");
276
+ expect(outcome.deferredEntries).toBe(0);
277
+ });
278
+
279
+ test("cap absent from config (hand-crafted stand-in) → full buffer processed", async () => {
280
+ writeFileSync(bufferPath(), FIVE_ENTRIES + "\n");
281
+
282
+ const outcome = await memoryV2ConsolidateJob(makeJob(), CONFIG);
283
+
284
+ expect(outcome.kind).toBe("invoked");
285
+ if (outcome.kind !== "invoked") throw new Error("unreachable");
286
+ expect(outcome.deferredEntries).toBe(0);
287
+ });
288
+
289
+ test("same-minute burst (every entry shares the over-cap timestamp) → full-buffer cutoff, no wedge", () => {
290
+ // A sweep that wrote >cap entries within one minute: a pulled-back
291
+ // cutoff would defer ALL of them ("timestamp >= cutoff stays") and the
292
+ // size trigger would requeue an identical no-op run forever.
293
+ writeFileSync(
294
+ bufferPath(),
295
+ [
296
+ "- [Apr 27, 9:00 AM] Alice prefers VS Code.",
297
+ "- [Apr 27, 9:00 AM] Bob takes his coffee black.",
298
+ "- [Apr 27, 9:00 AM] Carol loves jazz.",
299
+ "- [Apr 27, 9:00 AM] Dave runs marathons.",
300
+ ].join("\n") + "\n",
301
+ );
302
+
303
+ return memoryV2ConsolidateJob(makeJob(), configWithMaxEntries(2)).then(
304
+ (outcome) => {
305
+ expect(outcome.kind).toBe("invoked");
306
+ if (outcome.kind !== "invoked") throw new Error("unreachable");
307
+ expect(outcome.deferredEntries).toBe(0);
308
+ expect(outcome.cutoff).not.toBe("Apr 27, 9:00 AM");
309
+ },
310
+ );
311
+ });
312
+
313
+ test("continuation lines (multi-line entries) don't count as entries or break the cutoff", async () => {
314
+ // The second entry carries embedded newlines: its continuation lines
315
+ // belong to that entry, so the buffer holds 3 entries, not 5 lines.
316
+ // With a cap of 2, the cutoff is the THIRD entry's timestamp.
317
+ writeFileSync(
318
+ bufferPath(),
319
+ [
320
+ "- [Apr 27, 9:00 AM] Alice prefers VS Code.",
321
+ "- [Apr 27, 9:01 AM] Bob shared a snippet:",
322
+ " line two of Bob's multi-line memory",
323
+ " line three of Bob's multi-line memory",
324
+ "- [Apr 27, 9:03 AM] Dave runs marathons.",
325
+ ].join("\n") + "\n",
326
+ );
327
+
328
+ const outcome = await memoryV2ConsolidateJob(
329
+ makeJob(),
330
+ configWithMaxEntries(2),
331
+ );
332
+
333
+ expect(outcome.kind).toBe("invoked");
334
+ if (outcome.kind !== "invoked") throw new Error("unreachable");
335
+ expect(outcome.cutoff).toBe("Apr 27, 9:03 AM");
336
+ expect(outcome.deferredEntries).toBe(1);
337
+ });
338
+
339
+ test("bracketed continuation lines (checklists, wikilinks) are not counted as entries", async () => {
340
+ // Continuation lines that START with "- [" but don't carry the
341
+ // formatBufferTimestamp shape must not count toward the cap or become
342
+ // the cutoff — "- [ ] task" would otherwise yield a blank-string cutoff.
343
+ writeFileSync(
344
+ bufferPath(),
345
+ [
346
+ "- [Apr 27, 9:00 AM] Alice's project plan:",
347
+ "- [ ] follow up with Bob",
348
+ "- [[meeting-notes]] referenced doc",
349
+ "- [Apr 27, 9:01 AM] Carol loves jazz.",
350
+ ].join("\n") + "\n",
351
+ );
352
+
353
+ const outcome = await memoryV2ConsolidateJob(
354
+ makeJob(),
355
+ configWithMaxEntries(2),
356
+ );
357
+
358
+ expect(outcome.kind).toBe("invoked");
359
+ if (outcome.kind !== "invoked") throw new Error("unreachable");
360
+ // Two real entries, cap 2 → no chunking, full-buffer cutoff.
361
+ expect(outcome.deferredEntries).toBe(0);
362
+ expect(outcome.cutoff).not.toBe(" ");
363
+ expect(outcome.cutoff).not.toBe("[meeting-notes]");
364
+ });
365
+
366
+ test("multi-line entries under the cap → full-buffer cutoff even when raw line count exceeds it", async () => {
367
+ writeFileSync(
368
+ bufferPath(),
369
+ [
370
+ "- [Apr 27, 9:00 AM] Alice shared a snippet:",
371
+ " continuation one",
372
+ " continuation two",
373
+ "- [Apr 27, 9:01 AM] Bob takes his coffee black.",
374
+ ].join("\n") + "\n",
375
+ );
376
+
377
+ const outcome = await memoryV2ConsolidateJob(
378
+ makeJob(),
379
+ configWithMaxEntries(2),
380
+ );
381
+
382
+ expect(outcome.kind).toBe("invoked");
383
+ if (outcome.kind !== "invoked") throw new Error("unreachable");
384
+ expect(outcome.deferredEntries).toBe(0);
385
+ expect(outcome.cutoff).not.toBe("Apr 27, 9:01 AM");
386
+ });
387
+ });
388
+
208
389
  describe("memoryV2ConsolidateJob — v2 disabled", () => {
209
390
  test("returns disabled without invoking the runner when memory.enabled is false", async () => {
210
391
  writeFileSync(bufferPath(), "- [Apr 27, 9:00 AM] Alice prefers VS Code.\n");
@@ -1872,6 +1872,76 @@ describe("injectMemoryV2Block", () => {
1872
1872
  expect(aliceRow!.ownActivation).toBe(0);
1873
1873
  });
1874
1874
 
1875
+ test("flag-on: saturated index (every page already injected) skips the router LLM call", async () => {
1876
+ // Turn 1 — normal router path injects alice.
1877
+ routerState.nextResult = {
1878
+ selectedSlugs: ["alice-vscode"],
1879
+ failureReason: null,
1880
+ };
1881
+ await injectMemoryV2Block({
1882
+ database: db,
1883
+ conversationId: "conv-router-saturated",
1884
+ currentTurn: 1,
1885
+ recentTurnPairs: [
1886
+ { assistantMessage: "", userMessage: "Tell me about Alice" },
1887
+ ],
1888
+ nowText: "Now",
1889
+ messageId: "msg-1",
1890
+ config: makeConfig({ router: { enabled: true } }),
1891
+ });
1892
+
1893
+ // Mark every page in the index as already injected — the state a
1894
+ // small workspace reaches a few turns into any conversation.
1895
+ const { getPageIndex } = await import("../page-index.js");
1896
+ const index = await getPageIndex(tmpWorkspace);
1897
+ const seeded = await hydrate(db, "conv-router-saturated");
1898
+ await save(db, "conv-router-saturated", {
1899
+ ...seeded!,
1900
+ everInjected: index.entries.map((e) => ({ slug: e.slug, turn: 1 })),
1901
+ });
1902
+
1903
+ // Turn 2 — the router must not be called: any selection would dedupe
1904
+ // against everInjected and render nothing.
1905
+ const callsBefore = routerState.callCount;
1906
+ routerState.nextResult = {
1907
+ selectedSlugs: ["bob-coffee"],
1908
+ failureReason: null,
1909
+ };
1910
+ const result = await injectMemoryV2Block({
1911
+ database: db,
1912
+ conversationId: "conv-router-saturated",
1913
+ currentTurn: 2,
1914
+ recentTurnPairs: [{ assistantMessage: "ok", userMessage: "and Bob?" }],
1915
+ nowText: "Now",
1916
+ messageId: "msg-2",
1917
+ config: makeConfig({ router: { enabled: true } }),
1918
+ });
1919
+
1920
+ expect(routerState.callCount).toBe(callsBefore);
1921
+ expect(result.block).toBeNull();
1922
+ expect(result.toInject).toEqual([]);
1923
+
1924
+ // State still advanced; everInjected preserved for future dedupe.
1925
+ const persisted = await hydrate(db, "conv-router-saturated");
1926
+ expect(persisted!.currentTurn).toBe(2);
1927
+ expect(persisted!.messageId).toBe("msg-2");
1928
+ expect(persisted!.everInjected.length).toBe(index.entries.length);
1929
+
1930
+ // Telemetry row stays in router mode with every slug carried over.
1931
+ const row = telemetryState.recordCalls.at(-1) as {
1932
+ mode: string;
1933
+ concepts: Array<{ source: string; status: string }>;
1934
+ };
1935
+ expect(row.mode).toBe("router");
1936
+ expect(row.concepts.length).toBe(index.entries.length);
1937
+ for (const concept of row.concepts) {
1938
+ expect(concept.source).toBe("carry_over");
1939
+ // `finalizeInjection` rewrites the placeholder status: slugs already
1940
+ // in everInjected are reported as in_context.
1941
+ expect(concept.status).toBe("in_context");
1942
+ }
1943
+ });
1944
+
1875
1945
  test("flag-on: router failure logs warn, writes mode:`errored` telemetry, returns null block", async () => {
1876
1946
  routerState.nextResult = {
1877
1947
  selectedSlugs: [],
@@ -126,6 +126,18 @@ describe("readMemoryV2StaticContent", () => {
126
126
  expect(text.indexOf("## Recent")).toBeLessThan(text.indexOf("## Buffer"));
127
127
  });
128
128
 
129
+ test("excludeBuffer drops the Buffer section but keeps the other three", () => {
130
+ for (const file of MEMORY_FILES) writeMemoryFile(file, `Content ${file}`);
131
+
132
+ const result = readMemoryV2StaticContent({ excludeBuffer: true });
133
+ expect(result).not.toBeNull();
134
+ expect(result!).toContain("## Essentials");
135
+ expect(result!).toContain("## Threads");
136
+ expect(result!).toContain("## Recent");
137
+ expect(result!).not.toContain("## Buffer");
138
+ expect(result!).not.toContain("Content buffer.md");
139
+ });
140
+
129
141
  test("omits empty files but keeps populated ones", () => {
130
142
  writeMemoryFile("essentials.md", "Alice prefers VS Code.");
131
143
  writeMemoryFile("threads.md", "");
@@ -144,6 +144,12 @@ export type ConsolidationOutcome =
144
144
  kind: "invoked";
145
145
  conversationId: string;
146
146
  cutoff: string;
147
+ /**
148
+ * Buffer entries beyond `consolidation_max_entries_per_run` left for a
149
+ * follow-up pass via the pulled-back cutoff. `0` when the whole buffer
150
+ * fit in one run.
151
+ */
152
+ deferredEntries: number;
147
153
  followUpJobIds: string[];
148
154
  };
149
155
 
@@ -174,15 +180,7 @@ export async function memoryV2ConsolidateJob(
174
180
  }
175
181
 
176
182
  try {
177
- // Step 2: capture cutoff. Formatted to match `buffer.md` entry timestamps
178
- // (`Mon D, h:mm AM/PM`, see `formatBufferTimestamp`) so the agent's
179
- // "timestamp ≥ cutoff" check compares like-with-like at minute precision.
180
- // Same-minute entries land on the next pass — conservative but loss-free.
181
- // Captured here (not at enqueue time) so late-claimed rows get a fresh
182
- // cutoff.
183
- const cutoff = formatBufferTimestamp(new Date());
184
-
185
- // Step 3: bail on empty buffer. Nothing for the agent to consolidate.
183
+ // Step 2: bail on empty buffer. Nothing for the agent to consolidate.
186
184
  // The lock is released in finally below.
187
185
  const bufferContent = readBufferContent(bufferPath);
188
186
  if (bufferContent.trim().length === 0) {
@@ -190,6 +188,68 @@ export async function memoryV2ConsolidateJob(
190
188
  return { kind: "empty_buffer" };
191
189
  }
192
190
 
191
+ // Step 3: capture cutoff. Formatted to match `buffer.md` entry timestamps
192
+ // (`Mon D, h:mm AM/PM`, see `formatBufferTimestamp`) so the agent's
193
+ // "timestamp ≥ cutoff" check compares like-with-like at minute precision.
194
+ // Same-minute entries land on the next pass — conservative but loss-free.
195
+ // Captured here (not at enqueue time) so late-claimed rows get a fresh
196
+ // cutoff.
197
+ //
198
+ // Chunking: when the buffer holds more than
199
+ // `consolidation_max_entries_per_run` entries (a backlog from missed or
200
+ // failed runs), pull the cutoff back to the first over-cap entry's
201
+ // timestamp. The agent's existing "≥ cutoff stays" rule then defers the
202
+ // overflow loss-free, and the `consolidation_max_buffer_lines` size
203
+ // trigger re-fires while the remainder stays over threshold — so one run
204
+ // never has to read an unbounded backlog into context. Entries sharing
205
+ // the over-cap entry's minute are also deferred (conservative).
206
+ //
207
+ // Entries are counted by their timestamped bullet-start lines
208
+ // (`- [Mon D, h:mm AM/PM] …`) rather than raw non-empty lines: a
209
+ // remembered fact can carry embedded newlines, and its continuation
210
+ // lines belong to the preceding entry, not the count.
211
+ let cutoff = formatBufferTimestamp(new Date());
212
+ let deferredEntries = 0;
213
+ const maxEntries = config.memory.v2.consolidation_max_entries_per_run;
214
+ if (maxEntries != null) {
215
+ const entryTimestamps = bufferContent
216
+ .split("\n")
217
+ .map(extractBufferEntryTimestamp)
218
+ .filter((timestamp): timestamp is string => timestamp !== null);
219
+ if (entryTimestamps.length > maxEntries) {
220
+ const overflowTimestamp = entryTimestamps[maxEntries];
221
+ // Same-minute burst guard: timestamps have minute precision, so when
222
+ // even the FIRST entry shares the over-cap entry's timestamp, a
223
+ // pulled-back cutoff would tell the agent to defer every entry
224
+ // ("timestamp ≥ cutoff stays") — zero progress, and the size trigger
225
+ // would requeue the identical run forever. Fall back to the
226
+ // full-buffer cutoff in that case; partial same-minute runs (some
227
+ // earlier entries have older timestamps) still make progress.
228
+ if (entryTimestamps[0] === overflowTimestamp) {
229
+ log.warn(
230
+ {
231
+ bufferEntries: entryTimestamps.length,
232
+ maxEntries,
233
+ overflowTimestamp,
234
+ },
235
+ "consolidation: entire over-cap prefix shares one minute timestamp; processing full buffer to guarantee progress",
236
+ );
237
+ } else {
238
+ cutoff = overflowTimestamp;
239
+ deferredEntries = entryTimestamps.length - maxEntries;
240
+ log.info(
241
+ {
242
+ bufferEntries: entryTimestamps.length,
243
+ maxEntries,
244
+ deferredEntries,
245
+ cutoff,
246
+ },
247
+ "consolidation chunked: buffer over per-run cap, overflow deferred to next pass",
248
+ );
249
+ }
250
+ }
251
+ }
252
+
193
253
  // Step 4: hand off to the centralized background-job runner. The runner
194
254
  // bootstraps the conversation, drives `processMessage`, applies the
195
255
  // timeout policy, classifies errors, and — because we opt out via
@@ -225,6 +285,7 @@ export async function memoryV2ConsolidateJob(
225
285
  jobName: JOB_NAME,
226
286
  source: MEMORY_V2_CONSOLIDATION_SOURCE,
227
287
  prompt,
288
+ systemHint: "Memory consolidation",
228
289
  trustContext: { sourceChannel: "vellum", trustClass: "guardian" },
229
290
  callSite: "memoryV2Consolidation",
230
291
  timeoutMs: CONSOLIDATION_TIMEOUT_MS,
@@ -272,6 +333,7 @@ export async function memoryV2ConsolidateJob(
272
333
  {
273
334
  conversationId: runResult.conversationId,
274
335
  cutoff,
336
+ deferredEntries,
275
337
  followUpJobIds,
276
338
  },
277
339
  "consolidation invoked",
@@ -280,6 +342,7 @@ export async function memoryV2ConsolidateJob(
280
342
  kind: "invoked",
281
343
  conversationId: runResult.conversationId,
282
344
  cutoff,
345
+ deferredEntries,
283
346
  followUpJobIds,
284
347
  };
285
348
  } finally {
@@ -300,6 +363,27 @@ function readBufferContent(bufferPath: string): string {
300
363
  }
301
364
  }
302
365
 
366
+ /**
367
+ * Extract the bracketed timestamp from a `buffer.md` entry line
368
+ * (`- [Mon D, h:mm AM/PM] …`, see `formatBufferEntry`). Returned verbatim so
369
+ * it can serve directly as a consolidation cutoff — both sides of the agent's
370
+ * "timestamp ≥ cutoff" comparison then share the exact `formatBufferTimestamp`
371
+ * shape.
372
+ *
373
+ * The bracket contents must match that shape exactly: a remembered fact's
374
+ * continuation lines can themselves start with `- [` (markdown checklists
375
+ * `- [ ] …`, wikilink bullets `- [[…]]`), and counting those as entries
376
+ * would inflate the per-run budget — or worse, hand the agent a garbage
377
+ * cutoff like a blank string. Returns `null` for anything that isn't a real
378
+ * timestamped entry start.
379
+ */
380
+ function extractBufferEntryTimestamp(line: string): string | null {
381
+ const match = /^\s*-\s*\[([A-Z][a-z]{2} \d{1,2}, \d{1,2}:\d{2} [AP]M)\]/.exec(
382
+ line,
383
+ );
384
+ return match ? match[1] : null;
385
+ }
386
+
303
387
  /**
304
388
  * Count non-empty lines in `memory/buffer.md`. Used by the scheduler to
305
389
  * implement the size-based consolidation trigger. Missing file → 0.
@@ -44,6 +44,7 @@ import {
44
44
  } from "./cli-command-store.js";
45
45
  import { getEdgeIndex } from "./edge-index.js";
46
46
  import { recordInjectionEvents } from "./injection-events.js";
47
+ import { getPageIndex } from "./page-index.js";
47
48
  import { readPage, renderPageContent } from "./page-store.js";
48
49
  import { type RouterTurnPair, runRouter } from "./router.js";
49
50
  import { getSkillCapability, isSkillSlug } from "./skill-store.js";
@@ -553,6 +554,58 @@ async function injectViaRouter(args: {
553
554
  const priorEverInjected: readonly EverInjectedEntry[] =
554
555
  priorState?.everInjected ?? [];
555
556
 
557
+ // Saturated-index bypass: when every page in the index has already been
558
+ // injected on a prior turn of this conversation, the router LLM call is
559
+ // provably useless — whatever it selected would be deduped against
560
+ // `priorEverInjected` below and render nothing. Small workspaces reach
561
+ // this state within the first few turns, after which the per-message
562
+ // router call is pure cost. Skip the call and finalize directly with
563
+ // carry-over telemetry, mirroring the success path's "all selections
564
+ // already injected" outcome. (Trade-off: re-pick EMA events are not
565
+ // recorded from this state; with the whole index attached, injection-
566
+ // score ranking has nothing left to decide for this conversation.)
567
+ const everInjectedSlugSet = new Set(priorEverInjected.map((e) => e.slug));
568
+ const pageIndexForBypass = await getPageIndex(workspaceDir);
569
+ if (
570
+ pageIndexForBypass.entries.length > 0 &&
571
+ pageIndexForBypass.entries.every((e) => everInjectedSlugSet.has(e.slug))
572
+ ) {
573
+ log.info(
574
+ {
575
+ conversationId,
576
+ indexSize: pageIndexForBypass.entries.length,
577
+ },
578
+ "memory v2 router skipped — every index page already injected",
579
+ );
580
+ return finalizeInjection({
581
+ workspaceDir,
582
+ database,
583
+ conversationId,
584
+ mode: "router",
585
+ currentTurn,
586
+ messageId,
587
+ priorEverInjected,
588
+ slugsToRender: [],
589
+ telemetryRows: priorEverInjected.map((entry) => ({
590
+ slug: entry.slug,
591
+ finalActivation: 0,
592
+ ownActivation: 0,
593
+ priorActivation: 0,
594
+ simUser: 0,
595
+ simAssistant: 0,
596
+ simNow: 0,
597
+ simUserRerankBoost: 0,
598
+ simAssistantRerankBoost: 0,
599
+ inRerankPool: false,
600
+ spreadContribution: 0,
601
+ source: "carry_over",
602
+ status: "not_injected",
603
+ })),
604
+ config,
605
+ nextStateMap: {},
606
+ });
607
+ }
608
+
556
609
  const routerResult = await runRouter({
557
610
  workspaceDir,
558
611
  recentTurnPairs,
@@ -571,6 +571,7 @@ Bullets where a list is genuinely a list.
571
571
  - **\`links\`** are directed see-also references, each annotated: \`"target-slug — why"\`. The annotation is for future-you deciding whether to follow it. Directed means listing B here pulls B toward A's readers — it does not link back.
572
572
  - **\`tags\`** are flat labels for the cluster(s) this touches. Index pages also carry \`kind: index\`.
573
573
  - **No \`summary:\` field.** The lead is the summary. Writing a good lead IS writing the retrieval surface.
574
+ - **\`current:\`** — optional ONE-LINE live state of the page's subject: open items, deadlines, what's owed or pending, with an as-of date in the text (\`current: "bridge check owed before thursday's dry-run (as of jun 10)"\`). Retrieval renders it on the card, so status-shaped questions ("what's on my plate", "what's pending") can find the page. Maintain it like state, not prose: update it when the state moves, DELETE the field the moment nothing is live — a stale \`current:\` is worse than none. Most pages never carry one. \`threads.md\` remains the cross-page commitments list; \`current:\` is per-page state, not a second threads file.
574
575
 
575
576
  ## The card budget (the economic principle)
576
577
 
@@ -38,8 +38,17 @@ const MEMORY_V2_STATIC_BLOCKS: readonly MemoryV2StaticBlock[] = [
38
38
  * Build the v2 static memory block, gated on `config.memory.enabled` and
39
39
  * `config.memory.v2.enabled`. Empty/missing files are skipped; returns `null`
40
40
  * when either gate is off or every file is empty.
41
+ *
42
+ * `excludeBuffer` drops the `## Buffer` section. The consolidation run sets
43
+ * it: the agent's contract there is the `memory/buffer.md` FILE — it reads,
44
+ * routes, and rewrites it through file tools — so injecting a static snapshot
45
+ * of the same content would duplicate the entire (potentially unbounded)
46
+ * backlog into the turn's context and go stale the moment the agent edits
47
+ * the file.
41
48
  */
42
- export function readMemoryV2StaticContent(): string | null {
49
+ export function readMemoryV2StaticContent(
50
+ options: { excludeBuffer?: boolean } = {},
51
+ ): string | null {
43
52
  let config;
44
53
  try {
45
54
  config = loadConfig();
@@ -52,6 +61,9 @@ export function readMemoryV2StaticContent(): string | null {
52
61
 
53
62
  const sections: string[] = [];
54
63
  for (const { heading, file } of MEMORY_V2_STATIC_BLOCKS) {
64
+ if (options.excludeBuffer === true && file === "memory/buffer.md") {
65
+ continue;
66
+ }
55
67
  const content = readPromptFile(getWorkspacePromptPath(file));
56
68
  if (!content) continue;
57
69
  sections.push(`${heading}\n\n${content}`);
@@ -283,7 +283,7 @@ function loadRecentMessagesText(nowMs: number): string {
283
283
  // initial limit (1000) defends against pathological busy windows where a
284
284
  // naive scan would touch every recent message. Joining conversations and
285
285
  // excluding background/scheduled types keeps automation chatter
286
- // (heartbeats, filing, update bulletins, scheduled jobs) out of buffer.md.
286
+ // (heartbeats, filing, scheduled jobs) out of buffer.md.
287
287
  const rows = db
288
288
  .select({
289
289
  role: messages.role,
@@ -62,6 +62,11 @@ export const ConceptPageFrontmatterSchema = z
62
62
  main: z.string().optional(),
63
63
  kind: z.string().optional(),
64
64
  status: z.string().optional(),
65
+ // One-line live state of the page's subject (open items, deadlines,
66
+ // what's pending), as-of dated by convention. Memory-v3 renders it on the
67
+ // card surface so state-shaped questions can select the page. Distinct
68
+ // from `status:` (the article-model draft marker, e.g. "cc-draft").
69
+ current: z.string().optional(),
65
70
  })
66
71
  .strict();
67
72
 
@@ -314,6 +314,13 @@ export interface PostToolUseContext {
314
314
  * singular of Codex's `additional_contexts`. `null` means no extra context.
315
315
  */
316
316
  additionalContext: string | null;
317
+ /**
318
+ * Model id reported by the provider for the assistant turn that issued
319
+ * this tool call (e.g. `claude-opus-4-8`,
320
+ * `accounts/fireworks/models/kimi-k2p6`). Hooks use it to vary coaching by
321
+ * model family — some models need earlier or firmer steering than others.
322
+ */
323
+ readonly model: string;
317
324
  /**
318
325
  * The model's context-window size in tokens. Plugins derive their own
319
326
  * character budget from this (e.g. a share of the window) rather than