@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
@@ -27,10 +27,39 @@ mock.module("../../qdrant-client.js", () => ({
27
27
  // records every call and lets each test program the next response.
28
28
  type MockPoint = {
29
29
  id: string;
30
- vector: { dense: number[]; sparse: { indices: number[]; values: number[] } };
30
+ vector: {
31
+ dense: number[];
32
+ sparse: { indices: number[]; values: number[] };
33
+ summary_dense?: number[];
34
+ summary_sparse?: { indices: number[]; values: number[] };
35
+ };
31
36
  payload: { slug: string; updated_at: number };
32
37
  };
33
38
 
39
+ type MockCollectionInfo = {
40
+ config: {
41
+ params: {
42
+ vectors?: Record<string, { size: number }> | { size: number };
43
+ sparse_vectors?: Record<string, unknown>;
44
+ };
45
+ };
46
+ };
47
+
48
+ const FULL_SCHEMA_INFO: MockCollectionInfo = {
49
+ config: {
50
+ params: {
51
+ vectors: {
52
+ dense: { size: 384 },
53
+ summary_dense: { size: 384 },
54
+ },
55
+ sparse_vectors: {
56
+ sparse: {},
57
+ summary_sparse: {},
58
+ },
59
+ },
60
+ },
61
+ };
62
+
34
63
  const state = {
35
64
  collectionExistsBeforeCreate: false,
36
65
  collectionExistsCalls: 0,
@@ -39,6 +68,10 @@ const state = {
39
68
  createIndexCalls: [] as Array<{ field_name: string; field_schema: string }>,
40
69
  upsertCalls: [] as Array<{ wait: boolean; points: MockPoint[] }>,
41
70
  deleteCalls: [] as Array<{ wait: boolean; points: string[] }>,
71
+ // Tracks `client.deleteCollection(name)` calls (distinct from `delete()`,
72
+ // which targets points). The schema-drift recreate path drops the
73
+ // collection entirely and we want to assert it ran exactly once.
74
+ deleteCollectionCalls: [] as string[],
42
75
  queryCalls: [] as Array<{
43
76
  using: string;
44
77
  query: unknown;
@@ -55,6 +88,17 @@ const state = {
55
88
  }>,
56
89
  },
57
90
  createCollectionThrows: null as Error | null,
91
+ // Schema returned by `client.getCollection`. Tests that exercise the
92
+ // drift path point this at a partial schema; the default mirrors a fully
93
+ // migrated collection so the no-drift path is the silent default.
94
+ getCollectionInfo: FULL_SCHEMA_INFO as MockCollectionInfo,
95
+ getCollectionThrows: null as Error | null,
96
+ getCollectionCalls: 0,
97
+ // Point count returned by `client.count`. Used by `countConceptPagePoints`
98
+ // which the lifecycle hook reads for the empty-after-create recovery path.
99
+ countResult: 0,
100
+ countThrows: null as Error | null,
101
+ countCalls: 0,
58
102
  // Throw queue for upsert: first call shifts and throws if non-null;
59
103
  // subsequent calls succeed once the queue is exhausted.
60
104
  upsertThrowQueue: [] as Array<Error | null>,
@@ -66,13 +110,29 @@ class MockQdrantClient {
66
110
  state.collectionExistsCalls++;
67
111
  return { exists: state.collectionExistsBeforeCreate };
68
112
  }
113
+ async getCollection(_name: string) {
114
+ state.getCollectionCalls++;
115
+ if (state.getCollectionThrows) throw state.getCollectionThrows;
116
+ return state.getCollectionInfo;
117
+ }
69
118
  async createCollection(_name: string, params: unknown) {
70
119
  state.createCollectionCalls++;
71
120
  state.createCollectionParams = params;
72
121
  if (state.createCollectionThrows) throw state.createCollectionThrows;
73
122
  state.collectionExistsBeforeCreate = true;
123
+ state.getCollectionInfo = FULL_SCHEMA_INFO;
124
+ return {};
125
+ }
126
+ async deleteCollection(name: string) {
127
+ state.deleteCollectionCalls.push(name);
128
+ state.collectionExistsBeforeCreate = false;
74
129
  return {};
75
130
  }
131
+ async count(_name: string, _opts: { exact: boolean }) {
132
+ state.countCalls++;
133
+ if (state.countThrows) throw state.countThrows;
134
+ return { count: state.countResult };
135
+ }
76
136
  async createPayloadIndex(
77
137
  _name: string,
78
138
  params: { field_name: string; field_schema: string },
@@ -102,7 +162,14 @@ class MockQdrantClient {
102
162
  },
103
163
  ) {
104
164
  state.queryCalls.push(params);
105
- const queue = state.queryResponses[params.using as "dense" | "sparse"];
165
+ // Both `dense` and `summary_dense` consume from the dense queue (and
166
+ // similarly for sparse). The four-channel hybrid query fires them in
167
+ // order: body-dense, body-sparse, summary-dense, summary-sparse — so
168
+ // queue order matches call order.
169
+ const queue =
170
+ state.queryResponses[
171
+ params.using.endsWith("sparse") ? "sparse" : "dense"
172
+ ];
106
173
  return queue.shift() ?? { points: [] };
107
174
  }
108
175
  }
@@ -116,6 +183,7 @@ const {
116
183
  upsertConceptPageEmbedding,
117
184
  deleteConceptPageEmbedding,
118
185
  hybridQueryConceptPages,
186
+ countConceptPagePoints,
119
187
  MEMORY_V2_COLLECTION,
120
188
  _resetMemoryV2QdrantForTests,
121
189
  } = await import("../qdrant.js");
@@ -128,10 +196,17 @@ function resetState(): void {
128
196
  state.createIndexCalls.length = 0;
129
197
  state.upsertCalls.length = 0;
130
198
  state.deleteCalls.length = 0;
199
+ state.deleteCollectionCalls.length = 0;
131
200
  state.queryCalls.length = 0;
132
201
  state.queryResponses.dense.length = 0;
133
202
  state.queryResponses.sparse.length = 0;
134
203
  state.createCollectionThrows = null;
204
+ state.getCollectionInfo = FULL_SCHEMA_INFO;
205
+ state.getCollectionThrows = null;
206
+ state.getCollectionCalls = 0;
207
+ state.countResult = 0;
208
+ state.countThrows = null;
209
+ state.countCalls = 0;
135
210
  state.upsertThrowQueue.length = 0;
136
211
  _resetMemoryV2QdrantForTests();
137
212
  }
@@ -140,7 +215,7 @@ describe("memory v2 qdrant — collection lifecycle", () => {
140
215
  beforeEach(resetState);
141
216
  afterEach(resetState);
142
217
 
143
- test("creates the collection with named dense + sparse vectors", async () => {
218
+ test("creates the collection with named dense + sparse vectors (body and summary)", async () => {
144
219
  state.collectionExistsBeforeCreate = false;
145
220
 
146
221
  await ensureConceptPageCollection();
@@ -149,8 +224,12 @@ describe("memory v2 qdrant — collection lifecycle", () => {
149
224
  const params = state.createCollectionParams as {
150
225
  vectors: {
151
226
  dense: { size: number; distance: string; on_disk: boolean };
227
+ summary_dense: { size: number; distance: string; on_disk: boolean };
228
+ };
229
+ sparse_vectors: {
230
+ sparse: Record<string, unknown>;
231
+ summary_sparse: Record<string, unknown>;
152
232
  };
153
- sparse_vectors: { sparse: Record<string, unknown> };
154
233
  hnsw_config: { on_disk: boolean; m: number; ef_construct: number };
155
234
  on_disk_payload: boolean;
156
235
  };
@@ -159,7 +238,14 @@ describe("memory v2 qdrant — collection lifecycle", () => {
159
238
  distance: "Cosine",
160
239
  on_disk: true,
161
240
  });
241
+ // Summary side mirrors body so the activation pipeline can fuse symmetrically.
242
+ expect(params.vectors.summary_dense).toEqual({
243
+ size: 384,
244
+ distance: "Cosine",
245
+ on_disk: true,
246
+ });
162
247
  expect(params.sparse_vectors.sparse).toEqual({});
248
+ expect(params.sparse_vectors.summary_sparse).toEqual({});
163
249
  expect(params.hnsw_config).toEqual({
164
250
  on_disk: true,
165
251
  m: 16,
@@ -219,6 +305,115 @@ describe("memory v2 qdrant — collection lifecycle", () => {
219
305
  // expected to have created it (it ran the same code).
220
306
  expect(state.createIndexCalls).toEqual([]);
221
307
  });
308
+
309
+ test("detects missing summary_dense / summary_sparse on an existing collection and recreates", async () => {
310
+ // Pre-#29823 schema: only body channels, no summary_*.
311
+ state.collectionExistsBeforeCreate = true;
312
+ state.getCollectionInfo = {
313
+ config: {
314
+ params: {
315
+ vectors: { dense: { size: 384 } },
316
+ sparse_vectors: { sparse: {} },
317
+ },
318
+ },
319
+ };
320
+
321
+ const result = await ensureConceptPageCollection();
322
+
323
+ // Drift path probed once, dropped the collection once, and recreated
324
+ // with the full four-vector schema (the create-success branch resets
325
+ // `getCollectionInfo` to FULL_SCHEMA_INFO so a follow-up probe agrees).
326
+ expect(state.getCollectionCalls).toBe(1);
327
+ expect(state.deleteCollectionCalls).toEqual([MEMORY_V2_COLLECTION]);
328
+ expect(state.createCollectionCalls).toBe(1);
329
+ expect(result).toEqual({ migrated: true });
330
+
331
+ // Recreated schema carries summary_dense + summary_sparse.
332
+ const params = state.createCollectionParams as {
333
+ vectors: Record<string, unknown>;
334
+ sparse_vectors: Record<string, unknown>;
335
+ };
336
+ expect(params.vectors.summary_dense).toBeDefined();
337
+ expect(params.sparse_vectors.summary_sparse).toBeDefined();
338
+ });
339
+
340
+ test("leaves a fully migrated collection untouched", async () => {
341
+ // Default `getCollectionInfo` is FULL_SCHEMA_INFO — already migrated.
342
+ state.collectionExistsBeforeCreate = true;
343
+
344
+ const result = await ensureConceptPageCollection();
345
+
346
+ expect(state.getCollectionCalls).toBe(1);
347
+ expect(state.deleteCollectionCalls).toEqual([]);
348
+ expect(state.createCollectionCalls).toBe(0);
349
+ expect(result).toEqual({ migrated: false });
350
+ });
351
+
352
+ test("getCollection failure is treated as compatible (no destructive recreate)", async () => {
353
+ state.collectionExistsBeforeCreate = true;
354
+ state.getCollectionThrows = new Error("transient REST error");
355
+
356
+ const result = await ensureConceptPageCollection();
357
+
358
+ expect(state.getCollectionCalls).toBe(1);
359
+ expect(state.deleteCollectionCalls).toEqual([]);
360
+ expect(state.createCollectionCalls).toBe(0);
361
+ expect(result).toEqual({ migrated: false });
362
+ });
363
+
364
+ test("concurrent ensure during a schema rebuild only deletes/creates once", async () => {
365
+ state.collectionExistsBeforeCreate = true;
366
+ state.getCollectionInfo = {
367
+ config: {
368
+ params: {
369
+ vectors: { dense: { size: 384 } },
370
+ sparse_vectors: { sparse: {} },
371
+ },
372
+ },
373
+ };
374
+
375
+ const results = await Promise.all([
376
+ ensureConceptPageCollection(),
377
+ ensureConceptPageCollection(),
378
+ ensureConceptPageCollection(),
379
+ ]);
380
+
381
+ expect(state.deleteCollectionCalls).toEqual([MEMORY_V2_COLLECTION]);
382
+ expect(state.createCollectionCalls).toBe(1);
383
+ // All three concurrent callers see the same migrated signal so any one
384
+ // of them is safe to enqueue the reembed (the lifecycle hook is the
385
+ // single producer in practice).
386
+ expect(results).toEqual([
387
+ { migrated: true },
388
+ { migrated: true },
389
+ { migrated: true },
390
+ ]);
391
+ });
392
+ });
393
+
394
+ describe("memory v2 qdrant — point count", () => {
395
+ beforeEach(resetState);
396
+ afterEach(resetState);
397
+
398
+ test("returns the approximate Qdrant count for the v2 collection", async () => {
399
+ state.collectionExistsBeforeCreate = true;
400
+ state.countResult = 1185;
401
+
402
+ const count = await countConceptPagePoints();
403
+
404
+ expect(count).toBe(1185);
405
+ expect(state.countCalls).toBe(1);
406
+ });
407
+
408
+ test("returns 0 when the count call fails (treated as needs-reembed)", async () => {
409
+ state.collectionExistsBeforeCreate = true;
410
+ state.countThrows = new Error("Qdrant unreachable");
411
+
412
+ const count = await countConceptPagePoints();
413
+
414
+ expect(count).toBe(0);
415
+ expect(state.countCalls).toBe(1);
416
+ });
222
417
  });
223
418
 
224
419
  describe("memory v2 qdrant — upsert", () => {
@@ -249,12 +444,67 @@ describe("memory v2 qdrant — upsert", () => {
249
444
  indices: [1, 2],
250
445
  values: [0.5, 0.5],
251
446
  });
447
+ // No summary vectors when caller didn't pass them — Qdrant accepts a
448
+ // partial named-vector subset, and pages without a frontmatter summary
449
+ // legitimately have nothing to embed on the summary side.
450
+ const vectorRecord = point.vector as unknown as Record<string, unknown>;
451
+ expect(vectorRecord.summary_dense).toBeUndefined();
452
+ expect(vectorRecord.summary_sparse).toBeUndefined();
252
453
  // Point ID is a UUID-shaped string derived from the slug.
253
454
  expect(point.id).toMatch(
254
455
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
255
456
  );
256
457
  });
257
458
 
459
+ test("upserts summary vectors alongside body vectors when both are provided", async () => {
460
+ state.collectionExistsBeforeCreate = true;
461
+
462
+ await upsertConceptPageEmbedding({
463
+ slug: "summarized-page",
464
+ dense: [0.1, 0.2, 0.3],
465
+ sparse: { indices: [1, 2], values: [0.5, 0.5] },
466
+ summary: {
467
+ dense: [0.4, 0.5, 0.6],
468
+ sparse: { indices: [3, 4], values: [0.7, 0.7] },
469
+ },
470
+ updatedAt: 1714000000000,
471
+ });
472
+
473
+ expect(state.upsertCalls).toHaveLength(1);
474
+ const [point] = state.upsertCalls[0].points;
475
+ const vectorRecord = point.vector as unknown as Record<string, unknown>;
476
+ expect(vectorRecord.dense).toEqual([0.1, 0.2, 0.3]);
477
+ expect(vectorRecord.sparse).toEqual({
478
+ indices: [1, 2],
479
+ values: [0.5, 0.5],
480
+ });
481
+ expect(vectorRecord.summary_dense).toEqual([0.4, 0.5, 0.6]);
482
+ expect(vectorRecord.summary_sparse).toEqual({
483
+ indices: [3, 4],
484
+ values: [0.7, 0.7],
485
+ });
486
+ });
487
+
488
+ test("omits summary vectors when the summary block is undefined", async () => {
489
+ // The grouped-shape signature enforces summary as a paired { dense, sparse }
490
+ // block; passing `undefined` (or omitting it) leaves the summary vectors off
491
+ // the point entirely so query-time fusion stays symmetric.
492
+ state.collectionExistsBeforeCreate = true;
493
+
494
+ await upsertConceptPageEmbedding({
495
+ slug: "no-summary",
496
+ dense: [0.1],
497
+ sparse: { indices: [1], values: [1] },
498
+ // summary intentionally omitted
499
+ updatedAt: 1,
500
+ });
501
+
502
+ const [point] = state.upsertCalls[0].points;
503
+ const vectorRecord = point.vector as unknown as Record<string, unknown>;
504
+ expect(vectorRecord.summary_dense).toBeUndefined();
505
+ expect(vectorRecord.summary_sparse).toBeUndefined();
506
+ });
507
+
258
508
  test("two upserts for the same slug share the same point id (overwrites in place)", async () => {
259
509
  state.collectionExistsBeforeCreate = true;
260
510
 
@@ -357,8 +607,9 @@ describe("memory v2 qdrant — hybrid query", () => {
357
607
  beforeEach(resetState);
358
608
  afterEach(resetState);
359
609
 
360
- test("runs both dense and sparse queries and returns per-channel scores", async () => {
610
+ test("runs all four channels (body dense/sparse + summary dense/sparse) and returns per-channel scores", async () => {
361
611
  state.collectionExistsBeforeCreate = true;
612
+ // Body channel hits.
362
613
  state.queryResponses.dense.push({
363
614
  points: [
364
615
  { score: 0.91, payload: { slug: "alice-prefers-vs-code" } },
@@ -371,6 +622,14 @@ describe("memory v2 qdrant — hybrid query", () => {
371
622
  { score: 3, payload: { slug: "bob-uses-zsh" } },
372
623
  ],
373
624
  });
625
+ // Summary channel hits — queue order is body-dense, body-sparse,
626
+ // summary-dense, summary-sparse, so push summaries after bodies.
627
+ state.queryResponses.dense.push({
628
+ points: [{ score: 0.81, payload: { slug: "alice-prefers-vs-code" } }],
629
+ });
630
+ state.queryResponses.sparse.push({
631
+ points: [{ score: 9, payload: { slug: "alice-prefers-vs-code" } }],
632
+ });
374
633
 
375
634
  const results = await hybridQueryConceptPages(
376
635
  [0.1, 0.2, 0.3],
@@ -378,14 +637,19 @@ describe("memory v2 qdrant — hybrid query", () => {
378
637
  5,
379
638
  );
380
639
 
381
- // Both queries fired, with the same limit and the right `using`.
382
- expect(state.queryCalls).toHaveLength(2);
640
+ // All four queries fired with the same limit and distinct `using`.
641
+ expect(state.queryCalls).toHaveLength(4);
383
642
  const usings = state.queryCalls.map((c) => c.using).sort();
384
- expect(usings).toEqual(["dense", "sparse"]);
643
+ expect(usings).toEqual([
644
+ "dense",
645
+ "sparse",
646
+ "summary_dense",
647
+ "summary_sparse",
648
+ ]);
385
649
  expect(state.queryCalls.every((c) => c.limit === 5)).toBe(true);
386
650
  expect(state.queryCalls.every((c) => c.with_payload === true)).toBe(true);
387
651
 
388
- // Each slug exposes both channel scores.
652
+ // Alice has hits on all four channels; bob is body-only.
389
653
  expect(results).toHaveLength(2);
390
654
  const alice = results.find((r) => r.slug === "alice-prefers-vs-code");
391
655
  const bob = results.find((r) => r.slug === "bob-uses-zsh");
@@ -393,6 +657,8 @@ describe("memory v2 qdrant — hybrid query", () => {
393
657
  slug: "alice-prefers-vs-code",
394
658
  denseScore: 0.91,
395
659
  sparseScore: 12,
660
+ summaryDenseScore: 0.81,
661
+ summarySparseScore: 9,
396
662
  });
397
663
  expect(bob).toEqual({
398
664
  slug: "bob-uses-zsh",
@@ -403,6 +669,8 @@ describe("memory v2 qdrant — hybrid query", () => {
403
669
 
404
670
  test("dense-only hits leave sparseScore undefined (and vice versa)", async () => {
405
671
  state.collectionExistsBeforeCreate = true;
672
+ // Body dense + sparse hits. Summary channels stay empty (no push) →
673
+ // they fall through to `{ points: [] }` and produce no summary scores.
406
674
  state.queryResponses.dense.push({
407
675
  points: [{ score: 0.7, payload: { slug: "dense-only" } }],
408
676
  });
@@ -420,8 +688,41 @@ describe("memory v2 qdrant — hybrid query", () => {
420
688
  const sparseOnly = results.find((r) => r.slug === "sparse-only");
421
689
  expect(denseOnly).toEqual({ slug: "dense-only", denseScore: 0.7 });
422
690
  expect(denseOnly?.sparseScore).toBeUndefined();
691
+ expect(denseOnly?.summaryDenseScore).toBeUndefined();
423
692
  expect(sparseOnly).toEqual({ slug: "sparse-only", sparseScore: 2 });
424
693
  expect(sparseOnly?.denseScore).toBeUndefined();
694
+ expect(sparseOnly?.summarySparseScore).toBeUndefined();
695
+ });
696
+
697
+ test("returns summary-channel scores when only the summary side hits", async () => {
698
+ // Page has no body hits but matches via the summary embedding —
699
+ // exercises the path where `simBatch` falls back to summary-only.
700
+ state.collectionExistsBeforeCreate = true;
701
+ // Body channels empty.
702
+ state.queryResponses.dense.push({ points: [] });
703
+ state.queryResponses.sparse.push({ points: [] });
704
+ // Summary channels hit.
705
+ state.queryResponses.dense.push({
706
+ points: [{ score: 0.6, payload: { slug: "summary-only" } }],
707
+ });
708
+ state.queryResponses.sparse.push({
709
+ points: [{ score: 4, payload: { slug: "summary-only" } }],
710
+ });
711
+
712
+ const results = await hybridQueryConceptPages(
713
+ [0.1],
714
+ { indices: [1], values: [1] },
715
+ 5,
716
+ );
717
+
718
+ const summaryOnly = results.find((r) => r.slug === "summary-only");
719
+ expect(summaryOnly).toEqual({
720
+ slug: "summary-only",
721
+ summaryDenseScore: 0.6,
722
+ summarySparseScore: 4,
723
+ });
724
+ expect(summaryOnly?.denseScore).toBeUndefined();
725
+ expect(summaryOnly?.sparseScore).toBeUndefined();
425
726
  });
426
727
 
427
728
  test("does not use Qdrant-side RRF fusion (separate per-channel queries)", async () => {
@@ -136,7 +136,11 @@ class MockQdrantClient {
136
136
  limit: params.limit,
137
137
  filter: params.filter,
138
138
  });
139
- const channel = params.using as "dense" | "sparse";
139
+ // Both `dense` and `summary_dense` consume from the dense queue (and
140
+ // similarly for sparse). The four-channel hybrid query fires them in
141
+ // order: body-dense, body-sparse, summary-dense, summary-sparse — so
142
+ // the queue order matches the call order.
143
+ const channel = params.using.endsWith("sparse") ? "sparse" : "dense";
140
144
  return state.queryResponses[channel].shift() ?? { points: [] };
141
145
  }
142
146
  }
@@ -185,10 +189,18 @@ function configWithWeights(
185
189
  /**
186
190
  * Stage a single Qdrant response that maps each (slug, denseScore?, sparseScore?)
187
191
  * tuple onto the dense or sparse channel, mirroring how `hybridQueryConceptPages`
188
- * merges per-channel hits.
192
+ * merges per-channel hits. Optional `summaryDenseScore` / `summarySparseScore`
193
+ * stage the summary-side channels — pages without those entries fall through
194
+ * to body-only scoring at fusion time.
189
195
  */
190
196
  function stageHybridResponse(
191
- hits: Array<{ slug: string; denseScore?: number; sparseScore?: number }>,
197
+ hits: Array<{
198
+ slug: string;
199
+ denseScore?: number;
200
+ sparseScore?: number;
201
+ summaryDenseScore?: number;
202
+ summarySparseScore?: number;
203
+ }>,
192
204
  ): void {
193
205
  state.queryResponses.dense.push({
194
206
  points: hits
@@ -200,6 +212,20 @@ function stageHybridResponse(
200
212
  .filter((h) => h.sparseScore !== undefined)
201
213
  .map((h) => ({ score: h.sparseScore, payload: { slug: h.slug } })),
202
214
  });
215
+ // The four-channel hybrid query also fires `summary_dense` and
216
+ // `summary_sparse` queries against the same collection. Tests that don't
217
+ // care about summary scores leave those channels empty so the fallback
218
+ // (body-only) path runs.
219
+ state.queryResponses.dense.push({
220
+ points: hits
221
+ .filter((h) => h.summaryDenseScore !== undefined)
222
+ .map((h) => ({ score: h.summaryDenseScore, payload: { slug: h.slug } })),
223
+ });
224
+ state.queryResponses.sparse.push({
225
+ points: hits
226
+ .filter((h) => h.summarySparseScore !== undefined)
227
+ .map((h) => ({ score: h.summarySparseScore, payload: { slug: h.slug } })),
228
+ });
203
229
  }
204
230
 
205
231
  beforeEach(resetState);
@@ -468,15 +494,16 @@ describe("simBatch", () => {
468
494
  expect(out.get("loud-page")).toBe(1);
469
495
  });
470
496
 
471
- test("forwards the candidate slugs as a Qdrant slug-IN filter", async () => {
497
+ test("forwards the candidate slugs as a Qdrant slug-IN filter on every channel", async () => {
472
498
  const config = configWithWeights(0.7, 0.3);
473
499
  stageHybridResponse([]);
474
500
 
475
501
  await simBatch("query", ["alice", "bob", "carol"], config);
476
502
 
477
- // Both channels (dense + sparse) ran with the same slug-restriction
478
- // filter and the same per-channel limit equal to the candidate count.
479
- expect(state.queryCalls).toHaveLength(2);
503
+ // All four channels (body dense + sparse, summary dense + sparse) ran
504
+ // with the same slug-restriction filter and the same per-channel limit
505
+ // equal to the candidate count.
506
+ expect(state.queryCalls).toHaveLength(4);
480
507
  for (const call of state.queryCalls) {
481
508
  expect(call.limit).toBe(3);
482
509
  expect(call.filter).toEqual({
@@ -496,6 +523,90 @@ describe("simBatch", () => {
496
523
  expect(state.sparseCalls).toEqual(["hello world"]);
497
524
  });
498
525
 
526
+ test("takes max(body, summary) per slug — summary higher than body wins", async () => {
527
+ // Body channels return a modest score; summary channels return a much
528
+ // higher score. The max collapses to the summary score.
529
+ const config = configWithWeights(1.0, 0.0);
530
+ stageHybridResponse([
531
+ {
532
+ slug: "alice",
533
+ denseScore: 0.3,
534
+ summaryDenseScore: 0.7,
535
+ },
536
+ ]);
537
+
538
+ const out = await simBatch("query", ["alice"], config);
539
+
540
+ expect(out.get("alice")).toBeCloseTo(0.7, 6);
541
+ });
542
+
543
+ test("takes max(body, summary) per slug — body higher than summary wins", async () => {
544
+ // Inverse case: body dominates, max stays at body.
545
+ const config = configWithWeights(1.0, 0.0);
546
+ stageHybridResponse([
547
+ {
548
+ slug: "alice",
549
+ denseScore: 0.9,
550
+ summaryDenseScore: 0.4,
551
+ },
552
+ ]);
553
+
554
+ const out = await simBatch("query", ["alice"], config);
555
+
556
+ expect(out.get("alice")).toBeCloseTo(0.9, 6);
557
+ });
558
+
559
+ test("falls back to body-only when the page has no summary embedding", async () => {
560
+ // Pages predating the summary field have no summary_dense/sparse vectors.
561
+ // Their summary channels return no hits — the max collapses to body.
562
+ const config = configWithWeights(1.0, 0.0);
563
+ stageHybridResponse([
564
+ {
565
+ slug: "legacy-page",
566
+ denseScore: 0.6,
567
+ // summaryDenseScore / summarySparseScore omitted
568
+ },
569
+ ]);
570
+
571
+ const out = await simBatch("query", ["legacy-page"], config);
572
+
573
+ expect(out.get("legacy-page")).toBeCloseTo(0.6, 6);
574
+ });
575
+
576
+ test("normalizes body and summary sparse channels independently", async () => {
577
+ // Summary sparse scores live on a different scale than body sparse —
578
+ // a small absolute summary-sparse value (1.5) on the only page that
579
+ // has summary signal still normalizes to 1.0 within the summary
580
+ // channel, so the summary-only fused score should win out.
581
+ const config = configWithWeights(0.0, 1.0);
582
+ stageHybridResponse([
583
+ {
584
+ slug: "alice",
585
+ denseScore: 0.0,
586
+ sparseScore: 100, // body sparse max in this batch
587
+ },
588
+ {
589
+ slug: "bob",
590
+ denseScore: 0.0,
591
+ sparseScore: 0.5, // body sparse normalized = 0.005
592
+ summaryDenseScore: 0.0,
593
+ summarySparseScore: 1.5, // summary sparse max in this batch
594
+ },
595
+ ]);
596
+
597
+ const out = await simBatch("query", ["alice", "bob"], config);
598
+
599
+ // Alice has only body. Body sparse normalized to 1.0; sparse_weight=1.0 → 1.0.
600
+ expect(out.get("alice")).toBeCloseTo(1.0, 6);
601
+ // Bob's summary side normalizes its 1.5 (only sparse-bearing summary
602
+ // hit) — a single sparse-bearing hit is below the adaptive-spread
603
+ // floor, so the channel collapses to base weights and the lone
604
+ // sparseNormalized=1.0 hit yields a fused summary score of 1.0.
605
+ // Body side has only bob's tiny sparse=0.5 against the body batch max
606
+ // of 100 → ~0.005. The max picks the summary side.
607
+ expect(out.get("bob")).toBeCloseTo(1.0, 6);
608
+ });
609
+
499
610
  test("returned scores are always in [0, 1] for arbitrary inputs", async () => {
500
611
  const config = configWithWeights(0.7, 0.3);
501
612
  stageHybridResponse([
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * Tests for `readMemoryV2StaticContent` — the loader that powers the
3
- * `memory-v2-static` user-message auto-injection. Mirrors the coverage that
4
- * lived in the deprecated `system-prompt-memory-v2.test.ts`:
5
- * - Returns null when the v2 flag is off.
3
+ * `memory-v2-static` user-message auto-injection.
6
4
  * - Returns null when `config.memory.v2.enabled` is off.
7
5
  * - Reads the four files in canonical order and joins them under headings.
8
6
  * - Skips empty / missing files.
@@ -47,8 +45,6 @@ mock.module("../../../config/loader.js", () => ({
47
45
  setNestedValue: () => {},
48
46
  }));
49
47
 
50
- const { _setOverridesForTesting } =
51
- await import("../../../config/assistant-feature-flags.js");
52
48
  const { readMemoryV2StaticContent, shouldLoadMemoryV2Static } =
53
49
  await import("../static-context.js");
54
50
 
@@ -75,18 +71,10 @@ describe("readMemoryV2StaticContent", () => {
75
71
  beforeEach(() => {
76
72
  mkdirSync(TEST_DIR, { recursive: true });
77
73
  configMemoryV2Enabled = true;
78
- _setOverridesForTesting({ "memory-v2-enabled": true });
79
74
  });
80
75
 
81
76
  afterEach(() => {
82
77
  cleanupMemoryDir();
83
- _setOverridesForTesting({});
84
- });
85
-
86
- test("returns null when the feature flag is off", () => {
87
- _setOverridesForTesting({ "memory-v2-enabled": false });
88
- for (const file of MEMORY_FILES) writeMemoryFile(file, `Content ${file}`);
89
- expect(readMemoryV2StaticContent()).toBeNull();
90
78
  });
91
79
 
92
80
  test("returns null when config.memory.v2.enabled is off", () => {