@hanhnd/agent-kit 1.0.35 → 1.0.37
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 +93 -73
- package/dist/core/config/index.d.ts +1 -1
- package/dist/core/config/index.js +15 -1
- package/dist/server.js +93 -73
- package/dist/services/digest/digest-processor.test.js +5 -19
- package/dist/services/memory/constants.d.ts +2 -1
- package/dist/services/memory/constants.js +6 -1
- package/dist/services/memory/memory.test.js +10 -0
- package/dist/services/memory/store.d.ts +5 -3
- package/dist/services/memory/store.js +90 -74
- package/dist/services/memory/store.test.js +163 -15
- package/package.json +3 -5
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import Database from 'libsql';
|
|
1
2
|
import * as assert from 'node:assert/strict';
|
|
2
3
|
import * as fs from 'node:fs';
|
|
3
4
|
import * as os from 'node:os';
|
|
4
5
|
import * as path from 'node:path';
|
|
5
|
-
import Database from 'better-sqlite3';
|
|
6
6
|
import { after, before, describe, test } from 'node:test';
|
|
7
7
|
import { MemoryStore, SCHEMA_VERSION } from './store.js';
|
|
8
8
|
import { EmbeddingModelName } from './types.js';
|
|
@@ -29,6 +29,25 @@ function makeChunk(overrides = {}) {
|
|
|
29
29
|
...overrides,
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
+
function makeEmbedding(activeIndex, dimension = TEST_CONFIG.vectorDimension) {
|
|
33
|
+
const embedding = new Float32Array(dimension);
|
|
34
|
+
embedding[activeIndex] = 1;
|
|
35
|
+
return embedding;
|
|
36
|
+
}
|
|
37
|
+
function readUserVersion(db) {
|
|
38
|
+
const value = db.pragma('user_version', { simple: true });
|
|
39
|
+
if (typeof value === 'number')
|
|
40
|
+
return value;
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
const [row] = value;
|
|
43
|
+
return typeof row?.user_version === 'number' ? row.user_version : 0;
|
|
44
|
+
}
|
|
45
|
+
if (value && typeof value === 'object' && 'user_version' in value) {
|
|
46
|
+
const row = value;
|
|
47
|
+
return typeof row.user_version === 'number' ? row.user_version : 0;
|
|
48
|
+
}
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
32
51
|
describe('MemoryStore', () => {
|
|
33
52
|
let tmpDir;
|
|
34
53
|
let dbPath;
|
|
@@ -42,8 +61,50 @@ describe('MemoryStore', () => {
|
|
|
42
61
|
store.close();
|
|
43
62
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
44
63
|
});
|
|
45
|
-
test('
|
|
46
|
-
|
|
64
|
+
test('libsql supports required local driver and vector features', () => {
|
|
65
|
+
const libsqlDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-libsql-compat-'));
|
|
66
|
+
const libsqlDb = new Database(path.join(libsqlDir, 'index.db'));
|
|
67
|
+
try {
|
|
68
|
+
assert.equal(readUserVersion(libsqlDb), 0);
|
|
69
|
+
libsqlDb.pragma('journal_mode = WAL');
|
|
70
|
+
libsqlDb.pragma('busy_timeout = 5000');
|
|
71
|
+
libsqlDb.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
72
|
+
assert.equal(readUserVersion(libsqlDb), SCHEMA_VERSION);
|
|
73
|
+
libsqlDb.exec(`
|
|
74
|
+
CREATE TABLE vector_items (
|
|
75
|
+
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
76
|
+
name TEXT NOT NULL,
|
|
77
|
+
embedding F32_BLOB(3) NOT NULL
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX idx_vector_items_embedding
|
|
80
|
+
ON vector_items (libsql_vector_idx(embedding));
|
|
81
|
+
`);
|
|
82
|
+
const insert = libsqlDb.prepare(`INSERT INTO vector_items (name, embedding) VALUES (?, vector32(?))`);
|
|
83
|
+
const info = insert.run('alpha', '[1,0,0]');
|
|
84
|
+
assert.equal(info.lastInsertRowid, 1);
|
|
85
|
+
insert.run('beta', '[0,1,0]');
|
|
86
|
+
const insertGamma = libsqlDb.transaction(() => insert.run('gamma', '[0,0,1]'));
|
|
87
|
+
insertGamma();
|
|
88
|
+
const distance = libsqlDb
|
|
89
|
+
.prepare(`SELECT vector_distance_cos(embedding, vector32(?)) AS distance FROM vector_items WHERE name = ?`)
|
|
90
|
+
.get('[1,0,0]', 'alpha');
|
|
91
|
+
assert.equal(distance.distance, 0);
|
|
92
|
+
const rows = libsqlDb
|
|
93
|
+
.prepare(`SELECT vector_items.name
|
|
94
|
+
FROM vector_top_k('idx_vector_items_embedding', vector32(?), ?) AS vector_matches
|
|
95
|
+
JOIN vector_items ON vector_items.rowid = vector_matches.id
|
|
96
|
+
ORDER BY vector_distance_cos(vector_items.embedding, vector32(?))`)
|
|
97
|
+
.all('[1,0,0]', 2, '[1,0,0]');
|
|
98
|
+
assert.equal(rows[0].name, 'alpha');
|
|
99
|
+
assert.equal(rows.length, 2);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
libsqlDb.close();
|
|
103
|
+
fs.rmSync(libsqlDir, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
test('vecAvailable is true after schema creation', () => {
|
|
107
|
+
assert.equal(store.vecAvailable, true);
|
|
47
108
|
});
|
|
48
109
|
test('hashesBySource returns empty set for unknown source', () => {
|
|
49
110
|
const hashes = store.hashesBySource('nonexistent.md');
|
|
@@ -52,7 +113,7 @@ describe('MemoryStore', () => {
|
|
|
52
113
|
});
|
|
53
114
|
test('upsert stores chunks and hashesBySource returns their ids', () => {
|
|
54
115
|
const chunk = makeChunk({ id: 'upsert-test-0001', source: 'upsert.md', sourceType: 'entity', fileMtimeAt: 2000 });
|
|
55
|
-
const embedding =
|
|
116
|
+
const embedding = makeEmbedding(0);
|
|
56
117
|
store.upsert([chunk], [embedding]);
|
|
57
118
|
const hashes = store.hashesBySource('upsert.md');
|
|
58
119
|
assert.ok(hashes.has('upsert-test-0001'));
|
|
@@ -71,7 +132,7 @@ describe('MemoryStore', () => {
|
|
|
71
132
|
source: 'deleteme.md',
|
|
72
133
|
content: 'delete source chunk 2',
|
|
73
134
|
});
|
|
74
|
-
store.upsert([c1, c2], [
|
|
135
|
+
store.upsert([c1, c2], [makeEmbedding(1), makeEmbedding(2)]);
|
|
75
136
|
store.deleteBySource('deleteme.md');
|
|
76
137
|
const hashes = store.hashesBySource('deleteme.md');
|
|
77
138
|
assert.equal(hashes.size, 0);
|
|
@@ -82,7 +143,7 @@ describe('MemoryStore', () => {
|
|
|
82
143
|
source: 'bm25.md',
|
|
83
144
|
content: 'uniqueKeywordXYZ for BM25 testing',
|
|
84
145
|
});
|
|
85
|
-
store.upsert([chunk], [
|
|
146
|
+
store.upsert([chunk], [makeEmbedding(3)]);
|
|
86
147
|
const results = store.searchBm25('uniqueKeywordXYZ', 5);
|
|
87
148
|
assert.ok(results.length > 0, 'Expected at least one BM25 result');
|
|
88
149
|
assert.ok(results.some((r) => r.id === 'bm25-search-0001'), `Expected chunk id in results, got: ${results.map((r) => r.id).join(', ')}`);
|
|
@@ -101,7 +162,7 @@ describe('MemoryStore', () => {
|
|
|
101
162
|
heading: 'Open Questions',
|
|
102
163
|
content: 'How should git manage worktree lifecycle decisions?',
|
|
103
164
|
});
|
|
104
|
-
store.upsert([preference, unrelated], [
|
|
165
|
+
store.upsert([preference, unrelated], [makeEmbedding(4), makeEmbedding(5)]);
|
|
105
166
|
const results = store.searchBm25('personal likes and preferences of the user', 5);
|
|
106
167
|
assert.ok(results.length > 0, 'Expected preference query to return results');
|
|
107
168
|
assert.equal(results[0].id, 'preference-search-0001');
|
|
@@ -122,7 +183,7 @@ describe('MemoryStore', () => {
|
|
|
122
183
|
lineEnd: 20,
|
|
123
184
|
fileMtimeAt: 3000,
|
|
124
185
|
});
|
|
125
|
-
store.upsert([chunk], [
|
|
186
|
+
store.upsert([chunk], [makeEmbedding(6)]);
|
|
126
187
|
const results = store.getChunksByIds(['get-by-ids-0001']);
|
|
127
188
|
assert.equal(results.length, 1);
|
|
128
189
|
const [r] = results;
|
|
@@ -137,7 +198,7 @@ describe('MemoryStore', () => {
|
|
|
137
198
|
});
|
|
138
199
|
test('getChunksByIds returns only found rows for mixed ids', () => {
|
|
139
200
|
const chunk = makeChunk({ id: 'partial-found-0001', source: 'partial.md', content: 'partial' });
|
|
140
|
-
store.upsert([chunk], [
|
|
201
|
+
store.upsert([chunk], [makeEmbedding(7)]);
|
|
141
202
|
const results = store.getChunksByIds(['partial-found-0001', 'does-not-exist-999']);
|
|
142
203
|
assert.equal(results.length, 1);
|
|
143
204
|
assert.equal(results[0].id, 'partial-found-0001');
|
|
@@ -148,14 +209,14 @@ describe('MemoryStore', () => {
|
|
|
148
209
|
source: 'indexed-source.md',
|
|
149
210
|
content: 'indexed',
|
|
150
211
|
});
|
|
151
|
-
store.upsert([chunk], [
|
|
212
|
+
store.upsert([chunk], [makeEmbedding(8)]);
|
|
152
213
|
const sources = store.indexedSources();
|
|
153
214
|
assert.ok(sources.includes('indexed-source.md'), `Expected 'indexed-source.md' in ${sources.join(', ')}`);
|
|
154
215
|
});
|
|
155
216
|
test('deleteByIds removes specific chunks', () => {
|
|
156
217
|
const c1 = makeChunk({ id: 'del-ids-0001', source: 'del-ids.md', content: 'delete by id 1' });
|
|
157
218
|
const c2 = makeChunk({ id: 'del-ids-0002', source: 'del-ids.md', content: 'delete by id 2' });
|
|
158
|
-
store.upsert([c1, c2], [
|
|
219
|
+
store.upsert([c1, c2], [makeEmbedding(9), makeEmbedding(10)]);
|
|
159
220
|
store.deleteByIds(['del-ids-0001']);
|
|
160
221
|
const hashes = store.hashesBySource('del-ids.md');
|
|
161
222
|
assert.ok(!hashes.has('del-ids-0001'), 'Deleted chunk id must be gone');
|
|
@@ -168,7 +229,7 @@ describe('MemoryStore', () => {
|
|
|
168
229
|
source: 'fts5.md',
|
|
169
230
|
content: 'degradedModeTest keyword',
|
|
170
231
|
});
|
|
171
|
-
store.upsert([chunk], [
|
|
232
|
+
store.upsert([chunk], [makeEmbedding(11)]);
|
|
172
233
|
const results = store.searchBm25('degradedModeTest', 5);
|
|
173
234
|
assert.ok(results.length > 0, 'FTS5 search must work regardless of vecAvailable');
|
|
174
235
|
});
|
|
@@ -205,7 +266,7 @@ describe('MemoryStore', () => {
|
|
|
205
266
|
fileMtimeAt: 500,
|
|
206
267
|
content: 'entity',
|
|
207
268
|
}),
|
|
208
|
-
], [
|
|
269
|
+
], [makeEmbedding(12), makeEmbedding(13), makeEmbedding(14), makeEmbedding(15)]);
|
|
209
270
|
const allRows = recentStore.getRecentSources({ limit: 10 });
|
|
210
271
|
assert.deepEqual(allRows.map((row) => row.source), [
|
|
211
272
|
'compiled/entities/entity.md',
|
|
@@ -221,7 +282,41 @@ describe('MemoryStore', () => {
|
|
|
221
282
|
fs.rmSync(recentDir, { recursive: true, force: true });
|
|
222
283
|
}
|
|
223
284
|
});
|
|
224
|
-
test('
|
|
285
|
+
test('searchDense returns nearest vector with bounded scores', () => {
|
|
286
|
+
const denseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-dense-store-'));
|
|
287
|
+
const denseStore = new MemoryStore(path.join(denseDir, 'index.db'), { ...TEST_CONFIG, wikiDir: denseDir });
|
|
288
|
+
try {
|
|
289
|
+
denseStore.upsert([
|
|
290
|
+
makeChunk({ id: 'dense-alpha', source: 'dense.md', content: 'alpha vector' }),
|
|
291
|
+
makeChunk({ id: 'dense-beta', source: 'dense.md', content: 'beta vector' }),
|
|
292
|
+
makeChunk({ id: 'dense-gamma', source: 'dense.md', content: 'gamma vector' }),
|
|
293
|
+
], [makeEmbedding(21), makeEmbedding(22), makeEmbedding(23)]);
|
|
294
|
+
const results = denseStore.searchDense(makeEmbedding(22), 3);
|
|
295
|
+
assert.ok(results.length > 0, 'Expected dense search results');
|
|
296
|
+
assert.equal(results[0].id, 'dense-beta');
|
|
297
|
+
assert.ok(results.every((result) => result.score >= 0 && result.score <= 1));
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
denseStore.close();
|
|
301
|
+
fs.rmSync(denseDir, { recursive: true, force: true });
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
test('searchDense returns empty array for invalid query embedding', () => {
|
|
305
|
+
assert.deepEqual(store.searchDense(new Float32Array(3), 5), []);
|
|
306
|
+
});
|
|
307
|
+
test('upsert skips invalid embeddings while persisting valid chunks', () => {
|
|
308
|
+
const validChunk = makeChunk({ id: 'valid-embedding-0001', source: 'embedding-validation.md' });
|
|
309
|
+
const invalidChunk = makeChunk({ id: 'invalid-embedding-0001', source: 'embedding-validation.md' });
|
|
310
|
+
const nonFiniteChunk = makeChunk({ id: 'non-finite-embedding-0001', source: 'embedding-validation.md' });
|
|
311
|
+
const nonFiniteEmbedding = makeEmbedding(24);
|
|
312
|
+
nonFiniteEmbedding[25] = Number.NaN;
|
|
313
|
+
store.upsert([validChunk, invalidChunk, nonFiniteChunk], [makeEmbedding(24), new Float32Array(3), nonFiniteEmbedding]);
|
|
314
|
+
const hashes = store.hashesBySource('embedding-validation.md');
|
|
315
|
+
assert.ok(hashes.has('valid-embedding-0001'));
|
|
316
|
+
assert.ok(!hashes.has('invalid-embedding-0001'));
|
|
317
|
+
assert.ok(!hashes.has('non-finite-embedding-0001'));
|
|
318
|
+
});
|
|
319
|
+
test('schema migration recreates versioned schema with new columns, vector index, and working BM25', () => {
|
|
225
320
|
const migrationDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-migration-'));
|
|
226
321
|
const migrationDbPath = path.join(migrationDir, 'index.db');
|
|
227
322
|
const db = new Database(migrationDbPath);
|
|
@@ -264,22 +359,75 @@ describe('MemoryStore', () => {
|
|
|
264
359
|
try {
|
|
265
360
|
const verifyDb = new Database(migrationDbPath, { readonly: true });
|
|
266
361
|
try {
|
|
267
|
-
assert.equal(verifyDb
|
|
362
|
+
assert.equal(readUserVersion(verifyDb), SCHEMA_VERSION);
|
|
268
363
|
const columns = verifyDb.prepare(`PRAGMA table_info(memory_chunks)`).all();
|
|
269
364
|
assert.ok(columns.some((column) => column.name === 'source_type'));
|
|
270
365
|
assert.ok(columns.some((column) => column.name === 'file_mtime_at'));
|
|
366
|
+
assert.ok(columns.some((column) => column.name === 'embedding'));
|
|
271
367
|
const indexes = verifyDb.prepare(`PRAGMA index_list(memory_chunks)`).all();
|
|
272
368
|
assert.ok(indexes.some((index) => index.name === 'idx_memory_chunks_type_mtime'));
|
|
369
|
+
assert.ok(indexes.some((index) => index.name === 'idx_memory_chunks_embedding'));
|
|
273
370
|
const rowCount = verifyDb.prepare(`SELECT COUNT(*) AS count FROM memory_chunks`).get();
|
|
274
371
|
assert.equal(rowCount.count, 0);
|
|
275
372
|
}
|
|
276
373
|
finally {
|
|
277
374
|
verifyDb.close();
|
|
278
375
|
}
|
|
376
|
+
const migratedChunk = makeChunk({
|
|
377
|
+
id: 'migrated-bm25-0001',
|
|
378
|
+
source: 'migrated.md',
|
|
379
|
+
content: 'postMigrationKeyword is searchable after migration',
|
|
380
|
+
});
|
|
381
|
+
migratedStore.upsert([migratedChunk], [makeEmbedding(16)]);
|
|
382
|
+
const results = migratedStore.searchBm25('postMigrationKeyword', 5);
|
|
383
|
+
assert.ok(results.some((result) => result.id === 'migrated-bm25-0001'));
|
|
279
384
|
}
|
|
280
385
|
finally {
|
|
281
386
|
migratedStore.close();
|
|
282
387
|
fs.rmSync(migrationDir, { recursive: true, force: true });
|
|
283
388
|
}
|
|
284
389
|
});
|
|
390
|
+
test('recreates schema when existing vector dimension differs from config', () => {
|
|
391
|
+
const dimensionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-dimension-migration-'));
|
|
392
|
+
const dimensionDbPath = path.join(dimensionDir, 'index.db');
|
|
393
|
+
const baseStore = new MemoryStore(dimensionDbPath, { ...TEST_CONFIG, wikiDir: dimensionDir });
|
|
394
|
+
try {
|
|
395
|
+
baseStore.upsert([makeChunk({ id: 'old-dimension-row', source: 'dimension.md' })], [makeEmbedding(17)]);
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
baseStore.close();
|
|
399
|
+
}
|
|
400
|
+
const largeConfig = {
|
|
401
|
+
...TEST_CONFIG,
|
|
402
|
+
wikiDir: dimensionDir,
|
|
403
|
+
embeddingModel: EmbeddingModelName.LARGE,
|
|
404
|
+
vectorDimension: 768,
|
|
405
|
+
};
|
|
406
|
+
const largeStore = new MemoryStore(dimensionDbPath, largeConfig);
|
|
407
|
+
try {
|
|
408
|
+
const verifyDb = new Database(dimensionDbPath, { readonly: true });
|
|
409
|
+
try {
|
|
410
|
+
const columns = verifyDb.prepare(`PRAGMA table_info(memory_chunks)`).all();
|
|
411
|
+
const embeddingColumn = columns.find((column) => column.name === 'embedding');
|
|
412
|
+
assert.equal(embeddingColumn?.type, 'F32_BLOB(768)');
|
|
413
|
+
const rowCount = verifyDb.prepare(`SELECT COUNT(*) AS count FROM memory_chunks`).get();
|
|
414
|
+
assert.equal(rowCount.count, 0);
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
verifyDb.close();
|
|
418
|
+
}
|
|
419
|
+
const chunk = makeChunk({
|
|
420
|
+
id: 'new-dimension-row',
|
|
421
|
+
source: 'dimension.md',
|
|
422
|
+
content: 'large dimension vector can be indexed',
|
|
423
|
+
});
|
|
424
|
+
largeStore.upsert([chunk], [makeEmbedding(18, 768)]);
|
|
425
|
+
const results = largeStore.searchBm25('large dimension vector', 5);
|
|
426
|
+
assert.ok(results.some((result) => result.id === 'new-dimension-row'));
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
largeStore.close();
|
|
430
|
+
fs.rmSync(dimensionDir, { recursive: true, force: true });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
285
433
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanhnd/agent-kit",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.37",
|
|
4
4
|
"description": "Agent Kit MCP server for software development agent workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/server.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "rm -rf dist && tsc && npm run bundle",
|
|
25
|
-
"bundle": "esbuild src/entrypoints/server.ts src/entrypoints/cli.ts --bundle --platform=node --format=esm --outdir=dist --external:node:* --external:
|
|
25
|
+
"bundle": "esbuild src/entrypoints/server.ts src/entrypoints/cli.ts --bundle --platform=node --format=esm --outdir=dist --external:node:* --external:libsql --external:fastembed --external:node-llama-cpp",
|
|
26
26
|
"dev": "tsc --watch",
|
|
27
27
|
"prepublishOnly": "npm run build",
|
|
28
28
|
"test": "tsc && node --test $(find dist -name '*.test.js' | tr '\\n' ' ')"
|
|
@@ -30,15 +30,13 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"node-llama-cpp": "^3.0.0",
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.13.1",
|
|
33
|
-
"better-sqlite3": "^11.0.0",
|
|
34
33
|
"dedent": "^1.5.3",
|
|
35
34
|
"fastembed": "^1.14.0",
|
|
36
|
-
"
|
|
35
|
+
"libsql": "^0.5.29",
|
|
37
36
|
"stopword": "^3.1.5",
|
|
38
37
|
"zod": "^3.24.4"
|
|
39
38
|
},
|
|
40
39
|
"devDependencies": {
|
|
41
|
-
"@types/better-sqlite3": "^7.6.0",
|
|
42
40
|
"@types/node": "^22.15.21",
|
|
43
41
|
"esbuild": "^0.25.4",
|
|
44
42
|
"typescript": "^5.8.3"
|