@hanhnd/agent-kit 1.0.37 → 1.0.39
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 +241 -112
- package/dist/mcp/memory.js +1 -1
- package/dist/server.js +244 -115
- package/dist/services/digest/digest-processor.test.js +1 -3
- package/dist/services/memory/constants.d.ts +3 -0
- package/dist/services/memory/constants.js +6 -0
- package/dist/services/memory/indexer.js +37 -54
- package/dist/services/memory/indexer.test.js +229 -231
- package/dist/services/memory/store.d.ts +4 -11
- package/dist/services/memory/store.js +206 -72
- package/dist/services/memory/store.test.js +359 -108
- package/dist/services/memory/types.d.ts +28 -0
- package/package.json +1 -1
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import * as assert from 'node:assert/strict';
|
|
2
|
-
import fsDefault from 'node:fs';
|
|
3
2
|
import * as fs from 'node:fs';
|
|
4
|
-
import { syncBuiltinESMExports } from 'node:module';
|
|
5
3
|
import * as os from 'node:os';
|
|
6
4
|
import * as path from 'node:path';
|
|
7
5
|
import { after, before, describe, test } from 'node:test';
|
|
8
6
|
import { deriveSourceType, MemoryIndexer } from './indexer.js';
|
|
9
7
|
import { MemoryStore } from './store.js';
|
|
10
|
-
import { EmbeddingModelName } from './types.js';
|
|
8
|
+
import { EmbeddingModelName, } from './types.js';
|
|
9
|
+
import { DENSE_SCORE_FLOOR, FETCH_MULTIPLIER, RECENCY_WEIGHT, RRF_K } from './constants.js';
|
|
11
10
|
class StubEmbedder {
|
|
12
11
|
async embed(texts) {
|
|
13
12
|
return texts.map(() => new Float32Array(384).fill(0.05));
|
|
@@ -16,6 +15,14 @@ class StubEmbedder {
|
|
|
16
15
|
return Promise.resolve();
|
|
17
16
|
}
|
|
18
17
|
}
|
|
18
|
+
class FailEmbedder {
|
|
19
|
+
async embed() {
|
|
20
|
+
throw new Error('embed failed');
|
|
21
|
+
}
|
|
22
|
+
initialize() {
|
|
23
|
+
return Promise.resolve();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
19
26
|
function makeConfig(wikiDir) {
|
|
20
27
|
return {
|
|
21
28
|
enabled: true,
|
|
@@ -27,6 +34,23 @@ function makeConfig(wikiDir) {
|
|
|
27
34
|
vectorDimension: 384,
|
|
28
35
|
};
|
|
29
36
|
}
|
|
37
|
+
function makeChunkFull(overrides = {}) {
|
|
38
|
+
return {
|
|
39
|
+
id: 'test-id',
|
|
40
|
+
source: 'compiled/entities/test.md',
|
|
41
|
+
sourceType: 'entity',
|
|
42
|
+
heading: 'Test',
|
|
43
|
+
headingLevel: 1,
|
|
44
|
+
content: 'test content',
|
|
45
|
+
lineStart: 1,
|
|
46
|
+
lineEnd: 2,
|
|
47
|
+
fileMtimeAt: 1000,
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function makeHybridRow(chunk, retriever = 'both') {
|
|
52
|
+
return { chunk, score: 0.8, retriever };
|
|
53
|
+
}
|
|
30
54
|
describe('MemoryIndexer', () => {
|
|
31
55
|
let tmpDir;
|
|
32
56
|
let store;
|
|
@@ -52,9 +76,7 @@ describe('MemoryIndexer', () => {
|
|
|
52
76
|
test('indexFile on unchanged file — indexed === 0, skipped > 0', async () => {
|
|
53
77
|
const filePath = path.join(tmpDir, 'stable-file.md');
|
|
54
78
|
fs.writeFileSync(filePath, '# Stable\nThis content does not change between runs.', 'utf8');
|
|
55
|
-
// First run indexes it
|
|
56
79
|
await indexer.indexFile(filePath);
|
|
57
|
-
// Second run — same content
|
|
58
80
|
const stats = await indexer.indexFile(filePath);
|
|
59
81
|
assert.equal(stats.indexed, 0, `Expected indexed === 0, got ${stats.indexed}`);
|
|
60
82
|
assert.ok(stats.skipped > 0, `Expected skipped > 0, got ${stats.skipped}`);
|
|
@@ -74,29 +96,121 @@ describe('MemoryIndexer', () => {
|
|
|
74
96
|
const staleSource = path.relative(tmpDir, staleFile);
|
|
75
97
|
const before = store.hashesBySource(staleSource);
|
|
76
98
|
assert.ok(before.size > 0, 'Stale file must be indexed first');
|
|
77
|
-
// Delete the file and re-index the directory
|
|
78
99
|
fs.unlinkSync(staleFile);
|
|
79
100
|
await indexer.indexDirectory(tmpDir);
|
|
80
101
|
const afterDeletion = store.hashesBySource(staleSource);
|
|
81
102
|
assert.equal(afterDeletion.size, 0, 'Stale source must be removed from store after directory scan');
|
|
82
103
|
});
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
// BC13: delegation — vecAvailable=true embeds query and calls searchHybrid with correct options
|
|
105
|
+
test('BC13: search delegates to searchHybrid with embedding and constants when vecAvailable', async () => {
|
|
106
|
+
const chunk = makeChunkFull();
|
|
107
|
+
let capturedOptions;
|
|
108
|
+
const fakeStore = {
|
|
109
|
+
vecAvailable: true,
|
|
110
|
+
searchHybrid: (opts) => {
|
|
111
|
+
capturedOptions = opts;
|
|
112
|
+
return [makeHybridRow(chunk, 'both')];
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bc13-'));
|
|
116
|
+
const testCfg = makeConfig(path.join(testDir, 'wiki'));
|
|
117
|
+
try {
|
|
118
|
+
const compiledEntities = path.join(testCfg.wikiDir, 'compiled', 'entities');
|
|
119
|
+
fs.mkdirSync(compiledEntities, { recursive: true });
|
|
120
|
+
fs.writeFileSync(path.join(compiledEntities, 'test.md'), 'test content', 'utf8');
|
|
121
|
+
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
122
|
+
const results = await testIndexer.search('hello', 3);
|
|
123
|
+
assert.ok(capturedOptions, 'searchHybrid must be called');
|
|
124
|
+
assert.equal(capturedOptions.query, 'hello');
|
|
125
|
+
assert.equal(capturedOptions.topK, 3);
|
|
126
|
+
assert.equal(capturedOptions.fetchLimit, 3 * FETCH_MULTIPLIER);
|
|
127
|
+
assert.equal(capturedOptions.denseScoreFloor, DENSE_SCORE_FLOOR);
|
|
128
|
+
assert.equal(capturedOptions.recencyWeight, RECENCY_WEIGHT);
|
|
129
|
+
assert.equal(capturedOptions.rrfK, RRF_K);
|
|
130
|
+
assert.ok(capturedOptions.embedding instanceof Float32Array, 'embedding must be provided when vecAvailable');
|
|
131
|
+
assert.equal(results.length, 1);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
// BC14: embedding failure → searchHybrid called without embedding
|
|
138
|
+
test('BC14: embedding failure causes search to call searchHybrid without embedding', async () => {
|
|
139
|
+
const chunk = makeChunkFull();
|
|
140
|
+
let capturedOptions;
|
|
141
|
+
const fakeStore = {
|
|
142
|
+
vecAvailable: true,
|
|
143
|
+
searchHybrid: (opts) => {
|
|
144
|
+
capturedOptions = opts;
|
|
145
|
+
return [makeHybridRow(chunk, 'bm25')];
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bc14-'));
|
|
149
|
+
const testCfg = makeConfig(path.join(testDir, 'wiki'));
|
|
150
|
+
try {
|
|
151
|
+
const compiledEntities = path.join(testCfg.wikiDir, 'compiled', 'entities');
|
|
152
|
+
fs.mkdirSync(compiledEntities, { recursive: true });
|
|
153
|
+
fs.writeFileSync(path.join(compiledEntities, 'test.md'), 'test content', 'utf8');
|
|
154
|
+
const testIndexer = new MemoryIndexer(fakeStore, new FailEmbedder(), testCfg);
|
|
155
|
+
const results = await testIndexer.search('hello', 5);
|
|
156
|
+
assert.ok(capturedOptions, 'searchHybrid must be called even after embedding failure');
|
|
157
|
+
assert.equal(capturedOptions.embedding, undefined, 'embedding must be absent after failure');
|
|
158
|
+
assert.equal(results.length, 1);
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
// BC15: readable source file → full live content and contentSource: 'file'
|
|
165
|
+
test('BC15: readable source file is read asynchronously and contentSource is file', async () => {
|
|
166
|
+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bc15-'));
|
|
167
|
+
const testCfg = makeConfig(path.join(testDir, 'wiki'));
|
|
168
|
+
const sourceRelative = 'compiled/entities/test.md';
|
|
169
|
+
const sourceAbsolute = path.join(testCfg.wikiDir, sourceRelative);
|
|
170
|
+
const fileContent = '# Full\nLive file content from disk.';
|
|
171
|
+
try {
|
|
172
|
+
fs.mkdirSync(path.dirname(sourceAbsolute), { recursive: true });
|
|
173
|
+
fs.writeFileSync(sourceAbsolute, fileContent, 'utf8');
|
|
174
|
+
const chunk = makeChunkFull({ source: sourceRelative, content: 'stored chunk only' });
|
|
175
|
+
const fakeStore = {
|
|
176
|
+
vecAvailable: false,
|
|
177
|
+
searchHybrid: () => [makeHybridRow(chunk, 'bm25')],
|
|
178
|
+
};
|
|
179
|
+
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
180
|
+
const results = await testIndexer.search('test', 5);
|
|
181
|
+
assert.equal(results.length, 1);
|
|
182
|
+
assert.equal(results[0].contentSource, 'file');
|
|
183
|
+
assert.equal(results[0].chunk.content, fileContent);
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
// BC16: missing source file → stored chunk content and contentSource: 'fallback'
|
|
190
|
+
test('BC16: missing source file leaves stored chunk content and sets contentSource fallback', async () => {
|
|
191
|
+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bc16-'));
|
|
192
|
+
const testCfg = makeConfig(path.join(testDir, 'wiki'));
|
|
193
|
+
try {
|
|
194
|
+
const chunk = makeChunkFull({
|
|
195
|
+
source: 'compiled/entities/missing.md',
|
|
196
|
+
content: 'stored chunk content',
|
|
197
|
+
});
|
|
198
|
+
const fakeStore = {
|
|
199
|
+
vecAvailable: false,
|
|
200
|
+
searchHybrid: () => [makeHybridRow(chunk, 'bm25')],
|
|
201
|
+
};
|
|
202
|
+
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
203
|
+
const results = await testIndexer.search('test', 5);
|
|
204
|
+
assert.equal(results.length, 1);
|
|
205
|
+
assert.equal(results[0].contentSource, 'fallback');
|
|
206
|
+
assert.equal(results[0].chunk.content, 'stored chunk content');
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
210
|
+
}
|
|
98
211
|
});
|
|
99
|
-
|
|
212
|
+
// BC17: indexDirectory deterministic traversal and stale deletion
|
|
213
|
+
test('BC17: indexDirectory walks nested markdown files and excludes configured basenames', async () => {
|
|
100
214
|
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'recursive-index-'));
|
|
101
215
|
const testCfg = makeConfig(path.join(testDir, 'wiki'));
|
|
102
216
|
const testStore = new MemoryStore(path.join(testCfg.wikiDir, 'index.db'), testCfg);
|
|
@@ -123,209 +237,67 @@ describe('MemoryIndexer', () => {
|
|
|
123
237
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
124
238
|
}
|
|
125
239
|
});
|
|
126
|
-
|
|
240
|
+
// BC18: unreadable subtrees — log and continue
|
|
241
|
+
test('BC18: indexDirectory logs and continues when file or subdirectory is unreadable', async () => {
|
|
127
242
|
const stats = await indexer.indexDirectory(path.join(config.wikiDir, 'missing'), {
|
|
128
243
|
relativeBase: config.wikiDir,
|
|
129
244
|
});
|
|
130
245
|
assert.deepEqual(stats, { indexed: 0, deleted: 0, skipped: 0 });
|
|
131
246
|
});
|
|
132
|
-
|
|
133
|
-
|
|
247
|
+
// BC19: save appends content asynchronously
|
|
248
|
+
test('BC19: save appends manual content to daily wiki raw file asynchronously', async () => {
|
|
249
|
+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'save-daily-'));
|
|
134
250
|
const testCfg = makeConfig(path.join(testDir, 'wiki'));
|
|
135
251
|
const testStore = new MemoryStore(path.join(testCfg.wikiDir, 'index.db'), testCfg);
|
|
136
252
|
const testIndexer = new MemoryIndexer(testStore, new StubEmbedder(), testCfg);
|
|
137
|
-
const
|
|
253
|
+
const datePart = new Date().toISOString().slice(0, 10);
|
|
254
|
+
const savePath = path.join(testCfg.wikiDir, 'raw', `conv_save_${datePart}.md`);
|
|
138
255
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
lineStart: 1,
|
|
149
|
-
lineEnd: 2,
|
|
150
|
-
fileMtimeAt: 1,
|
|
151
|
-
},
|
|
152
|
-
], [new Float32Array(384)]);
|
|
153
|
-
assert.ok(testStore.hashesBySource('2026-05-18.md').size > 0);
|
|
154
|
-
const stats = await testIndexer.indexDirectory(compiledDir, {
|
|
155
|
-
relativeBase: testCfg.wikiDir,
|
|
156
|
-
});
|
|
157
|
-
assert.equal(stats.deleted, 1);
|
|
158
|
-
assert.equal(testStore.hashesBySource('2026-05-18.md').size, 0);
|
|
256
|
+
const firstStats = await testIndexer.save('first manual save');
|
|
257
|
+
const secondStats = await testIndexer.save('second manual save');
|
|
258
|
+
assert.deepEqual(firstStats, { indexed: 0, deleted: 0, skipped: 0 });
|
|
259
|
+
assert.deepEqual(secondStats, { indexed: 0, deleted: 0, skipped: 0 });
|
|
260
|
+
assert.equal(fs.existsSync(savePath), true);
|
|
261
|
+
const content = fs.readFileSync(savePath, 'utf8');
|
|
262
|
+
assert.match(content, /first manual save/);
|
|
263
|
+
assert.match(content, /second manual save/);
|
|
264
|
+
assert.equal(fs.readdirSync(path.dirname(savePath)).filter((name) => /^conv_save_.*\.md$/.test(name)).length, 1);
|
|
159
265
|
}
|
|
160
266
|
finally {
|
|
161
267
|
testStore.close();
|
|
162
268
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
163
269
|
}
|
|
164
270
|
});
|
|
165
|
-
test('search
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
].join('\n');
|
|
181
|
-
const originalReadFileSync = fsDefault.readFileSync;
|
|
182
|
-
let readCount = 0;
|
|
183
|
-
try {
|
|
184
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
185
|
-
fs.writeFileSync(filePath, fileContent, 'utf8');
|
|
186
|
-
await testIndexer.indexDirectory(path.join(testCfg.wikiDir, 'compiled'), {
|
|
187
|
-
relativeBase: testCfg.wikiDir,
|
|
188
|
-
});
|
|
189
|
-
const expectedPath = path.join(testCfg.wikiDir, 'compiled/entities/dedup.md');
|
|
190
|
-
fsDefault.readFileSync = ((targetPath, options) => {
|
|
191
|
-
if (targetPath === expectedPath)
|
|
192
|
-
readCount += 1;
|
|
193
|
-
return originalReadFileSync(targetPath, options);
|
|
194
|
-
});
|
|
195
|
-
syncBuiltinESMExports();
|
|
196
|
-
const results = await testIndexer.search('dedupUniqueTerm', 5);
|
|
197
|
-
assert.equal(results.filter((r) => r.chunk.source === 'compiled/entities/dedup.md').length, 1);
|
|
198
|
-
assert.equal(readCount, 1);
|
|
199
|
-
assert.equal(results[0].chunk.content, fileContent);
|
|
200
|
-
assert.equal(results[0].contentSource, 'file');
|
|
201
|
-
}
|
|
202
|
-
finally {
|
|
203
|
-
fsDefault.readFileSync = originalReadFileSync;
|
|
204
|
-
syncBuiltinESMExports();
|
|
205
|
-
testStore.close();
|
|
206
|
-
fs.rmSync(testDir, { recursive: true, force: true });
|
|
207
|
-
}
|
|
271
|
+
test('search returns result with correct source for indexed content', async () => {
|
|
272
|
+
const filePath = path.join(config.wikiDir, 'compiled', 'searchable.md');
|
|
273
|
+
const fileContent = '# Searchable\nspecialUniqueTermForSearch is in this document.';
|
|
274
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
275
|
+
fs.writeFileSync(filePath, fileContent, 'utf8');
|
|
276
|
+
await indexer.indexDirectory(path.join(config.wikiDir, 'compiled'), {
|
|
277
|
+
relativeBase: config.wikiDir,
|
|
278
|
+
});
|
|
279
|
+
const results = await indexer.search('specialUniqueTermForSearch', 5);
|
|
280
|
+
assert.ok(results.length > 0, 'Expected at least one search result');
|
|
281
|
+
const expectedSource = path.relative(config.wikiDir, filePath);
|
|
282
|
+
const match = results.find((r) => r.chunk.source === expectedSource);
|
|
283
|
+
assert.ok(match, `Expected result with source=${expectedSource}, got: ${results.map((r) => r.chunk.source).join(', ')}`);
|
|
284
|
+
assert.equal(match.chunk.content, fileContent);
|
|
285
|
+
assert.equal(match.contentSource, 'file');
|
|
208
286
|
});
|
|
209
|
-
test('search
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
const firstContent = '# First\nfirst source full content';
|
|
215
|
-
const secondContent = '# Second\nsecond source full content';
|
|
216
|
-
const chunks = [
|
|
217
|
-
{
|
|
218
|
-
id: 'first-1',
|
|
219
|
-
source: 'compiled/entities/first.md',
|
|
220
|
-
heading: 'First',
|
|
221
|
-
headingLevel: 1,
|
|
222
|
-
content: 'first matching chunk one',
|
|
223
|
-
lineStart: 1,
|
|
224
|
-
lineEnd: 2,
|
|
225
|
-
},
|
|
226
|
-
{
|
|
227
|
-
id: 'first-2',
|
|
228
|
-
source: 'compiled/entities/first.md',
|
|
229
|
-
heading: 'First',
|
|
230
|
-
headingLevel: 1,
|
|
231
|
-
content: 'first matching chunk two',
|
|
232
|
-
lineStart: 3,
|
|
233
|
-
lineEnd: 4,
|
|
234
|
-
},
|
|
235
|
-
{
|
|
236
|
-
id: 'second-1',
|
|
237
|
-
source: 'compiled/entities/second.md',
|
|
238
|
-
heading: 'Second',
|
|
239
|
-
headingLevel: 1,
|
|
240
|
-
content: 'second matching chunk',
|
|
241
|
-
lineStart: 1,
|
|
242
|
-
lineEnd: 2,
|
|
243
|
-
},
|
|
244
|
-
];
|
|
245
|
-
const fakeStore = {
|
|
246
|
-
vecAvailable: false,
|
|
247
|
-
searchBm25: () => [
|
|
248
|
-
{ id: 'first-1', score: 1 },
|
|
249
|
-
{ id: 'first-2', score: 0.9 },
|
|
250
|
-
{ id: 'second-1', score: 0.8 },
|
|
251
|
-
],
|
|
252
|
-
getChunksByIds: (ids) => chunks.filter((chunk) => ids.includes(chunk.id)),
|
|
253
|
-
};
|
|
254
|
-
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
287
|
+
test('search on empty store returns []', async () => {
|
|
288
|
+
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-store-'));
|
|
289
|
+
const emptyCfg = makeConfig(emptyDir);
|
|
290
|
+
const emptyStore = new MemoryStore(path.join(emptyCfg.wikiDir, 'index.db'), emptyCfg);
|
|
291
|
+
const emptyIndexer = new MemoryIndexer(emptyStore, new StubEmbedder(), emptyCfg);
|
|
255
292
|
try {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
fs.writeFileSync(secondPath, secondContent, 'utf8');
|
|
259
|
-
const results = await testIndexer.search('duplicate source query', 2);
|
|
260
|
-
assert.equal(results.length, 2);
|
|
261
|
-
assert.deepEqual(results.map((result) => result.chunk.source), ['compiled/entities/first.md', 'compiled/entities/second.md']);
|
|
262
|
-
assert.equal(results[0].chunk.content, firstContent);
|
|
263
|
-
assert.equal(results[1].chunk.content, secondContent);
|
|
293
|
+
const results = await emptyIndexer.search('anything', 5);
|
|
294
|
+
assert.deepEqual(results, []);
|
|
264
295
|
}
|
|
265
296
|
finally {
|
|
266
|
-
|
|
297
|
+
emptyStore.close();
|
|
298
|
+
fs.rmSync(emptyDir, { recursive: true, force: true });
|
|
267
299
|
}
|
|
268
300
|
});
|
|
269
|
-
test('search does not include dense-only results when BM25 has matches', async () => {
|
|
270
|
-
const testCfg = makeConfig('/tmp/search-dense-filter');
|
|
271
|
-
const chunks = [
|
|
272
|
-
{
|
|
273
|
-
id: 'preference-1',
|
|
274
|
-
source: 'compiled/preferences.md',
|
|
275
|
-
heading: '',
|
|
276
|
-
headingLevel: 0,
|
|
277
|
-
content: 'I like fish',
|
|
278
|
-
lineStart: 1,
|
|
279
|
-
lineEnd: 1,
|
|
280
|
-
},
|
|
281
|
-
{
|
|
282
|
-
id: 'dense-only-1',
|
|
283
|
-
source: 'compiled/entities/worktree.md',
|
|
284
|
-
heading: 'Worktree',
|
|
285
|
-
headingLevel: 1,
|
|
286
|
-
content: 'Unrelated worktree lifecycle content',
|
|
287
|
-
lineStart: 1,
|
|
288
|
-
lineEnd: 2,
|
|
289
|
-
},
|
|
290
|
-
];
|
|
291
|
-
const fakeStore = {
|
|
292
|
-
vecAvailable: true,
|
|
293
|
-
searchDense: () => [
|
|
294
|
-
{ id: 'dense-only-1', score: 0.99 },
|
|
295
|
-
{ id: 'preference-1', score: 0.98 },
|
|
296
|
-
],
|
|
297
|
-
searchBm25: () => [{ id: 'preference-1', score: 1 }],
|
|
298
|
-
getChunksByIds: (ids) => chunks.filter((chunk) => ids.includes(chunk.id)),
|
|
299
|
-
};
|
|
300
|
-
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
301
|
-
const results = await testIndexer.search('personal likes and preferences of the user', 5);
|
|
302
|
-
assert.deepEqual(results.map((result) => result.chunk.id), ['preference-1']);
|
|
303
|
-
assert.equal(results[0].retriever, 'both');
|
|
304
|
-
});
|
|
305
|
-
test('search still returns dense-only results when BM25 has no matches', async () => {
|
|
306
|
-
const testCfg = makeConfig('/tmp/search-dense-fallback');
|
|
307
|
-
const chunks = [
|
|
308
|
-
{
|
|
309
|
-
id: 'semantic-1',
|
|
310
|
-
source: 'compiled/preferences.md',
|
|
311
|
-
heading: '',
|
|
312
|
-
headingLevel: 0,
|
|
313
|
-
content: 'I like fish',
|
|
314
|
-
lineStart: 1,
|
|
315
|
-
lineEnd: 1,
|
|
316
|
-
},
|
|
317
|
-
];
|
|
318
|
-
const fakeStore = {
|
|
319
|
-
vecAvailable: true,
|
|
320
|
-
searchDense: () => [{ id: 'semantic-1', score: 0.99 }],
|
|
321
|
-
searchBm25: () => [],
|
|
322
|
-
getChunksByIds: (ids) => chunks.filter((chunk) => ids.includes(chunk.id)),
|
|
323
|
-
};
|
|
324
|
-
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
325
|
-
const results = await testIndexer.search('favorite dish', 5);
|
|
326
|
-
assert.deepEqual(results.map((result) => result.chunk.id), ['semantic-1']);
|
|
327
|
-
assert.equal(results[0].retriever, 'dense');
|
|
328
|
-
});
|
|
329
301
|
test('search falls back to stored chunk content when source file is missing', async () => {
|
|
330
302
|
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'search-fallback-'));
|
|
331
303
|
const testCfg = makeConfig(path.join(testDir, 'wiki'));
|
|
@@ -350,37 +322,56 @@ describe('MemoryIndexer', () => {
|
|
|
350
322
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
351
323
|
}
|
|
352
324
|
});
|
|
353
|
-
test('search
|
|
354
|
-
const
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
const
|
|
325
|
+
test('search returns distinct sources — one result per source', async () => {
|
|
326
|
+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'search-dedup-'));
|
|
327
|
+
const testCfg = makeConfig(path.join(testDir, 'wiki'));
|
|
328
|
+
const testStore = new MemoryStore(path.join(testCfg.wikiDir, 'index.db'), testCfg);
|
|
329
|
+
const testIndexer = new MemoryIndexer(testStore, new StubEmbedder(), testCfg);
|
|
330
|
+
const filePath = path.join(testCfg.wikiDir, 'compiled', 'entities', 'dedup.md');
|
|
331
|
+
// Multiple paragraphs create multiple chunks
|
|
332
|
+
const fileContent = '# Dedup\n\ndedupUniqueTermXYZ first chunk content\n\n---\n\ndedupUniqueTermXYZ second chunk content';
|
|
358
333
|
try {
|
|
359
|
-
|
|
360
|
-
|
|
334
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
335
|
+
fs.writeFileSync(filePath, fileContent, 'utf8');
|
|
336
|
+
await testIndexer.indexDirectory(path.join(testCfg.wikiDir, 'compiled'), {
|
|
337
|
+
relativeBase: testCfg.wikiDir,
|
|
338
|
+
});
|
|
339
|
+
const results = await testIndexer.search('dedupUniqueTermXYZ', 5);
|
|
340
|
+
const dedupSources = results.filter((r) => r.chunk.source === 'compiled/entities/dedup.md');
|
|
341
|
+
assert.equal(dedupSources.length, 1, 'At most one result per source after dedup');
|
|
361
342
|
}
|
|
362
343
|
finally {
|
|
363
|
-
|
|
364
|
-
fs.rmSync(
|
|
344
|
+
testStore.close();
|
|
345
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
365
346
|
}
|
|
366
347
|
});
|
|
367
|
-
test('
|
|
368
|
-
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), '
|
|
348
|
+
test('indexDirectory removes stale pre-migration daily-file sources', async () => {
|
|
349
|
+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'recursive-stale-'));
|
|
369
350
|
const testCfg = makeConfig(path.join(testDir, 'wiki'));
|
|
370
351
|
const testStore = new MemoryStore(path.join(testCfg.wikiDir, 'index.db'), testCfg);
|
|
371
352
|
const testIndexer = new MemoryIndexer(testStore, new StubEmbedder(), testCfg);
|
|
372
|
-
const
|
|
373
|
-
const savePath = path.join(testCfg.wikiDir, 'raw', `conv_save_${datePart}.md`);
|
|
353
|
+
const compiledDir = path.join(testCfg.wikiDir, 'compiled');
|
|
374
354
|
try {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
355
|
+
fs.mkdirSync(compiledDir, { recursive: true });
|
|
356
|
+
testStore.upsert([
|
|
357
|
+
{
|
|
358
|
+
id: 'stale-daily-file-0001',
|
|
359
|
+
source: '2026-05-18.md',
|
|
360
|
+
sourceType: 'wiki',
|
|
361
|
+
heading: 'Stale',
|
|
362
|
+
headingLevel: 1,
|
|
363
|
+
content: 'pre migration content',
|
|
364
|
+
lineStart: 1,
|
|
365
|
+
lineEnd: 2,
|
|
366
|
+
fileMtimeAt: 1,
|
|
367
|
+
},
|
|
368
|
+
], [new Float32Array(384)]);
|
|
369
|
+
assert.ok(testStore.hashesBySource('2026-05-18.md').size > 0);
|
|
370
|
+
const stats = await testIndexer.indexDirectory(compiledDir, {
|
|
371
|
+
relativeBase: testCfg.wikiDir,
|
|
372
|
+
});
|
|
373
|
+
assert.equal(stats.deleted, 1);
|
|
374
|
+
assert.equal(testStore.hashesBySource('2026-05-18.md').size, 0);
|
|
384
375
|
}
|
|
385
376
|
finally {
|
|
386
377
|
testStore.close();
|
|
@@ -410,11 +401,18 @@ describe('MemoryIndexer', () => {
|
|
|
410
401
|
assert.ok(stats.indexed > 0, `Expected indexed > 0, got ${stats.indexed}`);
|
|
411
402
|
const ids = [...testStore.hashesBySource('compiled/provisional/conversation-digests/digest.md')];
|
|
412
403
|
assert.ok(ids.length > 0);
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
404
|
+
const results = testStore.searchHybrid({
|
|
405
|
+
query: 'mtime metadata unique',
|
|
406
|
+
topK: 5,
|
|
407
|
+
fetchLimit: 20,
|
|
408
|
+
denseScoreFloor: 0,
|
|
409
|
+
recencyWeight: RECENCY_WEIGHT,
|
|
410
|
+
rrfK: RRF_K,
|
|
411
|
+
});
|
|
412
|
+
const match = results.find((r) => r.chunk.source === 'compiled/provisional/conversation-digests/digest.md');
|
|
413
|
+
assert.ok(match, 'Expected searchHybrid to find the indexed chunk');
|
|
414
|
+
assert.equal(match.chunk.sourceType, 'digest');
|
|
415
|
+
assert.equal(match.chunk.fileMtimeAt, expectedMtime);
|
|
418
416
|
}
|
|
419
417
|
finally {
|
|
420
418
|
testStore.close();
|
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
}
|