@hanhnd/agent-kit 1.0.38 → 1.0.40

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.
@@ -45,6 +45,7 @@ function makeIndexerStub(overrides = {}) {
45
45
  return {
46
46
  search: overrides.search ?? (async () => []),
47
47
  save: overrides.save ?? (async () => ({ indexed: 1, deleted: 0, skipped: 0 })),
48
+ startupIndex: overrides.startupIndex ?? (async () => undefined),
48
49
  };
49
50
  }
50
51
  function makeStoreStub(vecAvailable, overrides = {}) {
@@ -176,6 +177,44 @@ describe('kit_memory_search', () => {
176
177
  await tools.get('kit_memory_search')({ query: 'test', top_k: 3 });
177
178
  assert.equal(capturedTopK, 3);
178
179
  });
180
+ test('returns a warming note while subsystem warmup has not completed', async () => {
181
+ const indexer = makeIndexerStub({ search: async () => [] });
182
+ const store = makeStoreStub(true);
183
+ const { server, tools } = makeMockServer();
184
+ registerMemoryTools(server, '/tmp', {
185
+ settings: { memory: { enabled: true } },
186
+ indexer,
187
+ store,
188
+ config: BASE_CONFIG,
189
+ });
190
+ const result = await tools.get('kit_memory_search')({ query: 'test' });
191
+ const text = extractText(result);
192
+ assert.ok(text.includes('Memory index is warming'), `Expected warming note, got: ${text}`);
193
+ });
194
+ test('returns degraded note after warmup failure while preserving search results', async () => {
195
+ const chunk = makeChunk({ content: 'Degraded result', source: 'compiled/entities/degraded.md' });
196
+ const indexer = makeIndexerStub({
197
+ search: async () => [{ chunk, score: 0.7, retriever: 'bm25', contentSource: 'file' }],
198
+ startupIndex: async () => {
199
+ throw new Error('warmup failed');
200
+ },
201
+ });
202
+ const store = makeStoreStub(true);
203
+ const { server, tools } = makeMockServer();
204
+ const subsystem = registerMemoryTools(server, '/tmp', {
205
+ settings: { memory: { enabled: true } },
206
+ indexer,
207
+ store,
208
+ config: BASE_CONFIG,
209
+ });
210
+ assert.ok(subsystem);
211
+ await subsystem.startWarmup();
212
+ const result = await tools.get('kit_memory_search')({ query: 'test' });
213
+ const text = extractText(result);
214
+ assert.ok(text.includes('Memory index is degraded'), `Expected degraded note, got: ${text}`);
215
+ assert.ok(text.includes('warmup failed'), `Expected warmup error, got: ${text}`);
216
+ assert.ok(text.includes('Degraded result'), 'Result must still be included during degraded state');
217
+ });
179
218
  test('falls back to config.topK when top_k is not provided', async () => {
180
219
  let capturedTopK;
181
220
  const indexer = makeIndexerStub({
@@ -214,6 +253,25 @@ describe('kit_memory_search', () => {
214
253
  assert.ok(text.includes('kit_memory_search failed'), `Expected error prefix, got: ${text}`);
215
254
  assert.ok(text.includes('search exploded'), `Expected error message, got: ${text}`);
216
255
  });
256
+ test('returns initialization failure when store construction fails', async () => {
257
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-init-failure-'));
258
+ const occupiedWikiPath = path.join(tmpDir, 'wiki-file');
259
+ fs.writeFileSync(occupiedWikiPath, 'not a directory', 'utf8');
260
+ try {
261
+ const { server, tools } = makeMockServer();
262
+ const subsystem = registerMemoryTools(server, tmpDir, {
263
+ settings: { memory: { enabled: true } },
264
+ config: { ...BASE_CONFIG, wikiDir: occupiedWikiPath },
265
+ });
266
+ assert.ok(subsystem);
267
+ const result = await tools.get('kit_memory_search')({ query: 'test' });
268
+ const text = extractText(result);
269
+ assert.ok(text.includes('Memory initialization failed'), `Expected initialization failure, got: ${text}`);
270
+ }
271
+ finally {
272
+ fs.rmSync(tmpDir, { recursive: true, force: true });
273
+ }
274
+ });
217
275
  test('description routes factual and temporal queries distinctly', () => {
218
276
  const indexer = makeIndexerStub();
219
277
  const store = makeStoreStub(true);
@@ -1,4 +1,4 @@
1
- import type { MemoryChunk, MemoryConfig, RecentSource, SourceType } from './types.js';
1
+ import type { HybridSearchOptions, HybridSearchRow, MemoryChunk, MemoryConfig, RecentSource, SourceType } from './types.js';
2
2
  export declare const SCHEMA_VERSION = 4;
3
3
  export declare class StoreError extends Error {
4
4
  constructor(message: string, cause?: unknown);
@@ -13,7 +13,6 @@ export declare class MemoryStore {
13
13
  private readUserVersion;
14
14
  upsert(chunks: MemoryChunk[], embeddings: Float32Array[]): void;
15
15
  private toVectorBinding;
16
- private normalizeVectorDistance;
17
16
  hashesBySource(source: string): Set<string>;
18
17
  indexedSources(): string[];
19
18
  getRecentSources(options: {
@@ -22,15 +21,9 @@ export declare class MemoryStore {
22
21
  }): RecentSource[];
23
22
  deleteBySource(source: string): void;
24
23
  deleteByIds(ids: string[]): void;
25
- searchDense(embedding: Float32Array, limit: number): Array<{
26
- id: string;
27
- score: number;
28
- }>;
29
- searchBm25(query: string, limit: number): Array<{
30
- id: string;
31
- score: number;
32
- }>;
33
- getChunksByIds(ids: string[]): MemoryChunk[];
24
+ private buildFtsQuery;
25
+ private mapMemoryChunkRow;
26
+ searchHybrid(options: HybridSearchOptions): HybridSearchRow[];
34
27
  get vecAvailable(): boolean;
35
28
  close(): void;
36
29
  }
@@ -186,11 +186,6 @@ export class MemoryStore {
186
186
  }
187
187
  return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
188
188
  }
189
- normalizeVectorDistance(distance) {
190
- if (distance <= 0)
191
- return 1;
192
- return Math.max(0, Math.min(1, 1 - distance));
193
- }
194
189
  hashesBySource(source) {
195
190
  const rows = this.db.prepare(`SELECT id FROM memory_chunks WHERE source = ?`).all(source);
196
191
  return new Set(rows.map((r) => r.id));
@@ -229,74 +224,18 @@ export class MemoryStore {
229
224
  });
230
225
  deleteAll();
231
226
  }
232
- searchDense(embedding, limit) {
233
- try {
234
- const queryVector = this.toVectorBinding(embedding);
235
- const rows = this.db
236
- .prepare(`SELECT mc.id, vector_distance_cos(mc.embedding, vector32(?)) AS distance
237
- FROM vector_top_k('idx_memory_chunks_embedding', vector32(?), ?) AS vector_matches
238
- JOIN memory_chunks mc ON mc.rowid = vector_matches.id
239
- ORDER BY distance
240
- LIMIT ?`)
241
- .all(queryVector, queryVector, limit, limit);
242
- return rows.map((r) => ({
243
- id: r.id,
244
- score: this.normalizeVectorDistance(r.distance),
245
- }));
246
- }
247
- catch (err) {
248
- console.warn('[memory-store] Dense search failed:', err);
249
- return [];
250
- }
251
- }
252
- searchBm25(query, limit) {
253
- if (!query.trim())
254
- return [];
227
+ buildFtsQuery(query) {
255
228
  const tokens = query
256
229
  .trim()
257
230
  .split(/\s+/)
258
231
  .map((t) => t.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase())
259
232
  .filter((t) => t.length > 1);
260
- const ftsQuery = removeStopwords(tokens, eng)
233
+ return removeStopwords(tokens, eng)
261
234
  .map((t) => `${t.replace(/s$/, '')}*`)
262
235
  .join(' OR ');
263
- if (!ftsQuery)
264
- return [];
265
- try {
266
- const rows = this.db
267
- .prepare(`SELECT mc.id, bm25(memory_fts, 0.2, 2.0, 5.0) AS rank
268
- FROM memory_fts
269
- JOIN memory_chunks mc ON mc.rowid = memory_fts.rowid
270
- WHERE memory_fts MATCH ?
271
- ORDER BY rank
272
- LIMIT ?`)
273
- .all(ftsQuery, limit);
274
- if (rows.length === 0)
275
- return [];
276
- // BM25 rank is negative in SQLite FTS5 (lower = more relevant)
277
- const ranks = rows.map((r) => r.rank);
278
- const minRank = Math.min(...ranks);
279
- const maxRank = Math.max(...ranks);
280
- const range = maxRank - minRank;
281
- return rows.map((r) => ({
282
- id: r.id,
283
- score: range > 0 ? (maxRank - r.rank) / range : 1,
284
- }));
285
- }
286
- catch (err) {
287
- console.warn('[memory-store] BM25 search failed:', err);
288
- return [];
289
- }
290
236
  }
291
- getChunksByIds(ids) {
292
- if (ids.length === 0)
293
- return [];
294
- const placeholders = ids.map(() => '?').join(', ');
295
- const rows = this.db
296
- .prepare(`SELECT id, source, source_type, heading, heading_level, content, line_start, line_end, file_mtime_at
297
- FROM memory_chunks WHERE id IN (${placeholders})`)
298
- .all(...ids);
299
- return rows.map((r) => ({
237
+ mapMemoryChunkRow(r) {
238
+ return {
300
239
  id: r.id,
301
240
  source: r.source,
302
241
  sourceType: r.source_type,
@@ -306,7 +245,202 @@ export class MemoryStore {
306
245
  lineStart: r.line_start,
307
246
  lineEnd: r.line_end,
308
247
  fileMtimeAt: r.file_mtime_at,
309
- }));
248
+ };
249
+ }
250
+ searchHybrid(options) {
251
+ const { query, embedding, topK, fetchLimit, denseScoreFloor, recencyWeight, rrfK, sourceType, sinceMtimeAt, includeDebug, } = options;
252
+ const ftsQuery = this.buildFtsQuery(query);
253
+ const hasDense = embedding !== undefined;
254
+ const hasBm25 = ftsQuery.length > 0;
255
+ if (!hasDense && !hasBm25)
256
+ return [];
257
+ const hasFilters = sourceType !== undefined || sinceMtimeAt !== undefined;
258
+ try {
259
+ const params = [];
260
+ let denseCte = '';
261
+ let denseRankedCte = '';
262
+ if (hasDense) {
263
+ const queryBuf = this.toVectorBinding(embedding);
264
+ if (hasFilters) {
265
+ // Exact filtered scan — avoids losing candidates due to ANN post-filtering
266
+ const filterClauses = [];
267
+ if (sourceType) {
268
+ filterClauses.push('mc.source_type = ?');
269
+ params.push(sourceType);
270
+ }
271
+ if (sinceMtimeAt !== undefined) {
272
+ filterClauses.push('mc.file_mtime_at >= ?');
273
+ params.push(sinceMtimeAt);
274
+ }
275
+ const whereClause = filterClauses.length > 0 ? `WHERE ${filterClauses.join(' AND ')}` : '';
276
+ params.push(queryBuf); // vector_distance_cos arg
277
+ params.push(fetchLimit);
278
+ denseCte = `dense_data AS (
279
+ SELECT mc.id, mc.source, mc.source_type, mc.file_mtime_at,
280
+ mc.heading, mc.heading_level, mc.content, mc.line_start, mc.line_end,
281
+ vector_distance_cos(mc.embedding, vector32(?)) AS dense_dist
282
+ FROM memory_chunks mc
283
+ ${whereClause}
284
+ ORDER BY dense_dist
285
+ LIMIT ?
286
+ )`;
287
+ }
288
+ else {
289
+ params.push(queryBuf); // vector_distance_cos arg
290
+ params.push(queryBuf); // vector_top_k arg
291
+ params.push(fetchLimit);
292
+ denseCte = `dense_data AS (
293
+ SELECT mc.id, mc.source, mc.source_type, mc.file_mtime_at,
294
+ mc.heading, mc.heading_level, mc.content, mc.line_start, mc.line_end,
295
+ vector_distance_cos(mc.embedding, vector32(?)) AS dense_dist
296
+ FROM vector_top_k('idx_memory_chunks_embedding', vector32(?), ?) AS vtk
297
+ JOIN memory_chunks mc ON mc.rowid = vtk.id
298
+ )`;
299
+ }
300
+ denseRankedCte = `dense_ranked AS (
301
+ SELECT *, ROW_NUMBER() OVER (ORDER BY dense_dist, id) AS dense_rank
302
+ FROM dense_data
303
+ )`;
304
+ }
305
+ let bm25Cte = '';
306
+ let bm25RankedCte = '';
307
+ if (hasBm25) {
308
+ const bm25FilterClauses = [];
309
+ if (sourceType)
310
+ bm25FilterClauses.push('mc.source_type = ?');
311
+ if (sinceMtimeAt !== undefined)
312
+ bm25FilterClauses.push('mc.file_mtime_at >= ?');
313
+ const bm25WhereExtra = bm25FilterClauses.length > 0 ? ` AND ${bm25FilterClauses.join(' AND ')}` : '';
314
+ params.push(ftsQuery);
315
+ if (sourceType)
316
+ params.push(sourceType);
317
+ if (sinceMtimeAt !== undefined)
318
+ params.push(sinceMtimeAt);
319
+ params.push(fetchLimit);
320
+ bm25Cte = `bm25_data AS (
321
+ SELECT mc.id, mc.source, mc.source_type, mc.file_mtime_at,
322
+ mc.heading, mc.heading_level, mc.content, mc.line_start, mc.line_end,
323
+ bm25(memory_fts, 0.2, 2.0, 5.0) AS bm25_neg
324
+ FROM memory_fts
325
+ JOIN memory_chunks mc ON mc.rowid = memory_fts.rowid
326
+ WHERE memory_fts MATCH ?${bm25WhereExtra}
327
+ LIMIT ?
328
+ )`;
329
+ bm25RankedCte = `bm25_ranked AS (
330
+ SELECT *, ROW_NUMBER() OVER (ORDER BY bm25_neg, id) AS bm25_rank
331
+ FROM bm25_data
332
+ )`;
333
+ }
334
+ let candidatesCte = '';
335
+ if (hasDense && hasBm25) {
336
+ candidatesCte = `all_candidates AS (
337
+ SELECT d.id, d.source, d.source_type, d.file_mtime_at,
338
+ d.heading, d.heading_level, d.content, d.line_start, d.line_end,
339
+ CAST(d.dense_rank AS INTEGER) AS dense_rank, d.dense_dist,
340
+ b.bm25_rank,
341
+ CASE WHEN b.id IS NOT NULL THEN 'both' ELSE 'dense' END AS retriever
342
+ FROM dense_ranked d
343
+ LEFT JOIN bm25_ranked b ON b.id = d.id
344
+ UNION ALL
345
+ SELECT b.id, b.source, b.source_type, b.file_mtime_at,
346
+ b.heading, b.heading_level, b.content, b.line_start, b.line_end,
347
+ NULL AS dense_rank, NULL AS dense_dist,
348
+ CAST(b.bm25_rank AS INTEGER) AS bm25_rank,
349
+ 'bm25' AS retriever
350
+ FROM bm25_ranked b
351
+ LEFT JOIN dense_ranked d ON d.id = b.id
352
+ WHERE d.id IS NULL
353
+ )`;
354
+ }
355
+ else if (hasDense) {
356
+ candidatesCte = `all_candidates AS (
357
+ SELECT id, source, source_type, file_mtime_at,
358
+ heading, heading_level, content, line_start, line_end,
359
+ CAST(dense_rank AS INTEGER) AS dense_rank, dense_dist,
360
+ NULL AS bm25_rank, 'dense' AS retriever
361
+ FROM dense_ranked
362
+ )`;
363
+ }
364
+ else {
365
+ candidatesCte = `all_candidates AS (
366
+ SELECT id, source, source_type, file_mtime_at,
367
+ heading, heading_level, content, line_start, line_end,
368
+ NULL AS dense_rank, NULL AS dense_dist,
369
+ CAST(bm25_rank AS INTEGER) AS bm25_rank, 'bm25' AS retriever
370
+ FROM bm25_ranked
371
+ )`;
372
+ }
373
+ // Scoring: RRF over active channels + recency, dense floor, source dedup
374
+ params.push(rrfK, rrfK, recencyWeight, rrfK, denseScoreFloor, topK);
375
+ const sql = `
376
+ WITH
377
+ ${[denseCte, denseRankedCte, bm25Cte, bm25RankedCte, candidatesCte].filter(Boolean).join(',\n')}
378
+ ,
379
+ recency_ranked AS (
380
+ SELECT id, ROW_NUMBER() OVER (ORDER BY file_mtime_at DESC, source, id) AS recency_rank
381
+ FROM all_candidates
382
+ ),
383
+ scored AS (
384
+ SELECT c.id, c.source, c.source_type, c.file_mtime_at,
385
+ c.heading, c.heading_level, c.content, c.line_start, c.line_end,
386
+ c.dense_rank, c.dense_dist, c.bm25_rank, r.recency_rank,
387
+ c.retriever,
388
+ COALESCE(1.0 / (? + c.dense_rank), 0.0) +
389
+ COALESCE(1.0 / (? + c.bm25_rank), 0.0) +
390
+ ? * (1.0 / (? + r.recency_rank)) AS total_score
391
+ FROM all_candidates c
392
+ JOIN recency_ranked r ON r.id = c.id
393
+ WHERE c.retriever != 'dense' OR (1.0 - c.dense_dist) >= ?
394
+ ),
395
+ best_per_source AS (
396
+ SELECT *,
397
+ ROW_NUMBER() OVER (PARTITION BY source ORDER BY total_score DESC, source, id) AS source_rank
398
+ FROM scored
399
+ )
400
+ SELECT id, source, source_type, file_mtime_at, heading, heading_level, content, line_start, line_end,
401
+ dense_rank, dense_dist, bm25_rank, recency_rank, total_score, retriever
402
+ FROM best_per_source
403
+ WHERE source_rank = 1
404
+ ORDER BY total_score DESC, source, id
405
+ LIMIT ?
406
+ `;
407
+ const rows = this.db.prepare(sql).all(...params);
408
+ if (rows.length === 0)
409
+ return [];
410
+ const denseActive = hasDense;
411
+ const bm25Active = hasBm25;
412
+ const recencyActive = rows.length > 0;
413
+ const maxScore = ((denseActive ? 1 : 0) + (bm25Active ? 1 : 0) + (recencyActive ? recencyWeight : 0)) / (rrfK + 1);
414
+ return rows.map((r) => {
415
+ const chunk = this.mapMemoryChunkRow(r);
416
+ const normalizedScore = maxScore > 0 ? r.total_score / maxScore : 0;
417
+ const row = {
418
+ chunk,
419
+ score: normalizedScore,
420
+ retriever: r.retriever,
421
+ };
422
+ if (includeDebug) {
423
+ const denseScore = r.dense_dist !== null ? Math.max(0, Math.min(1, 1 - r.dense_dist)) : undefined;
424
+ const bm25Score = r.bm25_rank !== null ? 1 / (rrfK + r.bm25_rank) : undefined;
425
+ const recencyScore = recencyWeight * (1 / (rrfK + r.recency_rank));
426
+ const debug = {
427
+ denseRank: r.dense_rank ?? undefined,
428
+ denseScore,
429
+ bm25Rank: r.bm25_rank ?? undefined,
430
+ bm25Score,
431
+ recencyRank: r.recency_rank,
432
+ recencyScore,
433
+ totalScore: r.total_score,
434
+ };
435
+ row.debug = debug;
436
+ }
437
+ return row;
438
+ });
439
+ }
440
+ catch (err) {
441
+ console.warn('[memory-store] Hybrid search failed:', err);
442
+ return [];
443
+ }
310
444
  }
311
445
  get vecAvailable() {
312
446
  return true;