@hanhnd/agent-kit 1.0.28 → 1.0.30
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/kit-server.js +15737 -85
- package/dist/memory/__tests__/chunker.test.d.ts +1 -0
- package/dist/memory/__tests__/chunker.test.js +105 -0
- package/dist/memory/__tests__/indexer.test.d.ts +1 -0
- package/dist/memory/__tests__/indexer.test.js +392 -0
- package/dist/memory/__tests__/store.test.d.ts +1 -0
- package/dist/memory/__tests__/store.test.js +152 -0
- package/dist/memory/chunker.d.ts +4 -0
- package/dist/memory/chunker.js +147 -0
- package/dist/memory/embedder.d.ts +13 -0
- package/dist/memory/embedder.js +68 -0
- package/dist/memory/index.d.ts +5 -0
- package/dist/memory/index.js +4 -0
- package/dist/memory/indexer.d.ts +20 -0
- package/dist/memory/indexer.js +226 -0
- package/dist/memory/store.d.ts +29 -0
- package/dist/memory/store.js +273 -0
- package/dist/memory/types.d.ts +30 -0
- package/dist/memory/types.js +9 -0
- package/dist/tools/__tests__/memory.test.d.ts +1 -0
- package/dist/tools/__tests__/memory.test.js +185 -0
- package/dist/tools/config.d.ts +7 -0
- package/dist/tools/config.js +15 -0
- package/dist/tools/memory.d.ts +16 -0
- package/dist/tools/memory.js +75 -0
- package/dist/utils/utils.js +3 -1
- package/package.json +9 -3
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { eng, removeStopwords } from 'stopword';
|
|
5
|
+
export class StoreError extends Error {
|
|
6
|
+
constructor(message, cause) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'StoreError';
|
|
9
|
+
if (cause instanceof Error)
|
|
10
|
+
this.cause = cause;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class MemoryStore {
|
|
14
|
+
config;
|
|
15
|
+
db;
|
|
16
|
+
_vecAvailable = false;
|
|
17
|
+
constructor(dbPath, config) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
20
|
+
this.db = this.openDatabase(dbPath);
|
|
21
|
+
}
|
|
22
|
+
openDatabase(dbPath) {
|
|
23
|
+
try {
|
|
24
|
+
const db = new Database(dbPath);
|
|
25
|
+
db.pragma('journal_mode = WAL');
|
|
26
|
+
db.pragma('busy_timeout = 5000');
|
|
27
|
+
this.loadVecExtension(db);
|
|
28
|
+
this.createSchema(db);
|
|
29
|
+
return db;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error('[memory-store] DB open failed, recreating:', err);
|
|
33
|
+
try {
|
|
34
|
+
fs.unlinkSync(dbPath);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// ignore
|
|
38
|
+
}
|
|
39
|
+
const db = new Database(dbPath);
|
|
40
|
+
db.pragma('journal_mode = WAL');
|
|
41
|
+
db.pragma('busy_timeout = 5000');
|
|
42
|
+
this.loadVecExtension(db);
|
|
43
|
+
this.createSchema(db);
|
|
44
|
+
return db;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
loadVecExtension(db) {
|
|
48
|
+
try {
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
50
|
+
const sqliteVec = require('sqlite-vec');
|
|
51
|
+
sqliteVec.load(db);
|
|
52
|
+
this._vecAvailable = true;
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
console.warn('[memory-store] sqlite-vec unavailable, degrading to FTS5-only:', err);
|
|
56
|
+
this._vecAvailable = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
createSchema(db) {
|
|
60
|
+
db.exec(`
|
|
61
|
+
CREATE TABLE IF NOT EXISTS memory_chunks (
|
|
62
|
+
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
|
+
id TEXT NOT NULL UNIQUE,
|
|
64
|
+
source TEXT NOT NULL,
|
|
65
|
+
heading TEXT NOT NULL DEFAULT '',
|
|
66
|
+
heading_level INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
content TEXT NOT NULL,
|
|
68
|
+
line_start INTEGER NOT NULL,
|
|
69
|
+
line_end INTEGER NOT NULL,
|
|
70
|
+
indexed_at INTEGER NOT NULL
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
74
|
+
source,
|
|
75
|
+
heading,
|
|
76
|
+
content,
|
|
77
|
+
content='memory_chunks',
|
|
78
|
+
content_rowid='rowid'
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE TRIGGER IF NOT EXISTS memory_chunks_ai
|
|
82
|
+
AFTER INSERT ON memory_chunks BEGIN
|
|
83
|
+
INSERT INTO memory_fts(rowid, source, heading, content)
|
|
84
|
+
VALUES (new.rowid, new.source, new.heading, new.content);
|
|
85
|
+
END;
|
|
86
|
+
|
|
87
|
+
CREATE TRIGGER IF NOT EXISTS memory_chunks_ad
|
|
88
|
+
AFTER DELETE ON memory_chunks BEGIN
|
|
89
|
+
INSERT INTO memory_fts(memory_fts, rowid, source, heading, content)
|
|
90
|
+
VALUES ('delete', old.rowid, old.source, old.heading, old.content);
|
|
91
|
+
END;
|
|
92
|
+
`);
|
|
93
|
+
if (this._vecAvailable) {
|
|
94
|
+
try {
|
|
95
|
+
db.exec(`
|
|
96
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_vec USING vec0(
|
|
97
|
+
embedding FLOAT[${this.config.vectorDimension}]
|
|
98
|
+
);
|
|
99
|
+
`);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
console.warn('[memory-store] Failed to create memory_vec, disabling vector search:', err);
|
|
103
|
+
this._vecAvailable = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
upsert(chunks, embeddings) {
|
|
108
|
+
const insertChunk = this.db.prepare(`
|
|
109
|
+
INSERT OR REPLACE INTO memory_chunks
|
|
110
|
+
(id, source, heading, heading_level, content, line_start, line_end, indexed_at)
|
|
111
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
112
|
+
`);
|
|
113
|
+
let insertVec = null;
|
|
114
|
+
if (this._vecAvailable) {
|
|
115
|
+
insertVec = this.db.prepare(`INSERT OR REPLACE INTO memory_vec (rowid, embedding) VALUES (?, ?)`);
|
|
116
|
+
}
|
|
117
|
+
const upsertAll = this.db.transaction(() => {
|
|
118
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
119
|
+
const chunk = chunks[i];
|
|
120
|
+
try {
|
|
121
|
+
// Delete existing row first to get correct rowid
|
|
122
|
+
this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`).run(chunk.id);
|
|
123
|
+
const info = insertChunk.run(chunk.id, chunk.source, chunk.heading, chunk.headingLevel, chunk.content, chunk.lineStart, chunk.lineEnd, Date.now());
|
|
124
|
+
if (insertVec && embeddings[i] && info.lastInsertRowid) {
|
|
125
|
+
const rowid = Number(info.lastInsertRowid);
|
|
126
|
+
const emb = embeddings[i];
|
|
127
|
+
const buf = Buffer.from(emb.buffer, emb.byteOffset, emb.byteLength);
|
|
128
|
+
insertVec.run(rowid, buf);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
console.warn(`[memory-store] Failed to upsert chunk ${chunk.id}:`, err);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
try {
|
|
137
|
+
upsertAll();
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
throw new StoreError('Full batch upsert failed', err);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
hashesBySource(source) {
|
|
144
|
+
const rows = this.db
|
|
145
|
+
.prepare(`SELECT id FROM memory_chunks WHERE source = ?`)
|
|
146
|
+
.all(source);
|
|
147
|
+
return new Set(rows.map((r) => r.id));
|
|
148
|
+
}
|
|
149
|
+
indexedSources() {
|
|
150
|
+
const rows = this.db
|
|
151
|
+
.prepare(`SELECT DISTINCT source FROM memory_chunks`)
|
|
152
|
+
.all();
|
|
153
|
+
return rows.map((r) => r.source);
|
|
154
|
+
}
|
|
155
|
+
deleteBySource(source) {
|
|
156
|
+
const rows = this.db
|
|
157
|
+
.prepare(`SELECT rowid FROM memory_chunks WHERE source = ?`)
|
|
158
|
+
.all(source);
|
|
159
|
+
const deleteAll = this.db.transaction(() => {
|
|
160
|
+
if (this._vecAvailable && rows.length > 0) {
|
|
161
|
+
const placeholders = rows.map(() => '?').join(', ');
|
|
162
|
+
this.db
|
|
163
|
+
.prepare(`DELETE FROM memory_vec WHERE rowid IN (${placeholders})`)
|
|
164
|
+
.run(...rows.map((r) => r.rowid));
|
|
165
|
+
}
|
|
166
|
+
this.db.prepare(`DELETE FROM memory_chunks WHERE source = ?`).run(source);
|
|
167
|
+
});
|
|
168
|
+
deleteAll();
|
|
169
|
+
}
|
|
170
|
+
deleteByIds(ids) {
|
|
171
|
+
if (ids.length === 0)
|
|
172
|
+
return;
|
|
173
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
174
|
+
const rows = this.db
|
|
175
|
+
.prepare(`SELECT rowid FROM memory_chunks WHERE id IN (${placeholders})`)
|
|
176
|
+
.all(...ids);
|
|
177
|
+
const deleteAll = this.db.transaction(() => {
|
|
178
|
+
if (this._vecAvailable && rows.length > 0) {
|
|
179
|
+
const rPlaceholders = rows.map(() => '?').join(', ');
|
|
180
|
+
this.db
|
|
181
|
+
.prepare(`DELETE FROM memory_vec WHERE rowid IN (${rPlaceholders})`)
|
|
182
|
+
.run(...rows.map((r) => r.rowid));
|
|
183
|
+
}
|
|
184
|
+
this.db.prepare(`DELETE FROM memory_chunks WHERE id IN (${placeholders})`).run(...ids);
|
|
185
|
+
});
|
|
186
|
+
deleteAll();
|
|
187
|
+
}
|
|
188
|
+
searchDense(embedding, limit) {
|
|
189
|
+
if (!this._vecAvailable)
|
|
190
|
+
return [];
|
|
191
|
+
try {
|
|
192
|
+
const buf = Buffer.from(embedding.buffer);
|
|
193
|
+
const rows = this.db
|
|
194
|
+
.prepare(`SELECT mc.id, mv.distance
|
|
195
|
+
FROM memory_vec mv
|
|
196
|
+
JOIN memory_chunks mc ON mc.rowid = mv.rowid
|
|
197
|
+
WHERE mv.embedding MATCH ? AND k = ?
|
|
198
|
+
ORDER BY mv.distance`)
|
|
199
|
+
.all(buf, limit);
|
|
200
|
+
return rows.map((r) => ({
|
|
201
|
+
id: r.id,
|
|
202
|
+
score: Math.max(0, 1 - r.distance),
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.warn('[memory-store] Dense search failed:', err);
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
searchBm25(query, limit) {
|
|
211
|
+
if (!query.trim())
|
|
212
|
+
return [];
|
|
213
|
+
const tokens = query
|
|
214
|
+
.trim()
|
|
215
|
+
.split(/\s+/)
|
|
216
|
+
.map((t) => t.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase())
|
|
217
|
+
.filter((t) => t.length > 1);
|
|
218
|
+
const ftsQuery = removeStopwords(tokens, eng)
|
|
219
|
+
.map((t) => `${t.replace(/s$/, '')}*`)
|
|
220
|
+
.join(' OR ');
|
|
221
|
+
if (!ftsQuery)
|
|
222
|
+
return [];
|
|
223
|
+
try {
|
|
224
|
+
const rows = this.db
|
|
225
|
+
.prepare(`SELECT mc.id, bm25(memory_fts, 0.2, 2.0, 5.0) AS rank
|
|
226
|
+
FROM memory_fts
|
|
227
|
+
JOIN memory_chunks mc ON mc.rowid = memory_fts.rowid
|
|
228
|
+
WHERE memory_fts MATCH ?
|
|
229
|
+
ORDER BY rank
|
|
230
|
+
LIMIT ?`)
|
|
231
|
+
.all(ftsQuery, limit);
|
|
232
|
+
if (rows.length === 0)
|
|
233
|
+
return [];
|
|
234
|
+
// BM25 rank is negative in SQLite FTS5 (lower = more relevant)
|
|
235
|
+
const ranks = rows.map((r) => r.rank);
|
|
236
|
+
const minRank = Math.min(...ranks);
|
|
237
|
+
const maxRank = Math.max(...ranks);
|
|
238
|
+
const range = maxRank - minRank;
|
|
239
|
+
return rows.map((r) => ({
|
|
240
|
+
id: r.id,
|
|
241
|
+
score: range > 0 ? (maxRank - r.rank) / range : 1,
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
console.warn('[memory-store] BM25 search failed:', err);
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
getChunksByIds(ids) {
|
|
250
|
+
if (ids.length === 0)
|
|
251
|
+
return [];
|
|
252
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
253
|
+
const rows = this.db
|
|
254
|
+
.prepare(`SELECT id, source, heading, heading_level, content, line_start, line_end
|
|
255
|
+
FROM memory_chunks WHERE id IN (${placeholders})`)
|
|
256
|
+
.all(...ids);
|
|
257
|
+
return rows.map((r) => ({
|
|
258
|
+
id: r.id,
|
|
259
|
+
source: r.source,
|
|
260
|
+
heading: r.heading,
|
|
261
|
+
headingLevel: r.heading_level,
|
|
262
|
+
content: r.content,
|
|
263
|
+
lineStart: r.line_start,
|
|
264
|
+
lineEnd: r.line_end,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
get vecAvailable() {
|
|
268
|
+
return this._vecAvailable;
|
|
269
|
+
}
|
|
270
|
+
close() {
|
|
271
|
+
this.db.close();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface MemoryChunk {
|
|
2
|
+
id: string;
|
|
3
|
+
source: string;
|
|
4
|
+
heading: string;
|
|
5
|
+
headingLevel: number;
|
|
6
|
+
content: string;
|
|
7
|
+
lineStart: number;
|
|
8
|
+
lineEnd: number;
|
|
9
|
+
}
|
|
10
|
+
export interface SearchResult {
|
|
11
|
+
chunk: MemoryChunk;
|
|
12
|
+
score: number;
|
|
13
|
+
retriever: 'dense' | 'bm25' | 'both';
|
|
14
|
+
contentSource: 'file' | 'fallback';
|
|
15
|
+
}
|
|
16
|
+
export interface MemoryConfig {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
wikiDir: string;
|
|
19
|
+
topK: number;
|
|
20
|
+
chunkSize: number;
|
|
21
|
+
overlapLines: number;
|
|
22
|
+
embeddingModel: string;
|
|
23
|
+
vectorDimension: number;
|
|
24
|
+
}
|
|
25
|
+
export interface IndexStats {
|
|
26
|
+
indexed: number;
|
|
27
|
+
deleted: number;
|
|
28
|
+
skipped: number;
|
|
29
|
+
}
|
|
30
|
+
export declare const DEFAULT_MEMORY_CONFIG: MemoryConfig;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import * as assert from 'node:assert/strict';
|
|
2
|
+
import { describe, test } from 'node:test';
|
|
3
|
+
import { resolveMemoryConfig } from '../config.js';
|
|
4
|
+
import { registerMemoryToolHandlers } from '../memory.js';
|
|
5
|
+
// Minimal McpServer stub that captures tool registrations
|
|
6
|
+
function makeMockServer() {
|
|
7
|
+
const tools = new Map();
|
|
8
|
+
const server = {
|
|
9
|
+
tool(name, _desc, _schema, handler) {
|
|
10
|
+
tools.set(name, handler);
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
return { server, tools };
|
|
14
|
+
}
|
|
15
|
+
function makeChunk(overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
id: 'test-id-abc123',
|
|
18
|
+
source: '2024-01-01.md',
|
|
19
|
+
heading: 'My Section',
|
|
20
|
+
headingLevel: 2,
|
|
21
|
+
content: 'This is the chunk content.',
|
|
22
|
+
lineStart: 1,
|
|
23
|
+
lineEnd: 5,
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const BASE_CONFIG = {
|
|
28
|
+
enabled: true,
|
|
29
|
+
wikiDir: '/tmp/wiki',
|
|
30
|
+
topK: 5,
|
|
31
|
+
chunkSize: 1500,
|
|
32
|
+
overlapLines: 2,
|
|
33
|
+
embeddingModel: 'Xenova/bge-small-en-v1.5',
|
|
34
|
+
vectorDimension: 384,
|
|
35
|
+
};
|
|
36
|
+
function makeIndexerStub(overrides = {}) {
|
|
37
|
+
return {
|
|
38
|
+
search: overrides.search ?? (async () => []),
|
|
39
|
+
save: overrides.save ?? (async () => ({ indexed: 1, deleted: 0, skipped: 0 })),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function makeStoreStub(vecAvailable) {
|
|
43
|
+
return { vecAvailable };
|
|
44
|
+
}
|
|
45
|
+
function extractText(result) {
|
|
46
|
+
const r = result;
|
|
47
|
+
return r.content[0]?.text ?? '';
|
|
48
|
+
}
|
|
49
|
+
describe('kit_memory_search', () => {
|
|
50
|
+
test('returns formatted full content and display source when results exist', async () => {
|
|
51
|
+
const chunk = makeChunk({ content: 'Important memory content', source: 'compiled/entities/foo.md' });
|
|
52
|
+
const searchResult = { chunk, score: 0.85, retriever: 'bm25', contentSource: 'file' };
|
|
53
|
+
const indexer = makeIndexerStub({ search: async () => [searchResult] });
|
|
54
|
+
const store = makeStoreStub(true);
|
|
55
|
+
const { server, tools } = makeMockServer();
|
|
56
|
+
registerMemoryToolHandlers(server, indexer, store, BASE_CONFIG);
|
|
57
|
+
const result = await tools.get('kit_memory_search')({ query: 'test query' });
|
|
58
|
+
const text = extractText(result);
|
|
59
|
+
assert.ok(text.includes('Important memory content'), 'Result must include chunk content');
|
|
60
|
+
assert.ok(text.includes('### entities/foo.md (score: 0.850)'), 'Result must include display source and score');
|
|
61
|
+
assert.ok(!text.includes('compiled/entities/foo.md'), 'Result must strip compiled/ prefix');
|
|
62
|
+
assert.ok(!text.includes('My Section'), 'Result must not include chunk heading as the block title');
|
|
63
|
+
});
|
|
64
|
+
test('returns "no memories" message when results are empty', async () => {
|
|
65
|
+
const indexer = makeIndexerStub({ search: async () => [] });
|
|
66
|
+
const store = makeStoreStub(true);
|
|
67
|
+
const { server, tools } = makeMockServer();
|
|
68
|
+
registerMemoryToolHandlers(server, indexer, store, BASE_CONFIG);
|
|
69
|
+
const result = await tools.get('kit_memory_search')({ query: 'nothing here' });
|
|
70
|
+
const text = extractText(result);
|
|
71
|
+
assert.ok(text.toLowerCase().includes('no memories') || text.includes('No memories'), `Expected "no memories" message, got: ${text.slice(0, 100)}`);
|
|
72
|
+
});
|
|
73
|
+
test('prepends degraded warning when vecAvailable is false and no results', async () => {
|
|
74
|
+
const indexer = makeIndexerStub({ search: async () => [] });
|
|
75
|
+
const store = makeStoreStub(false);
|
|
76
|
+
const { server, tools } = makeMockServer();
|
|
77
|
+
registerMemoryToolHandlers(server, indexer, store, BASE_CONFIG);
|
|
78
|
+
const result = await tools.get('kit_memory_search')({ query: 'test' });
|
|
79
|
+
const text = extractText(result);
|
|
80
|
+
assert.ok(text.includes('Vector search unavailable') || text.includes('keyword-only'), `Expected degraded warning in: ${text.slice(0, 200)}`);
|
|
81
|
+
});
|
|
82
|
+
test('prepends degraded warning when vecAvailable is false with results', async () => {
|
|
83
|
+
const chunk = makeChunk({ content: 'Found content', source: 'test.md' });
|
|
84
|
+
const searchResult = { chunk, score: 0.5, retriever: 'bm25', contentSource: 'file' };
|
|
85
|
+
const indexer = makeIndexerStub({ search: async () => [searchResult] });
|
|
86
|
+
const store = makeStoreStub(false);
|
|
87
|
+
const { server, tools } = makeMockServer();
|
|
88
|
+
registerMemoryToolHandlers(server, indexer, store, BASE_CONFIG);
|
|
89
|
+
const result = await tools.get('kit_memory_search')({ query: 'test' });
|
|
90
|
+
const text = extractText(result);
|
|
91
|
+
assert.ok(text.includes('Vector search unavailable') || text.includes('keyword-only'), `Expected degraded warning with results in: ${text.slice(0, 200)}`);
|
|
92
|
+
assert.ok(text.includes('Found content'), 'Result must still contain chunk content');
|
|
93
|
+
});
|
|
94
|
+
test('prepends source-unavailable warning for fallback results', async () => {
|
|
95
|
+
const chunk = makeChunk({ content: 'Stored chunk only', source: 'compiled/entities/foo.md' });
|
|
96
|
+
const searchResult = { chunk, score: 0.5, retriever: 'bm25', contentSource: 'fallback' };
|
|
97
|
+
const indexer = makeIndexerStub({ search: async () => [searchResult] });
|
|
98
|
+
const store = makeStoreStub(true);
|
|
99
|
+
const { server, tools } = makeMockServer();
|
|
100
|
+
registerMemoryToolHandlers(server, indexer, store, BASE_CONFIG);
|
|
101
|
+
const result = await tools.get('kit_memory_search')({ query: 'test' });
|
|
102
|
+
const text = extractText(result);
|
|
103
|
+
assert.ok(text.includes('Source file unavailable'), `Expected fallback warning in: ${text}`);
|
|
104
|
+
assert.ok(text.includes('Stored chunk only'), 'Result must include fallback chunk content');
|
|
105
|
+
});
|
|
106
|
+
test('uses top_k parameter when provided', async () => {
|
|
107
|
+
let capturedTopK;
|
|
108
|
+
const indexer = makeIndexerStub({
|
|
109
|
+
search: async (_q, topK) => {
|
|
110
|
+
capturedTopK = topK;
|
|
111
|
+
return [];
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const store = makeStoreStub(true);
|
|
115
|
+
const { server, tools } = makeMockServer();
|
|
116
|
+
registerMemoryToolHandlers(server, indexer, store, BASE_CONFIG);
|
|
117
|
+
await tools.get('kit_memory_search')({ query: 'test', top_k: 3 });
|
|
118
|
+
assert.equal(capturedTopK, 3);
|
|
119
|
+
});
|
|
120
|
+
test('falls back to config.topK when top_k is not provided', async () => {
|
|
121
|
+
let capturedTopK;
|
|
122
|
+
const indexer = makeIndexerStub({
|
|
123
|
+
search: async (_q, topK) => {
|
|
124
|
+
capturedTopK = topK;
|
|
125
|
+
return [];
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
const store = makeStoreStub(true);
|
|
129
|
+
const { server, tools } = makeMockServer();
|
|
130
|
+
registerMemoryToolHandlers(server, indexer, store, BASE_CONFIG);
|
|
131
|
+
await tools.get('kit_memory_search')({ query: 'test' });
|
|
132
|
+
assert.equal(capturedTopK, BASE_CONFIG.topK);
|
|
133
|
+
});
|
|
134
|
+
test('returns error message when indexer.search throws', async () => {
|
|
135
|
+
const indexer = makeIndexerStub({
|
|
136
|
+
search: async () => { throw new Error('search exploded'); },
|
|
137
|
+
});
|
|
138
|
+
const store = makeStoreStub(true);
|
|
139
|
+
const { server, tools } = makeMockServer();
|
|
140
|
+
registerMemoryToolHandlers(server, indexer, store, BASE_CONFIG);
|
|
141
|
+
const result = await tools.get('kit_memory_search')({ query: 'test' });
|
|
142
|
+
const text = extractText(result);
|
|
143
|
+
assert.ok(text.includes('kit_memory_search failed'), `Expected error prefix, got: ${text}`);
|
|
144
|
+
assert.ok(text.includes('search exploded'), `Expected error message, got: ${text}`);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe('kit_memory_save', () => {
|
|
148
|
+
test('returns saved:true and queued-for-compile status on success', async () => {
|
|
149
|
+
const indexer = makeIndexerStub({
|
|
150
|
+
save: async () => ({ indexed: 0, deleted: 0, skipped: 0 }),
|
|
151
|
+
});
|
|
152
|
+
const store = makeStoreStub(true);
|
|
153
|
+
const { server, tools } = makeMockServer();
|
|
154
|
+
registerMemoryToolHandlers(server, indexer, store, BASE_CONFIG);
|
|
155
|
+
const result = await tools.get('kit_memory_save')({ content: 'Some important note' });
|
|
156
|
+
const text = extractText(result);
|
|
157
|
+
const parsed = JSON.parse(text);
|
|
158
|
+
assert.equal(parsed.saved, true);
|
|
159
|
+
assert.equal(parsed.queued_for_compile, true);
|
|
160
|
+
assert.equal(parsed.message, 'Saved to wiki/raw — will be indexed after next /wiki compile');
|
|
161
|
+
});
|
|
162
|
+
test('returns saved:false and error message when indexer.save throws', async () => {
|
|
163
|
+
const indexer = makeIndexerStub({
|
|
164
|
+
save: async () => { throw new Error('disk full'); },
|
|
165
|
+
});
|
|
166
|
+
const store = makeStoreStub(true);
|
|
167
|
+
const { server, tools } = makeMockServer();
|
|
168
|
+
registerMemoryToolHandlers(server, indexer, store, BASE_CONFIG);
|
|
169
|
+
const result = await tools.get('kit_memory_save')({ content: 'note' });
|
|
170
|
+
const text = extractText(result);
|
|
171
|
+
const parsed = JSON.parse(text);
|
|
172
|
+
assert.equal(parsed.saved, false);
|
|
173
|
+
assert.ok(parsed.error.includes('disk full'), `Expected "disk full" in error, got: ${parsed.error}`);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
describe('resolveMemoryConfig', () => {
|
|
177
|
+
test('defaults wikiDir under .agent-kit', () => {
|
|
178
|
+
const config = resolveMemoryConfig({ memory: {} }, '/repo');
|
|
179
|
+
assert.equal(config.wikiDir, '/repo/.agent-kit/wiki');
|
|
180
|
+
});
|
|
181
|
+
test('uses explicit wikiDir override', () => {
|
|
182
|
+
const config = resolveMemoryConfig({ memory: { wikiDir: '/custom/wiki' } }, '/repo');
|
|
183
|
+
assert.equal(config.wikiDir, '/custom/wiki');
|
|
184
|
+
});
|
|
185
|
+
});
|
package/dist/tools/config.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Configuration utilities
|
|
3
3
|
* Shared configuration constants and loaders
|
|
4
4
|
*/
|
|
5
|
+
import { type MemoryConfig } from '../memory/types.js';
|
|
5
6
|
/**
|
|
6
7
|
* Default file extensions - expanded to support more languages
|
|
7
8
|
* Used by kit_get_project_context and other tools
|
|
@@ -12,8 +13,14 @@ export declare const DEFAULT_EXTENSIONS: string[];
|
|
|
12
13
|
*/
|
|
13
14
|
export interface ProjectSettings {
|
|
14
15
|
fileExtensions?: string[];
|
|
16
|
+
memory?: Partial<MemoryConfig>;
|
|
15
17
|
[key: string]: unknown;
|
|
16
18
|
}
|
|
19
|
+
export { MemoryConfig };
|
|
20
|
+
/**
|
|
21
|
+
* Merge DEFAULT_MEMORY_CONFIG with settings.memory; resolve wikiDir when not explicitly set.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveMemoryConfig(settings: ProjectSettings, workspaceRoot: string): MemoryConfig;
|
|
17
24
|
/**
|
|
18
25
|
* Get file extensions - from settings.json or defaults
|
|
19
26
|
* Allows project-specific customization
|
package/dist/tools/config.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'fs';
|
|
6
6
|
import * as path from 'path';
|
|
7
|
+
import { DEFAULT_MEMORY_CONFIG } from '../memory/types.js';
|
|
7
8
|
/**
|
|
8
9
|
* Default file extensions - expanded to support more languages
|
|
9
10
|
* Used by kit_get_project_context and other tools
|
|
@@ -32,6 +33,20 @@ export const DEFAULT_EXTENSIONS = [
|
|
|
32
33
|
'.yml', // Config files
|
|
33
34
|
'.md', // Documentation
|
|
34
35
|
];
|
|
36
|
+
/**
|
|
37
|
+
* Merge DEFAULT_MEMORY_CONFIG with settings.memory; resolve wikiDir when not explicitly set.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveMemoryConfig(settings, workspaceRoot) {
|
|
40
|
+
const override = settings.memory ?? {};
|
|
41
|
+
return {
|
|
42
|
+
...DEFAULT_MEMORY_CONFIG,
|
|
43
|
+
...override,
|
|
44
|
+
enabled: override.enabled ?? DEFAULT_MEMORY_CONFIG.enabled,
|
|
45
|
+
wikiDir: typeof override.wikiDir === 'string' && override.wikiDir.length > 0
|
|
46
|
+
? override.wikiDir
|
|
47
|
+
: path.join(workspaceRoot, '.agent-kit', 'wiki'),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
35
50
|
/**
|
|
36
51
|
* Get file extensions - from settings.json or defaults
|
|
37
52
|
* Allows project-specific customization
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { MemoryIndexer } from '../memory/indexer.js';
|
|
3
|
+
import { MemoryStore } from '../memory/store.js';
|
|
4
|
+
import type { MemoryConfig } from '../memory/types.js';
|
|
5
|
+
import { type ProjectSettings } from './config.js';
|
|
6
|
+
/**
|
|
7
|
+
* Registers tool handlers onto an already-constructed indexer/store pair.
|
|
8
|
+
* Exported for unit testing with mocks.
|
|
9
|
+
*/
|
|
10
|
+
export declare function registerMemoryToolHandlers(server: McpServer, indexer: MemoryIndexer, store: MemoryStore, config: MemoryConfig): void;
|
|
11
|
+
/**
|
|
12
|
+
* Initializes the memory subsystem and registers all memory tools.
|
|
13
|
+
* Returns the MemoryIndexer so the caller can fire startupIndex() after server.connect().
|
|
14
|
+
* Returns null when memory is disabled (settings.memory.enabled !== true).
|
|
15
|
+
*/
|
|
16
|
+
export declare function registerMemoryTools(server: McpServer, settings: ProjectSettings, workspaceRoot: string): MemoryIndexer | null;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { Embedder } from '../memory/embedder.js';
|
|
4
|
+
import { MemoryIndexer } from '../memory/indexer.js';
|
|
5
|
+
import { MemoryStore } from '../memory/store.js';
|
|
6
|
+
import { mcpJson, mcpText } from '../utils/utils.js';
|
|
7
|
+
import { resolveMemoryConfig } from './config.js';
|
|
8
|
+
/**
|
|
9
|
+
* Registers tool handlers onto an already-constructed indexer/store pair.
|
|
10
|
+
* Exported for unit testing with mocks.
|
|
11
|
+
*/
|
|
12
|
+
export function registerMemoryToolHandlers(server, indexer, store, config) {
|
|
13
|
+
server.tool('kit_memory_search', 'Search persistent memory for context relevant to the current question. Returns semantically relevant results using hybrid dense+BM25+RRF search.', {
|
|
14
|
+
query: z.string().min(1).describe('Search query'),
|
|
15
|
+
top_k: z.number().int().positive().optional().describe('Number of results to return'),
|
|
16
|
+
}, async ({ query, top_k }) => {
|
|
17
|
+
try {
|
|
18
|
+
const results = await indexer.search(query, top_k ?? config.topK);
|
|
19
|
+
if (results.length === 0) {
|
|
20
|
+
const degradedNote = !store.vecAvailable
|
|
21
|
+
? '⚠️ Vector search unavailable — showing keyword-only results.\n\n'
|
|
22
|
+
: '';
|
|
23
|
+
return mcpText(`${degradedNote}No memories found for query: "${query}"`);
|
|
24
|
+
}
|
|
25
|
+
const degradedNote = !store.vecAvailable
|
|
26
|
+
? '⚠️ Vector search unavailable — showing keyword-only results.\n\n'
|
|
27
|
+
: '';
|
|
28
|
+
const formatted = results
|
|
29
|
+
.map((r) => {
|
|
30
|
+
const displaySource = r.chunk.source.replace(/^compiled\//, '');
|
|
31
|
+
const content = r.contentSource === 'fallback'
|
|
32
|
+
? `⚠️ Source file unavailable — showing matched chunk only:\n${r.chunk.content}`
|
|
33
|
+
: r.chunk.content;
|
|
34
|
+
return `### ${displaySource} (score: ${r.score.toFixed(3)})\n${content}`;
|
|
35
|
+
})
|
|
36
|
+
.join('\n\n---\n\n');
|
|
37
|
+
return mcpText(`${degradedNote}${formatted}`);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
41
|
+
return mcpText(`kit_memory_search failed: ${message}`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
server.tool('kit_memory_save', 'Save content to wiki/raw for inclusion after the next /wiki compile.', {
|
|
45
|
+
content: z.string().min(1).describe('Content to save to memory'),
|
|
46
|
+
}, async ({ content }) => {
|
|
47
|
+
try {
|
|
48
|
+
await indexer.save(content);
|
|
49
|
+
return mcpJson({
|
|
50
|
+
saved: true,
|
|
51
|
+
queued_for_compile: true,
|
|
52
|
+
message: 'Saved to wiki/raw — will be indexed after next /wiki compile',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
57
|
+
return mcpJson({ saved: false, error: message });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Initializes the memory subsystem and registers all memory tools.
|
|
63
|
+
* Returns the MemoryIndexer so the caller can fire startupIndex() after server.connect().
|
|
64
|
+
* Returns null when memory is disabled (settings.memory.enabled !== true).
|
|
65
|
+
*/
|
|
66
|
+
export function registerMemoryTools(server, settings, workspaceRoot) {
|
|
67
|
+
if (settings.memory?.enabled !== true)
|
|
68
|
+
return null;
|
|
69
|
+
const config = resolveMemoryConfig(settings, workspaceRoot);
|
|
70
|
+
const store = new MemoryStore(path.join(config.wikiDir, 'index.db'), config);
|
|
71
|
+
const embedder = new Embedder(config.embeddingModel);
|
|
72
|
+
const indexer = new MemoryIndexer(store, embedder, config);
|
|
73
|
+
registerMemoryToolHandlers(server, indexer, store, config);
|
|
74
|
+
return indexer;
|
|
75
|
+
}
|
package/dist/utils/utils.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export function getWorkspaceRoot() {
|
|
2
2
|
return (process.env.WORKSPACE_DIR ||
|
|
3
|
-
process.env.
|
|
3
|
+
process.env.CLAUDE_PROJECT_DIR ||
|
|
4
4
|
process.env.CODEX_PROJECT_DIR ||
|
|
5
|
+
process.env.GEMINI_PROJECT_DIR ||
|
|
5
6
|
process.env.CLAUDE_WORKSPACE ||
|
|
7
|
+
process.env.CODEX_WORKSPACE ||
|
|
6
8
|
process.env.GEMINI_WORKSPACE ||
|
|
7
9
|
process.env.INIT_CWD ||
|
|
8
10
|
process.env.PWD ||
|