@hanhnd/agent-kit 1.0.37 → 1.0.38

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.
package/dist/cli.js CHANGED
@@ -15036,6 +15036,9 @@ var DEFAULT_MEMORY_CONFIG = {
15036
15036
  var LOCK_RETRY_MS = 50;
15037
15037
  var LOCK_TIMEOUT_MS = 500;
15038
15038
  var RRF_K = 60;
15039
+ var FETCH_MULTIPLIER = 4;
15040
+ var RECENCY_WEIGHT = 0.5;
15041
+ var DENSE_SCORE_FLOOR = 0.2;
15039
15042
 
15040
15043
  // src/core/config/index.ts
15041
15044
  function resolveMemoryConfig(settings, workspaceRoot) {
@@ -15456,6 +15459,7 @@ var MemoryStore = class {
15456
15459
  return 0;
15457
15460
  }
15458
15461
  upsert(chunks, embeddings) {
15462
+ const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
15459
15463
  const insertChunk = this.db.prepare(`
15460
15464
  INSERT OR REPLACE INTO memory_chunks
15461
15465
  (id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
@@ -15466,7 +15470,7 @@ var MemoryStore = class {
15466
15470
  const chunk = chunks[i];
15467
15471
  try {
15468
15472
  const vectorBinding = this.toVectorBinding(embeddings[i]);
15469
- this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`).run(chunk.id);
15473
+ deleteChunk.run(chunk.id);
15470
15474
  insertChunk.run(
15471
15475
  chunk.id,
15472
15476
  chunk.source,
@@ -15495,11 +15499,10 @@ var MemoryStore = class {
15495
15499
  if (!embedding) throw new Error("missing embedding");
15496
15500
  if (!(embedding instanceof Float32Array)) throw new Error("missing embedding");
15497
15501
  if (embedding.length !== this.config.vectorDimension) throw new Error("embedding dimension mismatch");
15498
- const values = Array.from(embedding, (value) => {
15499
- if (!Number.isFinite(value)) throw new Error("embedding contains non-finite value");
15500
- return value;
15501
- });
15502
- return JSON.stringify(values);
15502
+ for (let i = 0; i < embedding.length; i++) {
15503
+ if (!Number.isFinite(embedding[i])) throw new Error("embedding contains non-finite value");
15504
+ }
15505
+ return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
15503
15506
  }
15504
15507
  normalizeVectorDistance(distance) {
15505
15508
  if (distance <= 0) return 1;
@@ -15855,7 +15858,7 @@ var MemoryIndexer = class {
15855
15858
  return totals;
15856
15859
  }
15857
15860
  async search(query, topK) {
15858
- const fetchLimit = topK * 2;
15861
+ const fetchLimit = topK * FETCH_MULTIPLIER;
15859
15862
  let denseResults = [];
15860
15863
  if (this.store.vecAvailable) {
15861
15864
  try {
@@ -15866,30 +15869,50 @@ var MemoryIndexer = class {
15866
15869
  }
15867
15870
  }
15868
15871
  const bm25Results = this.store.searchBm25(query, fetchLimit);
15869
- const bm25Ids = new Set(bm25Results.map((result) => result.id));
15870
- if (bm25Ids.size > 0) {
15871
- denseResults = denseResults.filter((result) => bm25Ids.has(result.id));
15872
- }
15872
+ const denseIds = new Set(denseResults.map((r) => r.id));
15873
+ const bm25Ids = new Set(bm25Results.map((r) => r.id));
15874
+ const unionIds = /* @__PURE__ */ new Set([...denseIds, ...bm25Ids]);
15875
+ const denseRetrievalScore = new Map(denseResults.map((r) => [r.id, r.score]));
15876
+ const unionArray = [...unionIds];
15877
+ const chunks = this.store.getChunksByIds(unionArray);
15878
+ const chunkMap = new Map(chunks.map((c) => [c.id, c]));
15879
+ const unionIndexOf = new Map(unionArray.map((id, idx) => [id, idx]));
15880
+ const recencyOrdered = [...unionArray].sort((a, b) => {
15881
+ const mtimeA = chunkMap.get(a)?.fileMtimeAt ?? 0;
15882
+ const mtimeB = chunkMap.get(b)?.fileMtimeAt ?? 0;
15883
+ if (mtimeB !== mtimeA) return mtimeB - mtimeA;
15884
+ return (unionIndexOf.get(a) ?? 0) - (unionIndexOf.get(b) ?? 0);
15885
+ });
15873
15886
  const scoreMap = /* @__PURE__ */ new Map();
15874
15887
  denseResults.forEach((r, rank) => {
15875
- scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0 });
15888
+ scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0, recency: 0 });
15876
15889
  });
15877
15890
  bm25Results.forEach((r, rank) => {
15878
- const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
15891
+ const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
15879
15892
  entry.bm25 = 1 / (RRF_K + rank + 1);
15880
15893
  scoreMap.set(r.id, entry);
15881
15894
  });
15882
- const hasDense = this.store.vecAvailable && denseResults.length > 0;
15883
- const numRetrievers = hasDense ? 2 : 1;
15884
- const maxScore = numRetrievers / (RRF_K + 1);
15895
+ recencyOrdered.forEach((id, rank) => {
15896
+ const entry = scoreMap.get(id);
15897
+ if (!entry) return;
15898
+ entry.recency = RECENCY_WEIGHT * (1 / (RRF_K + rank + 1));
15899
+ });
15900
+ const denseActive = denseResults.length > 0;
15901
+ const bm25Active = bm25Results.length > 0;
15902
+ const recencyActive = unionIds.size > 0;
15903
+ const maxScore = ((denseActive ? 1 : 0) + (bm25Active ? 1 : 0) + (recencyActive ? RECENCY_WEIGHT : 0)) / (RRF_K + 1);
15885
15904
  const ranked = [...scoreMap.entries()].map(([id, scores]) => ({
15886
15905
  id,
15887
- totalScore: scores.dense + scores.bm25,
15906
+ totalScore: scores.dense + scores.bm25 + scores.recency,
15888
15907
  hasDense: scores.dense > 0,
15889
- hasBm25: scores.bm25 > 0
15890
- })).sort((a, b) => b.totalScore - a.totalScore);
15891
- const chunks = this.store.getChunksByIds(ranked.map((r) => r.id));
15892
- const chunkMap = new Map(chunks.map((c) => [c.id, c]));
15908
+ hasBm25: scores.bm25 > 0,
15909
+ isDenseOnly: denseIds.has(id) && !bm25Ids.has(id)
15910
+ })).filter((candidate) => {
15911
+ if (!candidate.isDenseOnly) return true;
15912
+ const retrieval = denseRetrievalScore.get(candidate.id);
15913
+ if (retrieval === void 0) return true;
15914
+ return retrieval >= DENSE_SCORE_FLOOR;
15915
+ }).sort((a, b) => b.totalScore - a.totalScore);
15893
15916
  const results = [];
15894
15917
  const seenSources = /* @__PURE__ */ new Set();
15895
15918
  for (const r of ranked) {
package/dist/server.js CHANGED
@@ -35920,6 +35920,9 @@ var DEFAULT_MEMORY_CONFIG = {
35920
35920
  var LOCK_RETRY_MS = 50;
35921
35921
  var LOCK_TIMEOUT_MS = 500;
35922
35922
  var RRF_K = 60;
35923
+ var FETCH_MULTIPLIER = 4;
35924
+ var RECENCY_WEIGHT = 0.5;
35925
+ var DENSE_SCORE_FLOOR = 0.2;
35923
35926
 
35924
35927
  // src/utils/files.ts
35925
35928
  import * as fs from "node:fs";
@@ -37093,7 +37096,7 @@ var MemoryIndexer = class {
37093
37096
  return totals;
37094
37097
  }
37095
37098
  async search(query, topK) {
37096
- const fetchLimit = topK * 2;
37099
+ const fetchLimit = topK * FETCH_MULTIPLIER;
37097
37100
  let denseResults = [];
37098
37101
  if (this.store.vecAvailable) {
37099
37102
  try {
@@ -37104,30 +37107,50 @@ var MemoryIndexer = class {
37104
37107
  }
37105
37108
  }
37106
37109
  const bm25Results = this.store.searchBm25(query, fetchLimit);
37107
- const bm25Ids = new Set(bm25Results.map((result) => result.id));
37108
- if (bm25Ids.size > 0) {
37109
- denseResults = denseResults.filter((result) => bm25Ids.has(result.id));
37110
- }
37110
+ const denseIds = new Set(denseResults.map((r) => r.id));
37111
+ const bm25Ids = new Set(bm25Results.map((r) => r.id));
37112
+ const unionIds = /* @__PURE__ */ new Set([...denseIds, ...bm25Ids]);
37113
+ const denseRetrievalScore = new Map(denseResults.map((r) => [r.id, r.score]));
37114
+ const unionArray = [...unionIds];
37115
+ const chunks = this.store.getChunksByIds(unionArray);
37116
+ const chunkMap = new Map(chunks.map((c) => [c.id, c]));
37117
+ const unionIndexOf = new Map(unionArray.map((id, idx) => [id, idx]));
37118
+ const recencyOrdered = [...unionArray].sort((a, b) => {
37119
+ const mtimeA = chunkMap.get(a)?.fileMtimeAt ?? 0;
37120
+ const mtimeB = chunkMap.get(b)?.fileMtimeAt ?? 0;
37121
+ if (mtimeB !== mtimeA) return mtimeB - mtimeA;
37122
+ return (unionIndexOf.get(a) ?? 0) - (unionIndexOf.get(b) ?? 0);
37123
+ });
37111
37124
  const scoreMap = /* @__PURE__ */ new Map();
37112
37125
  denseResults.forEach((r, rank) => {
37113
- scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0 });
37126
+ scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0, recency: 0 });
37114
37127
  });
37115
37128
  bm25Results.forEach((r, rank) => {
37116
- const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
37129
+ const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
37117
37130
  entry.bm25 = 1 / (RRF_K + rank + 1);
37118
37131
  scoreMap.set(r.id, entry);
37119
37132
  });
37120
- const hasDense = this.store.vecAvailable && denseResults.length > 0;
37121
- const numRetrievers = hasDense ? 2 : 1;
37122
- const maxScore = numRetrievers / (RRF_K + 1);
37133
+ recencyOrdered.forEach((id, rank) => {
37134
+ const entry = scoreMap.get(id);
37135
+ if (!entry) return;
37136
+ entry.recency = RECENCY_WEIGHT * (1 / (RRF_K + rank + 1));
37137
+ });
37138
+ const denseActive = denseResults.length > 0;
37139
+ const bm25Active = bm25Results.length > 0;
37140
+ const recencyActive = unionIds.size > 0;
37141
+ const maxScore = ((denseActive ? 1 : 0) + (bm25Active ? 1 : 0) + (recencyActive ? RECENCY_WEIGHT : 0)) / (RRF_K + 1);
37123
37142
  const ranked = [...scoreMap.entries()].map(([id, scores]) => ({
37124
37143
  id,
37125
- totalScore: scores.dense + scores.bm25,
37144
+ totalScore: scores.dense + scores.bm25 + scores.recency,
37126
37145
  hasDense: scores.dense > 0,
37127
- hasBm25: scores.bm25 > 0
37128
- })).sort((a, b) => b.totalScore - a.totalScore);
37129
- const chunks = this.store.getChunksByIds(ranked.map((r) => r.id));
37130
- const chunkMap = new Map(chunks.map((c) => [c.id, c]));
37146
+ hasBm25: scores.bm25 > 0,
37147
+ isDenseOnly: denseIds.has(id) && !bm25Ids.has(id)
37148
+ })).filter((candidate) => {
37149
+ if (!candidate.isDenseOnly) return true;
37150
+ const retrieval = denseRetrievalScore.get(candidate.id);
37151
+ if (retrieval === void 0) return true;
37152
+ return retrieval >= DENSE_SCORE_FLOOR;
37153
+ }).sort((a, b) => b.totalScore - a.totalScore);
37131
37154
  const results = [];
37132
37155
  const seenSources = /* @__PURE__ */ new Set();
37133
37156
  for (const r of ranked) {
@@ -37321,6 +37344,7 @@ var MemoryStore = class {
37321
37344
  return 0;
37322
37345
  }
37323
37346
  upsert(chunks, embeddings) {
37347
+ const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
37324
37348
  const insertChunk = this.db.prepare(`
37325
37349
  INSERT OR REPLACE INTO memory_chunks
37326
37350
  (id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
@@ -37331,7 +37355,7 @@ var MemoryStore = class {
37331
37355
  const chunk = chunks[i];
37332
37356
  try {
37333
37357
  const vectorBinding = this.toVectorBinding(embeddings[i]);
37334
- this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`).run(chunk.id);
37358
+ deleteChunk.run(chunk.id);
37335
37359
  insertChunk.run(
37336
37360
  chunk.id,
37337
37361
  chunk.source,
@@ -37360,11 +37384,10 @@ var MemoryStore = class {
37360
37384
  if (!embedding) throw new Error("missing embedding");
37361
37385
  if (!(embedding instanceof Float32Array)) throw new Error("missing embedding");
37362
37386
  if (embedding.length !== this.config.vectorDimension) throw new Error("embedding dimension mismatch");
37363
- const values = Array.from(embedding, (value) => {
37364
- if (!Number.isFinite(value)) throw new Error("embedding contains non-finite value");
37365
- return value;
37366
- });
37367
- return JSON.stringify(values);
37387
+ for (let i = 0; i < embedding.length; i++) {
37388
+ if (!Number.isFinite(embedding[i])) throw new Error("embedding contains non-finite value");
37389
+ }
37390
+ return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
37368
37391
  }
37369
37392
  normalizeVectorDistance(distance) {
37370
37393
  if (distance <= 0) return 1;
@@ -4,3 +4,6 @@ export declare const DEFAULT_MEMORY_CONFIG: MemoryConfig;
4
4
  export declare const LOCK_RETRY_MS = 50;
5
5
  export declare const LOCK_TIMEOUT_MS = 500;
6
6
  export declare const RRF_K = 60;
7
+ export declare const FETCH_MULTIPLIER = 4;
8
+ export declare const RECENCY_WEIGHT = 0.5;
9
+ export declare const DENSE_SCORE_FLOOR = 0.2;
@@ -16,3 +16,9 @@ export const DEFAULT_MEMORY_CONFIG = {
16
16
  export const LOCK_RETRY_MS = 50;
17
17
  export const LOCK_TIMEOUT_MS = 500;
18
18
  export const RRF_K = 60;
19
+ // Tunable starting default: fetchLimit = topK * FETCH_MULTIPLIER (was hardcoded topK * 2)
20
+ export const FETCH_MULTIPLIER = 4;
21
+ // Tunable starting default: weight on the recency RRF channel; < 1 so recency cannot outrank a dual-channel durable hit
22
+ export const RECENCY_WEIGHT = 0.5;
23
+ // Tunable starting default: min dense retrieval score [0,1] for a dense-only candidate to survive (permissive start)
24
+ export const DENSE_SCORE_FLOOR = 0.2;
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { chunkMarkdown } from './chunker.js';
4
- import { LOCK_RETRY_MS, LOCK_TIMEOUT_MS, RRF_K } from './constants.js';
4
+ import { DENSE_SCORE_FLOOR, FETCH_MULTIPLIER, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, RECENCY_WEIGHT, RRF_K, } from './constants.js';
5
5
  import { acquireLock, releaseLock } from '../../utils/files.js';
6
6
  export function deriveSourceType(source) {
7
7
  if (source.startsWith('compiled/provisional/conversation-digests/'))
@@ -118,7 +118,8 @@ export class MemoryIndexer {
118
118
  return totals;
119
119
  }
120
120
  async search(query, topK) {
121
- const fetchLimit = topK * 2;
121
+ // Task 2.2: overfetch to supply enough distinct sources after per-source dedup
122
+ const fetchLimit = topK * FETCH_MULTIPLIER;
122
123
  let denseResults = [];
123
124
  if (this.store.vecAvailable) {
124
125
  try {
@@ -130,32 +131,65 @@ export class MemoryIndexer {
130
131
  }
131
132
  }
132
133
  const bm25Results = this.store.searchBm25(query, fetchLimit);
133
- const bm25Ids = new Set(bm25Results.map((result) => result.id));
134
- if (bm25Ids.size > 0) {
135
- denseResults = denseResults.filter((result) => bm25Ids.has(result.id));
136
- }
134
+ // Task 2.1: candidate set = dense ∪ bm25 — no intersection filter
135
+ const denseIds = new Set(denseResults.map((r) => r.id));
136
+ const bm25Ids = new Set(bm25Results.map((r) => r.id));
137
+ const unionIds = new Set([...denseIds, ...bm25Ids]);
138
+ // Task 2.5: preserve raw dense retrieval scores [0,1] for the precision floor check
139
+ const denseRetrievalScore = new Map(denseResults.map((r) => [r.id, r.score]));
140
+ // Task 2.3: fetch all union candidates once — supplies recency mtime AND final content (no second DB call)
141
+ const unionArray = [...unionIds];
142
+ const chunks = this.store.getChunksByIds(unionArray);
143
+ const chunkMap = new Map(chunks.map((c) => [c.id, c]));
144
+ // Task 2.3: recency channel — rank union by fileMtimeAt DESC; stable tiebreak on union insertion order
145
+ const unionIndexOf = new Map(unionArray.map((id, idx) => [id, idx]));
146
+ const recencyOrdered = [...unionArray].sort((a, b) => {
147
+ const mtimeA = chunkMap.get(a)?.fileMtimeAt ?? 0;
148
+ const mtimeB = chunkMap.get(b)?.fileMtimeAt ?? 0;
149
+ if (mtimeB !== mtimeA)
150
+ return mtimeB - mtimeA;
151
+ return (unionIndexOf.get(a) ?? 0) - (unionIndexOf.get(b) ?? 0);
152
+ });
153
+ // RRF score accumulation: dense + bm25 + weighted recency channels
137
154
  const scoreMap = new Map();
138
155
  denseResults.forEach((r, rank) => {
139
- scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0 });
156
+ scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0, recency: 0 });
140
157
  });
141
158
  bm25Results.forEach((r, rank) => {
142
- const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
159
+ const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
143
160
  entry.bm25 = 1 / (RRF_K + rank + 1);
144
161
  scoreMap.set(r.id, entry);
145
162
  });
146
- const hasDense = this.store.vecAvailable && denseResults.length > 0;
147
- const numRetrievers = hasDense ? 2 : 1;
148
- const maxScore = numRetrievers / (RRF_K + 1);
163
+ recencyOrdered.forEach((id, rank) => {
164
+ const entry = scoreMap.get(id);
165
+ if (!entry)
166
+ return;
167
+ entry.recency = RECENCY_WEIGHT * (1 / (RRF_K + rank + 1));
168
+ });
169
+ // Task 2.4: active-channel normalization — maxScore sums weights of channels that produced ≥1 result
170
+ const denseActive = denseResults.length > 0;
171
+ const bm25Active = bm25Results.length > 0;
172
+ const recencyActive = unionIds.size > 0;
173
+ const maxScore = ((denseActive ? 1 : 0) + (bm25Active ? 1 : 0) + (recencyActive ? RECENCY_WEIGHT : 0)) / (RRF_K + 1);
174
+ // Task 2.5: build ranked list, dropping dense-only candidates below the precision floor
149
175
  const ranked = [...scoreMap.entries()]
150
176
  .map(([id, scores]) => ({
151
177
  id,
152
- totalScore: scores.dense + scores.bm25,
178
+ totalScore: scores.dense + scores.bm25 + scores.recency,
153
179
  hasDense: scores.dense > 0,
154
180
  hasBm25: scores.bm25 > 0,
181
+ isDenseOnly: denseIds.has(id) && !bm25Ids.has(id),
155
182
  }))
183
+ .filter((candidate) => {
184
+ if (!candidate.isDenseOnly)
185
+ return true;
186
+ const retrieval = denseRetrievalScore.get(candidate.id);
187
+ // Only drop when a dense retrieval score exists and is below the floor
188
+ if (retrieval === undefined)
189
+ return true;
190
+ return retrieval >= DENSE_SCORE_FLOOR;
191
+ })
156
192
  .sort((a, b) => b.totalScore - a.totalScore);
157
- const chunks = this.store.getChunksByIds(ranked.map((r) => r.id));
158
- const chunkMap = new Map(chunks.map((c) => [c.id, c]));
159
193
  const results = [];
160
194
  const seenSources = new Set();
161
195
  for (const r of ranked) {
@@ -266,8 +266,10 @@ describe('MemoryIndexer', () => {
266
266
  fs.rmSync(testDir, { recursive: true, force: true });
267
267
  }
268
268
  });
269
- test('search does not include dense-only results when BM25 has matches', async () => {
270
- const testCfg = makeConfig('/tmp/search-dense-filter');
269
+ // Task 3.1: Rewrite — asserts union behavior (previously asserted the removed intersection filter).
270
+ // BC1 + BC2: dense-only high-score candidate surfaces; dual-channel hit ranks first.
271
+ test('search includes high-score dense-only results alongside BM25 matches', async () => {
272
+ const testCfg = makeConfig('/tmp/search-union-behavior');
271
273
  const chunks = [
272
274
  {
273
275
  id: 'preference-1',
@@ -291,7 +293,7 @@ describe('MemoryIndexer', () => {
291
293
  const fakeStore = {
292
294
  vecAvailable: true,
293
295
  searchDense: () => [
294
- { id: 'dense-only-1', score: 0.99 },
296
+ { id: 'dense-only-1', score: 0.99 }, // score >= DENSE_SCORE_FLOOR (0.2) → survives floor
295
297
  { id: 'preference-1', score: 0.98 },
296
298
  ],
297
299
  searchBm25: () => [{ id: 'preference-1', score: 1 }],
@@ -299,8 +301,361 @@ describe('MemoryIndexer', () => {
299
301
  };
300
302
  const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
301
303
  const results = await testIndexer.search('personal likes and preferences of the user', 5);
302
- assert.deepEqual(results.map((result) => result.chunk.id), ['preference-1']);
304
+ const resultIds = results.map((r) => r.chunk.id);
305
+ assert.ok(resultIds.includes('preference-1'), `Expected preference-1 in results, got: ${resultIds.join(', ')}`);
306
+ assert.ok(resultIds.includes('dense-only-1'), `Expected dense-only-1 in results, got: ${resultIds.join(', ')}`);
307
+ assert.equal(results[0].chunk.id, 'preference-1', 'dual-channel hit must rank first');
303
308
  assert.equal(results[0].retriever, 'both');
309
+ const denseOnlyResult = results.find((r) => r.chunk.id === 'dense-only-1');
310
+ assert.ok(denseOnlyResult, 'dense-only-1 must be present in results');
311
+ assert.equal(denseOnlyResult.retriever, 'dense');
312
+ });
313
+ // Task 3.2 — Regression tests BC1–BC9
314
+ // BC3: durable dual-channel memory ranks above a newer single-channel chunk despite lower recency score
315
+ test('BC3: durable dual-channel chunk ranks above newer single-channel chunk', async () => {
316
+ const testCfg = makeConfig('/tmp/bc3-durable-vs-recent');
317
+ const chunks = [
318
+ {
319
+ id: 'durable-1',
320
+ source: 'compiled/preferences.md',
321
+ heading: '',
322
+ headingLevel: 0,
323
+ content: 'I prefer dark mode',
324
+ lineStart: 1,
325
+ lineEnd: 1,
326
+ fileMtimeAt: 100, // old mtime
327
+ },
328
+ {
329
+ id: 'recent-1',
330
+ source: 'compiled/entities/meeting.md',
331
+ heading: 'Meeting',
332
+ headingLevel: 1,
333
+ content: 'Meeting notes from today',
334
+ lineStart: 1,
335
+ lineEnd: 2,
336
+ fileMtimeAt: 9_999_999_999_999, // very new mtime
337
+ },
338
+ ];
339
+ const fakeStore = {
340
+ vecAvailable: true,
341
+ searchDense: () => [{ id: 'durable-1', score: 0.9 }],
342
+ searchBm25: () => [
343
+ { id: 'durable-1', score: 1 }, // rank 0 in bm25
344
+ { id: 'recent-1', score: 0.8 }, // rank 1 in bm25 — newer mtime but single bm25 channel
345
+ ],
346
+ getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
347
+ };
348
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
349
+ const results = await testIndexer.search('preferences', 5);
350
+ assert.ok(results.length >= 2, `Expected at least 2 results, got ${results.length}`);
351
+ assert.equal(results[0].chunk.id, 'durable-1', 'dual-channel durable memory must rank above newer single-channel chunk');
352
+ assert.equal(results[0].retriever, 'both');
353
+ const recentResult = results.find((r) => r.chunk.id === 'recent-1');
354
+ assert.ok(recentResult, 'recent-1 must still surface');
355
+ });
356
+ // BC4: search returns dense results when BM25 is empty, with recency channel applied
357
+ test('BC4: search returns dense results and applies recency when BM25 has no matches', async () => {
358
+ const testCfg = makeConfig('/tmp/bc4-dense-only-recency');
359
+ const chunks = [
360
+ {
361
+ id: 'semantic-old',
362
+ source: 'compiled/concepts/topic.md',
363
+ heading: 'Topic',
364
+ headingLevel: 1,
365
+ content: 'Concept about topic',
366
+ lineStart: 1,
367
+ lineEnd: 2,
368
+ fileMtimeAt: 1, // very old
369
+ },
370
+ {
371
+ id: 'semantic-new',
372
+ source: 'compiled/entities/recent-entity.md',
373
+ heading: 'Recent',
374
+ headingLevel: 1,
375
+ content: 'Recent entity content',
376
+ lineStart: 1,
377
+ lineEnd: 2,
378
+ fileMtimeAt: 9_999_999_999_999, // very new
379
+ },
380
+ ];
381
+ const fakeStore = {
382
+ vecAvailable: true,
383
+ searchDense: () => [
384
+ { id: 'semantic-old', score: 0.9 }, // dense rank 0
385
+ { id: 'semantic-new', score: 0.85 }, // dense rank 1
386
+ ],
387
+ searchBm25: () => [],
388
+ getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
389
+ };
390
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
391
+ const results = await testIndexer.search('topic', 5);
392
+ assert.ok(results.length >= 2, `Expected at least 2 dense results, got ${results.length}`);
393
+ const ids = results.map((r) => r.chunk.id);
394
+ assert.ok(ids.includes('semantic-old'), 'semantic-old must be present');
395
+ assert.ok(ids.includes('semantic-new'), 'semantic-new must be present');
396
+ for (const result of results) {
397
+ assert.equal(result.retriever, 'dense', `Expected retriever 'dense', got '${result.retriever}'`);
398
+ assert.ok(result.score > 0, 'Normalized score must be positive');
399
+ }
400
+ });
401
+ // BC5: dense channel unavailable — bm25 results returned with finite normalized score, no throw
402
+ test('BC5: bm25 results are returned with normalized score when dense channel is unavailable', async () => {
403
+ const testCfg = makeConfig('/tmp/bc5-dense-unavailable');
404
+ const chunks = [
405
+ {
406
+ id: 'keyword-1',
407
+ source: 'compiled/concepts/keyword.md',
408
+ heading: 'Keyword',
409
+ headingLevel: 1,
410
+ content: 'Keyword concept content',
411
+ lineStart: 1,
412
+ lineEnd: 2,
413
+ },
414
+ ];
415
+ const fakeStore = {
416
+ vecAvailable: false,
417
+ searchBm25: () => [{ id: 'keyword-1', score: 1 }],
418
+ getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
419
+ };
420
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
421
+ const results = await testIndexer.search('keyword', 5);
422
+ assert.equal(results.length, 1, 'Must return bm25 result when dense is unavailable');
423
+ assert.equal(results[0].chunk.id, 'keyword-1');
424
+ assert.equal(results[0].retriever, 'bm25');
425
+ assert.ok(Number.isFinite(results[0].score), 'Score must be finite');
426
+ assert.ok(results[0].score >= 0 && results[0].score <= 1, `Score must be in [0,1], got ${results[0].score}`);
427
+ });
428
+ // BC6: both channels empty → search returns []
429
+ test('BC6: search returns empty array when both dense and bm25 channels are empty', async () => {
430
+ const testCfg = makeConfig('/tmp/bc6-both-empty');
431
+ const fakeStore = {
432
+ vecAvailable: false,
433
+ searchBm25: () => [],
434
+ getChunksByIds: () => [],
435
+ };
436
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
437
+ const results = await testIndexer.search('anything', 5);
438
+ assert.deepEqual(results, []);
439
+ });
440
+ // BC7: per-source dedup yields up to topK distinct sources even when one source dominates the pool
441
+ test('BC7: per-source dedup yields topK distinct sources when one source dominates the pool', async () => {
442
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bc7-overfetch-'));
443
+ const testCfg = makeConfig(path.join(testDir, 'wiki'));
444
+ const allChunks = [
445
+ {
446
+ id: 'dom-1',
447
+ source: 'compiled/entities/dominant.md',
448
+ heading: '',
449
+ headingLevel: 0,
450
+ content: 'dom 1',
451
+ lineStart: 1,
452
+ lineEnd: 1,
453
+ },
454
+ {
455
+ id: 'dom-2',
456
+ source: 'compiled/entities/dominant.md',
457
+ heading: '',
458
+ headingLevel: 0,
459
+ content: 'dom 2',
460
+ lineStart: 2,
461
+ lineEnd: 2,
462
+ },
463
+ {
464
+ id: 'dom-3',
465
+ source: 'compiled/entities/dominant.md',
466
+ heading: '',
467
+ headingLevel: 0,
468
+ content: 'dom 3',
469
+ lineStart: 3,
470
+ lineEnd: 3,
471
+ },
472
+ {
473
+ id: 'dom-4',
474
+ source: 'compiled/entities/dominant.md',
475
+ heading: '',
476
+ headingLevel: 0,
477
+ content: 'dom 4',
478
+ lineStart: 4,
479
+ lineEnd: 4,
480
+ },
481
+ {
482
+ id: 'dom-5',
483
+ source: 'compiled/entities/dominant.md',
484
+ heading: '',
485
+ headingLevel: 0,
486
+ content: 'dom 5',
487
+ lineStart: 5,
488
+ lineEnd: 5,
489
+ },
490
+ {
491
+ id: 'src-b-1',
492
+ source: 'compiled/entities/source-b.md',
493
+ heading: '',
494
+ headingLevel: 0,
495
+ content: 'source b',
496
+ lineStart: 1,
497
+ lineEnd: 1,
498
+ },
499
+ {
500
+ id: 'src-c-1',
501
+ source: 'compiled/entities/source-c.md',
502
+ heading: '',
503
+ headingLevel: 0,
504
+ content: 'source c',
505
+ lineStart: 1,
506
+ lineEnd: 1,
507
+ },
508
+ ];
509
+ const fakeStore = {
510
+ vecAvailable: false,
511
+ searchBm25: () => [
512
+ { id: 'dom-1', score: 5 },
513
+ { id: 'dom-2', score: 4.9 },
514
+ { id: 'dom-3', score: 4.8 },
515
+ { id: 'dom-4', score: 4.7 },
516
+ { id: 'dom-5', score: 4.6 },
517
+ { id: 'src-b-1', score: 3 },
518
+ { id: 'src-c-1', score: 2 },
519
+ ],
520
+ getChunksByIds: (ids) => allChunks.filter((c) => ids.includes(c.id)),
521
+ };
522
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
523
+ try {
524
+ const entitiesDir = path.join(testCfg.wikiDir, 'compiled', 'entities');
525
+ fs.mkdirSync(entitiesDir, { recursive: true });
526
+ fs.writeFileSync(path.join(entitiesDir, 'dominant.md'), 'dominant content', 'utf8');
527
+ fs.writeFileSync(path.join(entitiesDir, 'source-b.md'), 'source b content', 'utf8');
528
+ fs.writeFileSync(path.join(entitiesDir, 'source-c.md'), 'source c content', 'utf8');
529
+ const results = await testIndexer.search('query', 3);
530
+ assert.equal(results.length, 3, `Expected 3 results, got ${results.length}`);
531
+ const sources = results.map((r) => r.chunk.source);
532
+ const uniqueSources = new Set(sources);
533
+ assert.equal(uniqueSources.size, 3, 'Each result must be from a distinct source');
534
+ assert.ok(sources.includes('compiled/entities/dominant.md'), 'dominant source must be present');
535
+ assert.ok(sources.includes('compiled/entities/source-b.md'), 'source-b must be present');
536
+ assert.ok(sources.includes('compiled/entities/source-c.md'), 'source-c must be present');
537
+ }
538
+ finally {
539
+ fs.rmSync(testDir, { recursive: true, force: true });
540
+ }
541
+ });
542
+ // BC8: dense-only candidate below DENSE_SCORE_FLOOR (0.2) is dropped; at/above floor is kept; bm25-only never dropped
543
+ test('BC8: dense-only candidate below DENSE_SCORE_FLOOR is dropped; bm25-only and above-floor are kept', async () => {
544
+ const testCfg = makeConfig('/tmp/bc8-floor');
545
+ const chunks = [
546
+ {
547
+ id: 'below-floor',
548
+ source: 'compiled/entities/below.md',
549
+ heading: '',
550
+ headingLevel: 0,
551
+ content: 'below floor dense',
552
+ lineStart: 1,
553
+ lineEnd: 1,
554
+ },
555
+ {
556
+ id: 'above-floor',
557
+ source: 'compiled/entities/above.md',
558
+ heading: '',
559
+ headingLevel: 0,
560
+ content: 'above floor dense',
561
+ lineStart: 1,
562
+ lineEnd: 1,
563
+ },
564
+ {
565
+ id: 'bm25-overlap',
566
+ source: 'compiled/concepts/overlap.md',
567
+ heading: '',
568
+ headingLevel: 0,
569
+ content: 'overlap content',
570
+ lineStart: 1,
571
+ lineEnd: 1,
572
+ },
573
+ {
574
+ id: 'bm25-only',
575
+ source: 'compiled/concepts/keyword-only.md',
576
+ heading: '',
577
+ headingLevel: 0,
578
+ content: 'keyword only',
579
+ lineStart: 1,
580
+ lineEnd: 1,
581
+ },
582
+ ];
583
+ const fakeStore = {
584
+ vecAvailable: true,
585
+ // below-floor: dense-only, score 0.1 < DENSE_SCORE_FLOOR (0.2) → must be dropped
586
+ // above-floor: dense-only, score 0.9 >= DENSE_SCORE_FLOOR (0.2) → must be kept
587
+ // bm25-overlap: in both channels → never dropped regardless of dense score
588
+ searchDense: () => [
589
+ { id: 'below-floor', score: 0.1 },
590
+ { id: 'above-floor', score: 0.9 },
591
+ { id: 'bm25-overlap', score: 0.3 },
592
+ ],
593
+ searchBm25: () => [
594
+ { id: 'bm25-overlap', score: 1 },
595
+ { id: 'bm25-only', score: 0.8 },
596
+ ],
597
+ getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
598
+ };
599
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
600
+ const results = await testIndexer.search('query', 10);
601
+ const resultIds = results.map((r) => r.chunk.id);
602
+ assert.ok(!resultIds.includes('below-floor'), `below-floor (score 0.1 < 0.2) must be dropped; got: ${resultIds.join(', ')}`);
603
+ assert.ok(resultIds.includes('above-floor'), `above-floor (score 0.9 >= 0.2) must be kept; got: ${resultIds.join(', ')}`);
604
+ assert.ok(resultIds.includes('bm25-overlap'), `bm25-overlap (dual-channel) must always be kept`);
605
+ assert.ok(resultIds.includes('bm25-only'), `bm25-only must never be dropped by the floor`);
606
+ });
607
+ // BC9: recency tiebreak is stable — repeated calls with tied/absent fileMtimeAt return identical order
608
+ test('BC9: repeated search calls with equal-mtime candidates return deterministic order', async () => {
609
+ const testCfg = makeConfig('/tmp/bc9-determinism');
610
+ const chunks = [
611
+ {
612
+ id: 'c1',
613
+ source: 'compiled/entities/c1.md',
614
+ heading: '',
615
+ headingLevel: 0,
616
+ content: 'c1 content',
617
+ lineStart: 1,
618
+ lineEnd: 1,
619
+ fileMtimeAt: 0,
620
+ },
621
+ {
622
+ id: 'c2',
623
+ source: 'compiled/entities/c2.md',
624
+ heading: '',
625
+ headingLevel: 0,
626
+ content: 'c2 content',
627
+ lineStart: 1,
628
+ lineEnd: 1,
629
+ fileMtimeAt: 0,
630
+ },
631
+ {
632
+ id: 'c3',
633
+ source: 'compiled/entities/c3.md',
634
+ heading: '',
635
+ headingLevel: 0,
636
+ content: 'c3 content',
637
+ lineStart: 1,
638
+ lineEnd: 1,
639
+ fileMtimeAt: 0,
640
+ },
641
+ ];
642
+ const fakeStore = {
643
+ vecAvailable: true,
644
+ searchDense: () => [
645
+ { id: 'c1', score: 0.9 },
646
+ { id: 'c2', score: 0.85 },
647
+ { id: 'c3', score: 0.8 },
648
+ ],
649
+ searchBm25: () => [],
650
+ getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
651
+ };
652
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
653
+ const firstCall = await testIndexer.search('query', 5);
654
+ const secondCall = await testIndexer.search('query', 5);
655
+ assert.equal(firstCall.length, secondCall.length, 'Result count must be identical across calls');
656
+ for (let idx = 0; idx < firstCall.length; idx++) {
657
+ assert.equal(firstCall[idx].chunk.id, secondCall[idx].chunk.id, `Position ${idx} must be deterministic: got '${firstCall[idx].chunk.id}' vs '${secondCall[idx].chunk.id}'`);
658
+ }
304
659
  });
305
660
  test('search still returns dense-only results when BM25 has no matches', async () => {
306
661
  const testCfg = makeConfig('/tmp/search-dense-fallback');
@@ -146,6 +146,7 @@ export class MemoryStore {
146
146
  return 0;
147
147
  }
148
148
  upsert(chunks, embeddings) {
149
+ const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
149
150
  const insertChunk = this.db.prepare(`
150
151
  INSERT OR REPLACE INTO memory_chunks
151
152
  (id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
@@ -157,7 +158,7 @@ export class MemoryStore {
157
158
  try {
158
159
  const vectorBinding = this.toVectorBinding(embeddings[i]);
159
160
  // Delete existing row first to get correct rowid
160
- this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`).run(chunk.id);
161
+ deleteChunk.run(chunk.id);
161
162
  insertChunk.run(chunk.id, chunk.source, chunk.sourceType, chunk.heading, chunk.headingLevel, chunk.content, chunk.lineStart, chunk.lineEnd, Date.now(), chunk.fileMtimeAt, vectorBinding);
162
163
  }
163
164
  catch (err) {
@@ -179,12 +180,11 @@ export class MemoryStore {
179
180
  throw new Error('missing embedding');
180
181
  if (embedding.length !== this.config.vectorDimension)
181
182
  throw new Error('embedding dimension mismatch');
182
- const values = Array.from(embedding, (value) => {
183
- if (!Number.isFinite(value))
183
+ for (let i = 0; i < embedding.length; i++) {
184
+ if (!Number.isFinite(embedding[i]))
184
185
  throw new Error('embedding contains non-finite value');
185
- return value;
186
- });
187
- return JSON.stringify(values);
186
+ }
187
+ return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
188
188
  }
189
189
  normalizeVectorDistance(distance) {
190
190
  if (distance <= 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanhnd/agent-kit",
3
- "version": "1.0.37",
3
+ "version": "1.0.38",
4
4
  "description": "Agent Kit MCP server for software development agent workflows",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",