@vellumai/assistant 0.7.3 → 0.8.0

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 (169) hide show
  1. package/ARCHITECTURE.md +29 -28
  2. package/Dockerfile +1 -0
  3. package/__tests__/permissions/gateway-threshold-reader.test.ts +236 -9
  4. package/bun.lock +3 -0
  5. package/knip.json +1 -0
  6. package/node_modules/@vellumai/ipc-server-utils/bun.lock +24 -0
  7. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  8. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +6 -0
  9. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +430 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +221 -0
  11. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  12. package/openapi.yaml +22 -4
  13. package/package.json +3 -1
  14. package/src/__tests__/annotate-risk-options.test.ts +291 -0
  15. package/src/__tests__/approval-cascade.test.ts +8 -16
  16. package/src/__tests__/approval-routes-http.test.ts +6 -0
  17. package/src/__tests__/auto-analysis-end-to-end.test.ts +12 -25
  18. package/src/__tests__/call-constants.test.ts +10 -1
  19. package/src/__tests__/call-controller.test.ts +127 -0
  20. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +58 -28
  21. package/src/__tests__/config-loader-platform-defaults.test.ts +284 -1
  22. package/src/__tests__/context-search-memory-source.test.ts +3 -26
  23. package/src/__tests__/context-search-pkb-source.test.ts +12 -6
  24. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -6
  25. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  26. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
  27. package/src/__tests__/conversation-agent-loop.test.ts +3 -3
  28. package/src/__tests__/conversation-confirmation-signals.test.ts +5 -13
  29. package/src/__tests__/conversation-init.benchmark.test.ts +1 -1
  30. package/src/__tests__/conversation-process-callsite.test.ts +1 -6
  31. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -6
  32. package/src/__tests__/conversation-runtime-assembly.test.ts +15 -6
  33. package/src/__tests__/conversation-slash-unknown.test.ts +1 -6
  34. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +170 -9
  35. package/src/__tests__/conversation-surfaces-data-persist.test.ts +73 -1
  36. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +59 -0
  37. package/src/__tests__/conversation-workspace-injection.test.ts +1 -7
  38. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -7
  39. package/src/__tests__/filing-service.test.ts +2 -19
  40. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +10 -26
  41. package/src/__tests__/injector-chain.test.ts +24 -16
  42. package/src/__tests__/injector-pkb-v2-silenced.test.ts +10 -7
  43. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +154 -67
  44. package/src/__tests__/notification-decision-fallback.test.ts +91 -0
  45. package/src/__tests__/notification-decision-strategy.test.ts +22 -0
  46. package/src/__tests__/oauth-cli.test.ts +121 -0
  47. package/src/__tests__/relay-server.test.ts +46 -2
  48. package/src/__tests__/secret-prompt-log-hygiene.test.ts +7 -5
  49. package/src/__tests__/secret-prompter-channel-fallback.test.ts +7 -5
  50. package/src/__tests__/secret-response-routing.test.ts +7 -5
  51. package/src/__tests__/server-history-render.test.ts +82 -0
  52. package/src/__tests__/skill-include-graph.test.ts +31 -0
  53. package/src/__tests__/skill-load-tool.test.ts +44 -16
  54. package/src/__tests__/skills.test.ts +39 -0
  55. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -42
  56. package/src/__tests__/tool-executor.test.ts +155 -0
  57. package/src/__tests__/voice-session-bridge.test.ts +3 -0
  58. package/src/__tests__/workspace-migration-069-seed-onboarding-threads.test.ts +120 -0
  59. package/src/__tests__/workspace-migration-071-remove-safe-storage-release-note.test.ts +206 -0
  60. package/src/__tests__/workspace-migration-safe-storage-limits-release.test.ts +15 -27
  61. package/src/agent/loop.ts +11 -0
  62. package/src/approvals/guardian-decision-primitive.ts +0 -13
  63. package/src/approvals/guardian-request-resolvers.ts +4 -32
  64. package/src/calls/call-constants.ts +5 -8
  65. package/src/calls/call-controller.ts +130 -67
  66. package/src/calls/relay-server.ts +7 -1
  67. package/src/calls/voice-session-bridge.ts +1 -1
  68. package/src/cli/commands/memory-v2.ts +7 -7
  69. package/src/cli/commands/oauth/__tests__/connect.test.ts +0 -254
  70. package/src/cli/commands/oauth/connect.ts +10 -52
  71. package/src/config/bundled-skills/app-builder/SKILL.md +1 -3
  72. package/src/config/feature-flag-registry.json +1 -17
  73. package/src/config/loader.ts +72 -19
  74. package/src/config/schemas/memory-v2.ts +1 -1
  75. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +32 -0
  76. package/src/daemon/conversation-agent-loop-handlers.ts +32 -0
  77. package/src/daemon/conversation-agent-loop.ts +13 -10
  78. package/src/daemon/conversation-lifecycle.ts +22 -8
  79. package/src/daemon/conversation-surfaces.ts +16 -14
  80. package/src/daemon/conversation-tool-setup.ts +9 -5
  81. package/src/daemon/conversation.ts +1 -1
  82. package/src/daemon/handlers/shared.ts +26 -0
  83. package/src/daemon/host-bash-proxy.ts +1 -1
  84. package/src/daemon/host-browser-proxy.ts +1 -1
  85. package/src/daemon/host-cu-proxy.ts +1 -1
  86. package/src/daemon/host-file-proxy.ts +1 -1
  87. package/src/daemon/host-transfer-proxy.ts +2 -2
  88. package/src/daemon/lifecycle.ts +88 -73
  89. package/src/daemon/memory-v2-startup.ts +55 -14
  90. package/src/daemon/message-types/messages.ts +19 -1
  91. package/src/documents/document-store.ts +35 -1
  92. package/src/filing/filing-service.ts +2 -3
  93. package/src/heartbeat/heartbeat-service.ts +1 -1
  94. package/src/ipc/assistant-server.ts +93 -36
  95. package/src/ipc/skill-server.ts +99 -42
  96. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +10 -57
  97. package/src/memory/context-search/sources/memory-v2.ts +1 -17
  98. package/src/memory/context-search/sources/memory.ts +2 -2
  99. package/src/memory/context-search/sources/pkb.ts +2 -3
  100. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +104 -61
  101. package/src/memory/graph/__tests__/handle-remember-v2.test.ts +11 -26
  102. package/src/memory/graph/conversation-graph-memory.ts +32 -9
  103. package/src/memory/graph/graph-search.test.ts +6 -5
  104. package/src/memory/graph/graph-search.ts +3 -4
  105. package/src/memory/graph/retriever.test.ts +12 -7
  106. package/src/memory/graph/retriever.ts +4 -5
  107. package/src/memory/graph/tool-handlers.ts +3 -4
  108. package/src/memory/graph/tools.ts +4 -4
  109. package/src/memory/indexer.ts +1 -2
  110. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +116 -0
  111. package/src/memory/jobs/embed-concept-page.ts +223 -87
  112. package/src/memory/jobs-worker.ts +8 -4
  113. package/src/memory/pkb/pkb-search.test.ts +6 -5
  114. package/src/memory/pkb/pkb-search.ts +4 -5
  115. package/src/memory/qdrant-client.ts +3 -0
  116. package/src/memory/search/semantic.ts +4 -5
  117. package/src/memory/v2/__tests__/activation.test.ts +35 -5
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +21 -32
  119. package/src/memory/v2/__tests__/injection.test.ts +140 -23
  120. package/src/memory/v2/__tests__/qdrant.test.ts +310 -9
  121. package/src/memory/v2/__tests__/sim.test.ts +118 -7
  122. package/src/memory/v2/__tests__/static-context.test.ts +1 -13
  123. package/src/memory/v2/__tests__/sweep-job.test.ts +19 -33
  124. package/src/memory/v2/consolidation-job.ts +7 -8
  125. package/src/memory/v2/injection.ts +32 -12
  126. package/src/memory/v2/page-store.ts +39 -0
  127. package/src/memory/v2/prompts/consolidation.ts +5 -0
  128. package/src/memory/v2/qdrant.ts +209 -48
  129. package/src/memory/v2/sim.ts +67 -26
  130. package/src/memory/v2/static-context.ts +4 -8
  131. package/src/memory/v2/sweep-job.ts +5 -6
  132. package/src/memory/v2/types.ts +7 -0
  133. package/src/notifications/copy-composer.ts +46 -12
  134. package/src/notifications/decision-engine.ts +46 -0
  135. package/src/permissions/gateway-threshold-reader.ts +116 -8
  136. package/src/permissions/prompter.ts +86 -96
  137. package/src/permissions/secret-prompter.ts +31 -31
  138. package/src/plugins/defaults/injectors.ts +1 -2
  139. package/src/proactive-artifact/job.test.ts +51 -4
  140. package/src/proactive-artifact/job.ts +16 -2
  141. package/src/proactive-artifact/message-copy.ts +18 -1
  142. package/src/prompts/templates/SOUL.md +13 -28
  143. package/src/runtime/auth/route-policy.ts +1 -0
  144. package/src/runtime/channel-approvals.ts +3 -2
  145. package/src/runtime/guardian-reply-router.ts +0 -10
  146. package/src/runtime/pending-interactions.ts +19 -15
  147. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +147 -0
  148. package/src/runtime/routes/approval-routes.ts +7 -3
  149. package/src/runtime/routes/consolidation-routes.ts +8 -9
  150. package/src/runtime/routes/conversation-query-routes.ts +44 -1
  151. package/src/runtime/routes/debug-bash-routes.ts +2 -0
  152. package/src/runtime/routes/filing-routes.ts +2 -3
  153. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +0 -3
  154. package/src/runtime/routes/memory-item-routes.test.ts +3 -9
  155. package/src/runtime/routes/memory-item-routes.ts +5 -6
  156. package/src/runtime/routes/memory-v2-routes.ts +103 -17
  157. package/src/skills/include-graph.ts +35 -13
  158. package/src/tools/document/document-tool.ts +20 -0
  159. package/src/tools/executor.ts +18 -2
  160. package/src/tools/memory/register.test.ts +7 -5
  161. package/src/tools/permission-checker.ts +15 -0
  162. package/src/tools/skills/load.ts +24 -20
  163. package/src/tools/tool-name-aliases.ts +19 -0
  164. package/src/tools/types.ts +19 -1
  165. package/src/workspace/migrations/067-release-notes-safe-storage-limits.ts +4 -62
  166. package/src/workspace/migrations/069-seed-onboarding-threads.ts +28 -0
  167. package/src/workspace/migrations/070-memory-v2-summary-schema-rebuild.ts +31 -0
  168. package/src/workspace/migrations/071-remove-safe-storage-release-note.ts +111 -0
  169. package/src/workspace/migrations/registry.ts +6 -0
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * Tests for the v2 routing wired into `ConversationGraphMemory.prepareMemory`.
3
3
  *
4
- * The wiring layer at `conversation-graph-memory.ts` reads two signals to
5
- * decide whether to swap v1's injection step for the v2 activation pipeline:
4
+ * The wiring layer at `conversation-graph-memory.ts` reads
5
+ * `config.memory.v2.enabled` to decide whether to swap v1's injection step
6
+ * for the v2 activation pipeline.
6
7
  *
7
- * 1. The `memory-v2-enabled` feature flag (`isAssistantFeatureFlagEnabled`).
8
- * 2. The workspace config value `memory.v2.enabled`.
9
- *
10
- * Both must be true for v2 to take over. This file uses the *real*
11
- * `injectMemoryV2Block` and stubs only the lower-level deps (Qdrant client,
12
- * embedding backend) the way `memory/v2/__tests__/injection.test.ts` does
13
- * mocking `injection.js` itself would clobber that sibling test when both
14
- * files run in the same `bun test` invocation, since `mock.module` is
15
- * process-global. Avoiding the mock keeps the suite hermetic in either order.
8
+ * This file uses the *real* `injectMemoryV2Block` and stubs only the
9
+ * lower-level deps (Qdrant client, embedding backend) the way
10
+ * `memory/v2/__tests__/injection.test.ts` does — mocking `injection.js`
11
+ * itself would clobber that sibling test when both files run in the same
12
+ * `bun test` invocation, since `mock.module` is process-global. Avoiding
13
+ * the mock keeps the suite hermetic in either order.
16
14
  */
17
15
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
16
  import { tmpdir } from "node:os";
@@ -20,7 +18,6 @@ import { join } from "node:path";
20
18
  import { Database } from "bun:sqlite";
21
19
  import {
22
20
  afterAll,
23
- afterEach,
24
21
  beforeAll,
25
22
  beforeEach,
26
23
  describe,
@@ -98,9 +95,11 @@ class MockQdrantClient {
98
95
  _name: string,
99
96
  params: { using: string; limit: number; filter?: unknown },
100
97
  ) {
101
- const queue =
102
- qdrantState.queryResponses[params.using as "dense" | "sparse"];
103
- return queue.shift() ?? { points: [] };
98
+ // The four-channel hybrid query fires body-dense, body-sparse,
99
+ // summary-dense, summary-sparse in order; both dense channels share
100
+ // the dense queue and both sparse channels share the sparse queue.
101
+ const channel = params.using.endsWith("sparse") ? "sparse" : "dense";
102
+ return qdrantState.queryResponses[channel].shift() ?? { points: [] };
104
103
  }
105
104
  }
106
105
 
@@ -170,14 +169,14 @@ import type { DrizzleDb } from "../../db-connection.js";
170
169
 
171
170
  const { ConversationGraphMemory } =
172
171
  await import("../conversation-graph-memory.js");
173
- const { _setOverridesForTesting } =
174
- await import("../../../config/assistant-feature-flags.js");
175
172
  const { applyNestedDefaults } = await import("../../../config/loader.js");
176
173
  const { getSqliteFrom } = await import("../../db-connection.js");
177
174
  const { migrateActivationState } =
178
175
  await import("../../migrations/232-activation-state.js");
179
176
  const schema = await import("../../schema.js");
180
177
  const { _resetMemoryV2QdrantForTests } = await import("../../v2/qdrant.js");
178
+ const { hydrate: hydrateActivationState } =
179
+ await import("../../v2/activation-store.js");
181
180
 
182
181
  // The wiring layer calls `getDb()` to fetch the SQLite handle. We mock
183
182
  // only that one export and spread the real module so unrelated callers
@@ -240,10 +239,21 @@ function makeMemory(): InstanceType<typeof ConversationGraphMemory> {
240
239
  return m;
241
240
  }
242
241
 
243
- /** Stage one set of dense/sparse hits for each channel of the activation
244
- * pipeline (1 candidate query + 3 simBatch channels). */
242
+ /** Stage one set of body and summary dense/sparse hits for each channel of
243
+ * the activation pipeline (1 candidate query + 3 simBatch channels). Each
244
+ * `hybridQueryConceptPages` call now fires four sub-queries (body-dense,
245
+ * body-sparse, summary-dense, summary-sparse) so we push four entries per
246
+ * channel iteration. Hits without `summary*Score` set produce empty point
247
+ * lists for the summary channels — fine for tests that only care about body
248
+ * scoring. */
245
249
  function stageTurn(
246
- hits: Array<{ slug: string; denseScore?: number; sparseScore?: number }>,
250
+ hits: Array<{
251
+ slug: string;
252
+ denseScore?: number;
253
+ sparseScore?: number;
254
+ summaryDenseScore?: number;
255
+ summarySparseScore?: number;
256
+ }>,
247
257
  ): void {
248
258
  for (let i = 0; i < 4; i++) {
249
259
  qdrantState.queryResponses.dense.push({
@@ -256,6 +266,22 @@ function stageTurn(
256
266
  .filter((h) => h.sparseScore !== undefined)
257
267
  .map((h) => ({ score: h.sparseScore, payload: { slug: h.slug } })),
258
268
  });
269
+ qdrantState.queryResponses.dense.push({
270
+ points: hits
271
+ .filter((h) => h.summaryDenseScore !== undefined)
272
+ .map((h) => ({
273
+ score: h.summaryDenseScore,
274
+ payload: { slug: h.slug },
275
+ })),
276
+ });
277
+ qdrantState.queryResponses.sparse.push({
278
+ points: hits
279
+ .filter((h) => h.summarySparseScore !== undefined)
280
+ .map((h) => ({
281
+ score: h.summarySparseScore,
282
+ payload: { slug: h.slug },
283
+ })),
284
+ });
259
285
  }
260
286
  }
261
287
 
@@ -270,39 +296,12 @@ beforeEach(() => {
270
296
  _resetMemoryV2QdrantForTests();
271
297
  });
272
298
 
273
- afterEach(() => {
274
- _setOverridesForTesting({});
275
- });
276
-
277
299
  // ---------------------------------------------------------------------------
278
300
  // Tests
279
301
  // ---------------------------------------------------------------------------
280
302
 
281
303
  describe("ConversationGraphMemory.prepareMemory — v2 routing (per-turn path)", () => {
282
- test("flag off → v2 not run, messages unchanged", async () => {
283
- _setOverridesForTesting({ "memory-v2-enabled": false });
284
- stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
285
-
286
- const memory = makeMemory();
287
- const config = makeConfig(true);
288
- const messages = makeMessages();
289
-
290
- const result = await memory.prepareMemory(
291
- messages,
292
- config,
293
- new AbortController().signal,
294
- noopEvent,
295
- );
296
-
297
- expect(result.mode).toBe("per-turn");
298
- expect(result.injectedBlockText).toBeNull();
299
- // No v2 block prepended — the v1 retriever returned zero nodes so the
300
- // user message is exactly the input.
301
- expect(result.runMessages).toEqual(messages);
302
- });
303
-
304
- test("flag on + config off → v2 not run, messages unchanged", async () => {
305
- _setOverridesForTesting({ "memory-v2-enabled": true });
304
+ test("config off → v2 not run, messages unchanged", async () => {
306
305
  stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
307
306
 
308
307
  const memory = makeMemory();
@@ -321,8 +320,7 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (per-turn path)",
321
320
  expect(result.runMessages).toEqual(messages);
322
321
  });
323
322
 
324
- test("flag on + config on → v2 block prepended, mode is per-turn", async () => {
325
- _setOverridesForTesting({ "memory-v2-enabled": true });
323
+ test("config on → v2 block prepended, mode is per-turn", async () => {
326
324
  stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
327
325
 
328
326
  const memory = makeMemory();
@@ -339,7 +337,9 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (per-turn path)",
339
337
  expect(result.mode).toBe("per-turn");
340
338
  expect(result.injectedBlockText).not.toBeNull();
341
339
  expect(result.injectedBlockText).not.toContain("<memory>");
342
- expect(result.injectedBlockText).toContain("### alice-vscode");
340
+ expect(result.injectedBlockText).toContain(
341
+ "# memory/concepts/alice-vscode.md",
342
+ );
343
343
 
344
344
  // The leading content block on the user message is the v2 block,
345
345
  // wrapped exactly once.
@@ -361,7 +361,6 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (per-turn path)",
361
361
  // Regression for the double-wrap bug: v2 cached `lastInjectedBlock`
362
362
  // already wrapped, then `reinjectCachedMemory` re-wrapped via
363
363
  // `injectTextBlock`, producing `<memory>\n<memory>\n...\n</memory>\n</memory>`.
364
- _setOverridesForTesting({ "memory-v2-enabled": true });
365
364
  stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
366
365
 
367
366
  const memory = makeMemory();
@@ -388,11 +387,10 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (per-turn path)",
388
387
  expect(firstBlock.text.endsWith("\n</memory>")).toBe(true);
389
388
  expect(firstBlock.text.match(/<memory>/g)?.length).toBe(1);
390
389
  expect(firstBlock.text.match(/<\/memory>/g)?.length).toBe(1);
391
- expect(firstBlock.text).toContain("### alice-vscode");
390
+ expect(firstBlock.text).toContain("# memory/concepts/alice-vscode.md");
392
391
  });
393
392
 
394
- test("flag on + config on with empty Qdrant hits → no v2 block, v1 fallback skipped", async () => {
395
- _setOverridesForTesting({ "memory-v2-enabled": true });
393
+ test("config on with empty Qdrant hits → no v2 block, v1 fallback skipped", async () => {
396
394
  // No `stageTurn` call — every channel returns `{ points: [] }` so the
397
395
  // candidate set is empty and `injectMemoryV2Block` returns block=null.
398
396
  const memory = makeMemory();
@@ -412,8 +410,7 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (per-turn path)",
412
410
  });
413
411
 
414
412
  describe("ConversationGraphMemory.prepareMemory — v2 routing (context-load path)", () => {
415
- test("flag on + config on → v2 fires with mode=context-load", async () => {
416
- _setOverridesForTesting({ "memory-v2-enabled": true });
413
+ test("config on → v2 fires with mode=context-load", async () => {
417
414
  stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
418
415
 
419
416
  // Fresh memory → initialized=false → runContextLoad branch.
@@ -430,7 +427,9 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (context-load pat
430
427
 
431
428
  expect(result.mode).toBe("context-load");
432
429
  expect(result.injectedBlockText).not.toBeNull();
433
- expect(result.injectedBlockText).toContain("### alice-vscode");
430
+ expect(result.injectedBlockText).toContain(
431
+ "# memory/concepts/alice-vscode.md",
432
+ );
434
433
  // injectedBlockText is the unwrapped inner content; the wrapper is
435
434
  // applied at injection time on the run message.
436
435
  expect(result.injectedBlockText).not.toContain("<memory>");
@@ -443,12 +442,11 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (context-load pat
443
442
  expect(loadContextMemoryMock).not.toHaveBeenCalled();
444
443
  });
445
444
 
446
- test("flag off → v2 not run on first turn either", async () => {
447
- _setOverridesForTesting({ "memory-v2-enabled": false });
445
+ test("config off → v2 not run on first turn either", async () => {
448
446
  stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
449
447
 
450
448
  const memory = new ConversationGraphMemory("conv-test-cl-off");
451
- const config = makeConfig(true);
449
+ const config = makeConfig(false);
452
450
  const messages = makeMessages("first message of the conversation here");
453
451
 
454
452
  const result = await memory.prepareMemory(
@@ -462,3 +460,48 @@ describe("ConversationGraphMemory.prepareMemory — v2 routing (context-load pat
462
460
  expect(result.injectedBlockText).toBeNull();
463
461
  });
464
462
  });
463
+
464
+ describe("ConversationGraphMemory.onCompacted — v2 activation eviction", () => {
465
+ test("clears everInjected so a previously-injected slug can re-attach", async () => {
466
+ // Without this wiring, `selectInjections` keeps subtracting the slug from
467
+ // every per-turn delta even though compaction discarded the cached
468
+ // `<memory>` attachment that previously made it visible.
469
+ const conversationId = "conv-test-evict";
470
+ const memory = new ConversationGraphMemory(conversationId);
471
+ const config = makeConfig(true);
472
+
473
+ // Turn 1 — context-load fires (initialized=false), injecting alice-vscode.
474
+ stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
475
+ const initial = await memory.prepareMemory(
476
+ makeMessages("Tell me about Alice's editor preferences"),
477
+ config,
478
+ new AbortController().signal,
479
+ noopEvent,
480
+ );
481
+ expect(initial.injectedBlockText).toContain(
482
+ "# memory/concepts/alice-vscode.md",
483
+ );
484
+
485
+ const before = await hydrateActivationState(testDbHandle!, conversationId);
486
+ expect(before?.everInjected.map((e) => e.slug)).toContain("alice-vscode");
487
+
488
+ await memory.onCompacted(1);
489
+
490
+ const after = await hydrateActivationState(testDbHandle!, conversationId);
491
+ expect(after?.everInjected).toEqual([]);
492
+
493
+ // Turn 2 — same Qdrant relevance. With everInjected cleared the slug
494
+ // should appear again in the injection block (re-attached on the new
495
+ // user message after compaction).
496
+ stageTurn([{ slug: "alice-vscode", denseScore: 0.9 }]);
497
+ const next = await memory.prepareMemory(
498
+ makeMessages("And what about Alice's editor again?"),
499
+ config,
500
+ new AbortController().signal,
501
+ noopEvent,
502
+ );
503
+ expect(next.injectedBlockText).toContain(
504
+ "# memory/concepts/alice-vscode.md",
505
+ );
506
+ });
507
+ });
@@ -1,19 +1,14 @@
1
1
  /**
2
- * Tests for `handleRemember` flag-routing between v1 (PKB) and v2 (memory/).
2
+ * Tests for `handleRemember` routing between v1 (PKB) and v2 (memory/).
3
3
  *
4
- * Verifies:
5
- * - Flag on → writes go to `memory/buffer.md` + `memory/archive/<today>.md`
6
- * and NO PKB re-index job is enqueued.
7
- * - Flag off → existing PKB path is unchanged (writes to `pkb/buffer.md`
8
- * + `pkb/archive/<today>.md`, both files are re-indexed).
9
- * - Archive filename uses today's local date.
4
+ * Routing follows `config.memory.v2.enabled`: when true, writes go to
5
+ * memory/; otherwise they fall back to v1 PKB.
10
6
  */
11
7
  import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
12
8
  import { tmpdir } from "node:os";
13
9
  import { join } from "node:path";
14
10
  import {
15
11
  afterAll,
16
- afterEach,
17
12
  beforeAll,
18
13
  beforeEach,
19
14
  describe,
@@ -62,11 +57,13 @@ afterAll(() => {
62
57
  // Imports are deferred to after the env var is set so any internal use of
63
58
  // `getWorkspaceDir()` resolves to the tmpdir.
64
59
  const { handleRemember } = await import("../tool-handlers.js");
65
- const { _setOverridesForTesting } =
66
- await import("../../../config/assistant-feature-flags.js");
67
60
  const { applyNestedDefaults } = await import("../../../config/loader.js");
68
61
 
69
62
  const CONFIG = applyNestedDefaults({});
63
+ const CONFIG_V2_OFF = {
64
+ ...CONFIG,
65
+ memory: { ...CONFIG.memory, v2: { ...CONFIG.memory.v2, enabled: false } },
66
+ };
70
67
 
71
68
  beforeEach(() => {
72
69
  enqueueCalls.length = 0;
@@ -76,10 +73,6 @@ beforeEach(() => {
76
73
  rmSync(join(tmpWorkspace, "memory"), { recursive: true, force: true });
77
74
  });
78
75
 
79
- afterEach(() => {
80
- _setOverridesForTesting({});
81
- });
82
-
83
76
  function todaysArchiveBasename(now: Date = new Date()): string {
84
77
  const yyyy = now.getFullYear();
85
78
  const mm = String(now.getMonth() + 1).padStart(2, "0");
@@ -87,11 +80,7 @@ function todaysArchiveBasename(now: Date = new Date()): string {
87
80
  return `${yyyy}-${mm}-${dd}.md`;
88
81
  }
89
82
 
90
- describe("handleRemember — memory-v2 flag on", () => {
91
- beforeEach(() => {
92
- _setOverridesForTesting({ "memory-v2-enabled": true });
93
- });
94
-
83
+ describe("handleRemember — memory.v2.enabled on", () => {
95
84
  test("writes to memory/buffer.md and memory/archive/<today>.md", () => {
96
85
  const result = handleRemember(
97
86
  { content: "Alice prefers VS Code over Vim" },
@@ -175,17 +164,13 @@ describe("handleRemember — memory-v2 flag on", () => {
175
164
  });
176
165
  });
177
166
 
178
- describe("handleRemember — memory-v2 flag off (v1 PKB path)", () => {
179
- beforeEach(() => {
180
- _setOverridesForTesting({ "memory-v2-enabled": false });
181
- });
182
-
167
+ describe("handleRemember — memory.v2.enabled off (v1 PKB path)", () => {
183
168
  test("writes to pkb/buffer.md and pkb/archive/<today>.md", () => {
184
169
  const result = handleRemember(
185
170
  { content: "v1 path still works" },
186
171
  "conv-v1-1",
187
172
  "default",
188
- CONFIG,
173
+ CONFIG_V2_OFF,
189
174
  );
190
175
 
191
176
  expect(result.success).toBe(true);
@@ -206,7 +191,7 @@ describe("handleRemember — memory-v2 flag off (v1 PKB path)", () => {
206
191
  { content: "index me" },
207
192
  "conv-v1-2",
208
193
  "default",
209
- CONFIG,
194
+ CONFIG_V2_OFF,
210
195
  );
211
196
 
212
197
  expect(result.success).toBe(true);
@@ -8,7 +8,6 @@
8
8
 
9
9
  import { and, desc, eq, inArray, ne, notInArray } from "drizzle-orm";
10
10
 
11
- import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
12
11
  import type { AssistantConfig } from "../../config/types.js";
13
12
  import { estimateTextTokens } from "../../context/token-estimator.js";
14
13
  import type { ServerMessage } from "../../daemon/message-protocol.js";
@@ -23,6 +22,11 @@ import { getDb } from "../db-connection.js";
23
22
  import type { QdrantSparseVector } from "../qdrant-client.js";
24
23
  import { memorySummaries } from "../schema.js";
25
24
  import { conversations } from "../schema/conversations.js";
25
+ import {
26
+ evictCompactedTurns as evictCompactedTurnsV2,
27
+ hydrate as hydrateV2State,
28
+ save as saveV2State,
29
+ } from "../v2/activation-store.js";
26
30
  import {
27
31
  injectMemoryV2Block,
28
32
  type InjectMemoryV2Mode,
@@ -206,11 +210,33 @@ export class ConversationGraphMemory {
206
210
  * Notify that context compaction just happened.
207
211
  * On the next turn, we'll re-run full context load.
208
212
  */
209
- onCompacted(compactedMessageCount: number): void {
213
+ async onCompacted(compactedMessageCount: number): Promise<void> {
210
214
  // Evict everything — compaction summarized all prior turns.
211
215
  // The tracker can't know exactly which turns were compacted,
212
216
  // so we conservatively clear everything and reload.
213
- this.tracker.evictCompactedTurns(this.tracker.getTurn());
217
+ const upToTurn = this.tracker.getTurn();
218
+ this.tracker.evictCompactedTurns(upToTurn);
219
+
220
+ // Mirror the eviction on the v2 activation row: the cached `<memory>`
221
+ // attachments those slugs lived on are gone, but `everInjected` would
222
+ // otherwise keep them deduped from per-turn deltas forever.
223
+ try {
224
+ const db = getDb();
225
+ const state = await hydrateV2State(db, this.conversationId);
226
+ if (state) {
227
+ await saveV2State(
228
+ db,
229
+ this.conversationId,
230
+ evictCompactedTurnsV2(state, upToTurn),
231
+ );
232
+ }
233
+ } catch (err) {
234
+ log.warn(
235
+ { err: err instanceof Error ? err.message : String(err) },
236
+ "Failed to evict v2 activation state on compaction (non-fatal)",
237
+ );
238
+ }
239
+
214
240
  this.needsReload = true;
215
241
  log.info(
216
242
  { compactedMessageCount },
@@ -627,8 +653,8 @@ export class ConversationGraphMemory {
627
653
  }
628
654
 
629
655
  /**
630
- * Run the v2 activation pipeline when the `memory-v2-enabled` feature flag
631
- * *and* the workspace config (`memory.v2.enabled`) are both on.
656
+ * Run the v2 activation pipeline when the workspace config
657
+ * (`memory.v2.enabled`) is on.
632
658
  *
633
659
  * The two outcomes the caller distinguishes via `routed`:
634
660
  * - `routed: false` — v2 disabled; caller falls through to the legacy v1
@@ -650,10 +676,7 @@ export class ConversationGraphMemory {
650
676
  runMessages: Message[];
651
677
  injectedBlockText: string | null;
652
678
  }> {
653
- if (
654
- !isAssistantFeatureFlagEnabled("memory-v2-enabled", config) ||
655
- !config.memory.v2.enabled
656
- ) {
679
+ if (!config.memory.v2.enabled) {
657
680
  return { routed: false, runMessages: messages, injectedBlockText: null };
658
681
  }
659
682
 
@@ -1,12 +1,13 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import { makeMockLogger } from "../../__tests__/helpers/mock-logger.js";
4
- import { _setOverridesForTesting } from "../../config/assistant-feature-flags.js";
5
4
 
6
- // This test exercises the v1 graph search path. The `memory-v2-enabled` flag
7
- // (registry default `true`) makes graph-search short-circuit to keep traffic
8
- // off the legacy collection — disable it so the v1 path stays under test.
9
- _setOverridesForTesting({ "memory-v2-enabled": false });
5
+ // This test exercises the v1 graph search path. `config.memory.v2.enabled`
6
+ // (default `true`) makes graph-search short-circuit to keep traffic off
7
+ // the legacy collection — force it off so the v1 path stays under test.
8
+ mock.module("../../config/loader.js", () => ({
9
+ getConfig: () => ({ memory: { v2: { enabled: false } } }),
10
+ }));
10
11
 
11
12
  mock.module("../../util/logger.js", () => ({
12
13
  getLogger: () => makeMockLogger(),
@@ -5,7 +5,6 @@
5
5
  import { getConfig } from "../../config/loader.js";
6
6
  import type { AssistantConfig } from "../../config/types.js";
7
7
  import { getLogger } from "../../util/logger.js";
8
- import { isMemoryV2ReadActive } from "../context-search/sources/memory-v2.js";
9
8
  import { selectedBackendSupportsMultimodal } from "../embedding-backend.js";
10
9
  import type { EmbeddingInput } from "../embedding-types.js";
11
10
  import { embedAndUpsert } from "../job-utils.js";
@@ -46,11 +45,11 @@ export async function searchGraphNodes(
46
45
  sparseVector?: QdrantSparseVector,
47
46
  dateRange?: { afterMs?: number; beforeMs?: number },
48
47
  ): Promise<GraphSearchResult[]> {
49
- // v2 owns the read path when both gates are on. The v1 `memory` collection
50
- // is in active retirement and a corrupted sparse segment can OOM-crash the
48
+ // v2 owns the read path when enabled. The v1 `memory` collection is in
49
+ // active retirement and a corrupted sparse segment can OOM-crash the
51
50
  // shared Qdrant process — short-circuiting here keeps v1 background work
52
51
  // and stale callers from taking v2 down with them.
53
- if (isMemoryV2ReadActive(getConfig())) return [];
52
+ if (getConfig().memory.v2.enabled) return [];
54
53
 
55
54
  if (isQdrantBreakerOpen()) {
56
55
  log.warn("Qdrant circuit breaker open, skipping graph search");
@@ -71,7 +71,6 @@ mock.module("../../providers/provider-send-message.js", () => ({
71
71
  extractToolUse: () => null,
72
72
  }));
73
73
 
74
- import { _setOverridesForTesting } from "../../config/assistant-feature-flags.js";
75
74
  import { DEFAULT_CONFIG } from "../../config/defaults.js";
76
75
  import type { AssistantConfig } from "../../config/types.js";
77
76
  import { resetDb } from "../db-connection.js";
@@ -82,12 +81,16 @@ import { loadContextMemory, retrieveForTurn } from "./retriever.js";
82
81
  import { createNode } from "./store.js";
83
82
  import type { NewNode } from "./types.js";
84
83
 
85
- // These tests exercise v1 retrieval. v2 takeover (both `memory-v2-enabled`
86
- // flag *and* `memory.v2.enabled` schema field) makes `loadContextMemory`
87
- // short-circuit, so disable the flag here to keep the v1 path under test.
88
- _setOverridesForTesting({ "memory-v2-enabled": false });
89
-
90
- const TEST_CONFIG: AssistantConfig = { ...DEFAULT_CONFIG };
84
+ // These tests exercise v1 retrieval. `memory.v2.enabled` (default `true`)
85
+ // makes `loadContextMemory` short-circuit, so disable it here to keep the
86
+ // v1 path under test.
87
+ const TEST_CONFIG: AssistantConfig = {
88
+ ...DEFAULT_CONFIG,
89
+ memory: {
90
+ ...DEFAULT_CONFIG.memory,
91
+ v2: { ...DEFAULT_CONFIG.memory.v2, enabled: false },
92
+ },
93
+ };
91
94
 
92
95
  function makeCapabilityNode(content: string, capId: string): NewNode {
93
96
  const now = Date.now();
@@ -407,8 +410,10 @@ describe("loadContextMemory — dual-query capability ranking", () => {
407
410
 
408
411
  // Build a config where capabilityReserve=1 so the ranking code actually
409
412
  // prunes (it only prunes when capabilityNodes.length > capabilityReserve).
413
+ // memory.v2.enabled=false to keep the v1 retrieval path under test.
410
414
  const DUAL_QUERY_CONFIG: AssistantConfig = structuredClone(DEFAULT_CONFIG);
411
415
  DUAL_QUERY_CONFIG.memory.retrieval.injection.contextLoad.capabilityReserve = 1;
416
+ DUAL_QUERY_CONFIG.memory.v2.enabled = false;
412
417
 
413
418
  // Keyword-routed embed: any text that contains a topic keyword returns a
414
419
  // one-hot vector identifying that topic. Anything else falls back to a
@@ -14,7 +14,6 @@ import {
14
14
  } from "../../providers/provider-send-message.js";
15
15
  import type { ContentBlock, ImageContent } from "../../providers/types.js";
16
16
  import { getLogger } from "../../util/logger.js";
17
- import { isMemoryV2ReadActive } from "../context-search/sources/memory-v2.js";
18
17
  import { embedWithRetry } from "../embed.js";
19
18
  import {
20
19
  generateSparseEmbedding,
@@ -426,12 +425,12 @@ interface ContextLoadResult {
426
425
  export async function loadContextMemory(
427
426
  opts: ContextLoadOpts,
428
427
  ): Promise<ContextLoadResult> {
429
- // v2 owns the read path when both gates are on. The v1 collection is in
430
- // active retirement and querying it can OOM-crash Qdrant via a corrupted
431
- // sparse segment, so we skip the embedding work and downstream searches
428
+ // v2 owns the read path when enabled. The v1 collection is in active
429
+ // retirement and querying it can OOM-crash Qdrant via a corrupted sparse
430
+ // segment, so we skip the embedding work and downstream searches
432
431
  // entirely. Caller (`runContextLoad`) sees zero nodes and routes to the
433
432
  // v2 activation pipeline.
434
- if (isMemoryV2ReadActive(opts.config)) {
433
+ if (opts.config.memory.v2.enabled) {
435
434
  return {
436
435
  nodes: [],
437
436
  serendipityNodes: [],
@@ -2,14 +2,13 @@
2
2
  // Memory Tool handlers
3
3
  //
4
4
  // remember: save facts to the PKB (buffer.md + daily archive) under the v1
5
- // path, or to memory/buffer.md + memory/archive/<today>.md when the
6
- // `memory-v2-enabled` feature flag is on.
5
+ // path, or to memory/buffer.md + memory/archive/<today>.md when memory v2 is
6
+ // active.
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
9
  import { appendFileSync, existsSync, mkdirSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
- import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
13
12
  import type { AssistantConfig } from "../../config/types.js";
14
13
  import { getLogger } from "../../util/logger.js";
15
14
  import { getWorkspaceDir } from "../../util/platform.js";
@@ -46,7 +45,7 @@ export function handleRemember(
46
45
  const now = new Date();
47
46
  const entry = formatRememberEntry(input.content.trim(), now);
48
47
 
49
- if (isAssistantFeatureFlagEnabled("memory-v2-enabled", config)) {
48
+ if (config.memory.v2.enabled) {
50
49
  appendBufferAndArchive({
51
50
  rootDir: join(workspaceDir, "memory"),
52
51
  entry,
@@ -56,10 +56,10 @@ export const graphRecallDefinition: ToolDefinition = {
56
56
  /**
57
57
  * Save a fact to the assistant's knowledge base. The fact is appended to
58
58
  * `buffer.md` (immediately available in the next conversation) and the daily
59
- * archive (permanent date-indexed record). With the `memory-v2-enabled`
60
- * feature flag on, writes go under `memory/`; otherwise they go under
61
- * `pkb/`. Consolidation of the buffer into longer-form storage runs as a
62
- * separate periodic job in both modes.
59
+ * archive (permanent date-indexed record). When `memory.v2.enabled` is true,
60
+ * writes go under `memory/`; otherwise they go under `pkb/`. Consolidation
61
+ * of the buffer into longer-form storage runs as a separate periodic job in
62
+ * both modes.
63
63
  */
64
64
  export const graphRememberDefinition: ToolDefinition = {
65
65
  name: "remember",
@@ -9,7 +9,6 @@ import { getLogger } from "../util/logger.js";
9
9
  import { enqueueAutoAnalysisIfEnabled } from "./auto-analysis-enqueue.js";
10
10
  import { isAutoAnalysisConversation } from "./auto-analysis-guard.js";
11
11
  import { getMemoryCheckpoint, setMemoryCheckpoint } from "./checkpoints.js";
12
- import { isMemoryV2ReadActive } from "./context-search/sources/memory-v2.js";
13
12
  import { getDb } from "./db-connection.js";
14
13
  import { selectedBackendSupportsMultimodal } from "./embedding-backend.js";
15
14
  import { enqueueMemoryJob, upsertDebouncedJob } from "./jobs-store.js";
@@ -190,7 +189,7 @@ export async function indexMessageNow(
190
189
  }
191
190
 
192
191
  const v2Config =
193
- triggerConfig != null && isMemoryV2ReadActive(triggerConfig)
192
+ triggerConfig != null && triggerConfig.memory.v2.enabled
194
193
  ? triggerConfig
195
194
  : null;
196
195