agent-memory-store 0.0.4 → 0.0.6
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/README.MD +123 -92
- package/package.json +10 -4
- package/src/db.js +354 -0
- package/src/embeddings.js +124 -0
- package/src/index.js +21 -8
- package/src/migrate.js +124 -0
- package/src/search.js +151 -0
- package/src/store.js +112 -185
package/src/db.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite database layer powered by node:sqlite (built-in).
|
|
3
|
+
*
|
|
4
|
+
* Single-file database at <STORE_PATH>/store.db with WAL mode.
|
|
5
|
+
* FTS5 for full-text BM25 search, BLOB columns for vector embeddings.
|
|
6
|
+
* Zero external dependencies — uses Node.js native SQLite (>=22.5).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import { mkdirSync } from "fs";
|
|
11
|
+
import path from "path";
|
|
12
|
+
|
|
13
|
+
const STORE_PATH = process.env.AGENT_STORE_PATH
|
|
14
|
+
? path.resolve(process.env.AGENT_STORE_PATH)
|
|
15
|
+
: path.join(process.cwd(), ".agent-memory-store");
|
|
16
|
+
|
|
17
|
+
const DB_PATH = path.join(STORE_PATH, "store.db");
|
|
18
|
+
|
|
19
|
+
let db = null;
|
|
20
|
+
|
|
21
|
+
// ─── Schema ─────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const SCHEMA_TABLES = `
|
|
24
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
topic TEXT NOT NULL,
|
|
27
|
+
agent TEXT NOT NULL DEFAULT 'global',
|
|
28
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
29
|
+
importance TEXT NOT NULL DEFAULT 'medium',
|
|
30
|
+
content TEXT NOT NULL,
|
|
31
|
+
embedding BLOB,
|
|
32
|
+
created_at TEXT NOT NULL,
|
|
33
|
+
updated_at TEXT NOT NULL,
|
|
34
|
+
expires_at TEXT
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_agent ON chunks(agent);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_updated ON chunks(updated_at);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_expires ON chunks(expires_at);
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS state (
|
|
42
|
+
key TEXT PRIMARY KEY,
|
|
43
|
+
value TEXT NOT NULL,
|
|
44
|
+
updated_at TEXT NOT NULL
|
|
45
|
+
);
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const SCHEMA_FTS = `
|
|
49
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
50
|
+
id UNINDEXED,
|
|
51
|
+
topic,
|
|
52
|
+
tags,
|
|
53
|
+
agent,
|
|
54
|
+
content,
|
|
55
|
+
content='chunks',
|
|
56
|
+
content_rowid=rowid
|
|
57
|
+
);
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const SCHEMA_TRIGGERS = `
|
|
61
|
+
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
|
|
62
|
+
INSERT INTO chunks_fts(rowid, id, topic, tags, agent, content)
|
|
63
|
+
VALUES (new.rowid, new.id, new.topic, new.tags, new.agent, new.content);
|
|
64
|
+
END;
|
|
65
|
+
|
|
66
|
+
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
|
|
67
|
+
INSERT INTO chunks_fts(chunks_fts, rowid, id, topic, tags, agent, content)
|
|
68
|
+
VALUES ('delete', old.rowid, old.id, old.topic, old.tags, old.agent, old.content);
|
|
69
|
+
END;
|
|
70
|
+
|
|
71
|
+
CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
|
|
72
|
+
INSERT INTO chunks_fts(chunks_fts, rowid, id, topic, tags, agent, content)
|
|
73
|
+
VALUES ('delete', old.rowid, old.id, old.topic, old.tags, old.agent, old.content);
|
|
74
|
+
INSERT INTO chunks_fts(rowid, id, topic, tags, agent, content)
|
|
75
|
+
VALUES (new.rowid, new.id, new.topic, new.tags, new.agent, new.content);
|
|
76
|
+
END;
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
// ─── Initialization ─────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Returns the database instance. Creates it on first call.
|
|
83
|
+
* Synchronous — node:sqlite DatabaseSync is synchronous by design.
|
|
84
|
+
*/
|
|
85
|
+
export function getDb() {
|
|
86
|
+
if (db) return db;
|
|
87
|
+
|
|
88
|
+
mkdirSync(STORE_PATH, { recursive: true });
|
|
89
|
+
|
|
90
|
+
db = new DatabaseSync(DB_PATH);
|
|
91
|
+
|
|
92
|
+
// WAL mode for better concurrent read performance
|
|
93
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
94
|
+
|
|
95
|
+
// Run schema
|
|
96
|
+
db.exec(SCHEMA_TABLES);
|
|
97
|
+
db.exec(SCHEMA_FTS);
|
|
98
|
+
db.exec(SCHEMA_TRIGGERS);
|
|
99
|
+
|
|
100
|
+
// Purge expired chunks
|
|
101
|
+
db.prepare(
|
|
102
|
+
`DELETE FROM chunks WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`,
|
|
103
|
+
).run();
|
|
104
|
+
|
|
105
|
+
// Graceful shutdown
|
|
106
|
+
const shutdown = () => {
|
|
107
|
+
if (db) db.close();
|
|
108
|
+
process.exit(0);
|
|
109
|
+
};
|
|
110
|
+
process.on("SIGINT", shutdown);
|
|
111
|
+
process.on("SIGTERM", shutdown);
|
|
112
|
+
|
|
113
|
+
return db;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── CRUD Operations ────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Inserts or replaces a chunk in the database.
|
|
120
|
+
*/
|
|
121
|
+
export function insertChunk({
|
|
122
|
+
id,
|
|
123
|
+
topic,
|
|
124
|
+
agent,
|
|
125
|
+
tags,
|
|
126
|
+
importance,
|
|
127
|
+
content,
|
|
128
|
+
embedding,
|
|
129
|
+
createdAt,
|
|
130
|
+
updatedAt,
|
|
131
|
+
expiresAt,
|
|
132
|
+
}) {
|
|
133
|
+
const d = getDb();
|
|
134
|
+
d.prepare(
|
|
135
|
+
`INSERT OR REPLACE INTO chunks (id, topic, agent, tags, importance, content, embedding, created_at, updated_at, expires_at)
|
|
136
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
137
|
+
).run(
|
|
138
|
+
id,
|
|
139
|
+
topic,
|
|
140
|
+
agent,
|
|
141
|
+
JSON.stringify(tags),
|
|
142
|
+
importance,
|
|
143
|
+
content,
|
|
144
|
+
embedding ? Buffer.from(embedding.buffer) : null,
|
|
145
|
+
createdAt,
|
|
146
|
+
updatedAt,
|
|
147
|
+
expiresAt,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Retrieves a single chunk by ID.
|
|
153
|
+
* @returns {object|null}
|
|
154
|
+
*/
|
|
155
|
+
export function getChunk(id) {
|
|
156
|
+
const d = getDb();
|
|
157
|
+
const row = d.prepare(`SELECT * FROM chunks WHERE id = ?`).get(id);
|
|
158
|
+
if (!row) return null;
|
|
159
|
+
return parseChunkRow(row);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Deletes a chunk by ID.
|
|
164
|
+
* @returns {boolean} true if a row was deleted
|
|
165
|
+
*/
|
|
166
|
+
export function deleteChunkById(id) {
|
|
167
|
+
const d = getDb();
|
|
168
|
+
const result = d.prepare(`DELETE FROM chunks WHERE id = ?`).run(id);
|
|
169
|
+
return result.changes > 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Lists chunk metadata, with optional agent/tags filters.
|
|
174
|
+
* Sorted by updated_at descending.
|
|
175
|
+
*/
|
|
176
|
+
export function listChunksDb({ agent, tags = [] } = {}) {
|
|
177
|
+
const d = getDb();
|
|
178
|
+
let sql = `SELECT id, topic, agent, tags, importance, updated_at FROM chunks`;
|
|
179
|
+
const conditions = [];
|
|
180
|
+
const params = [];
|
|
181
|
+
|
|
182
|
+
if (agent) {
|
|
183
|
+
conditions.push(`agent = ?`);
|
|
184
|
+
params.push(agent);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (tags.length > 0) {
|
|
188
|
+
const tagConditions = tags.map(() => `tags LIKE ?`);
|
|
189
|
+
conditions.push(`(${tagConditions.join(" OR ")})`);
|
|
190
|
+
params.push(...tags.map((t) => `%"${t}"%`));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (conditions.length) sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
194
|
+
sql += ` ORDER BY updated_at DESC`;
|
|
195
|
+
|
|
196
|
+
const rows = d.prepare(sql).all(...params);
|
|
197
|
+
return rows.map((r) => ({
|
|
198
|
+
id: r.id,
|
|
199
|
+
topic: r.topic,
|
|
200
|
+
agent: r.agent,
|
|
201
|
+
tags: JSON.parse(r.tags),
|
|
202
|
+
importance: r.importance,
|
|
203
|
+
updated: r.updated_at,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Full-text search via FTS5 (BM25).
|
|
209
|
+
* Returns ranked results with scores.
|
|
210
|
+
*/
|
|
211
|
+
export function searchFTS({ query, agent, tags = [], topK = 18 }) {
|
|
212
|
+
const d = getDb();
|
|
213
|
+
|
|
214
|
+
// Escape FTS5 special chars and build query
|
|
215
|
+
const ftsQuery = query
|
|
216
|
+
.replace(/["*^:(){}[\]]/g, " ")
|
|
217
|
+
.split(/\s+/)
|
|
218
|
+
.filter((t) => t.length > 1)
|
|
219
|
+
.join(" OR ");
|
|
220
|
+
|
|
221
|
+
if (!ftsQuery) return [];
|
|
222
|
+
|
|
223
|
+
let sql = `
|
|
224
|
+
SELECT chunks_fts.id, rank
|
|
225
|
+
FROM chunks_fts
|
|
226
|
+
JOIN chunks ON chunks.id = chunks_fts.id
|
|
227
|
+
WHERE chunks_fts MATCH ?`;
|
|
228
|
+
const params = [ftsQuery];
|
|
229
|
+
|
|
230
|
+
if (agent) {
|
|
231
|
+
sql += ` AND chunks.agent = ?`;
|
|
232
|
+
params.push(agent);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (tags.length > 0) {
|
|
236
|
+
const tagConditions = tags.map(() => `chunks.tags LIKE ?`);
|
|
237
|
+
sql += ` AND (${tagConditions.join(" OR ")})`;
|
|
238
|
+
params.push(...tags.map((t) => `%"${t}"%`));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
sql += ` ORDER BY rank LIMIT ?`;
|
|
242
|
+
params.push(topK);
|
|
243
|
+
|
|
244
|
+
const rows = d.prepare(sql).all(...params);
|
|
245
|
+
return rows.map((r) => ({
|
|
246
|
+
id: r.id,
|
|
247
|
+
score: -r.rank, // FTS5 rank is negative (lower = better), invert
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Retrieves all embeddings for vector search.
|
|
253
|
+
* @returns {Array<{ id: string, embedding: Float32Array }>}
|
|
254
|
+
*/
|
|
255
|
+
export function getAllEmbeddings({ agent, tags = [] } = {}) {
|
|
256
|
+
const d = getDb();
|
|
257
|
+
let sql = `SELECT id, embedding FROM chunks WHERE embedding IS NOT NULL`;
|
|
258
|
+
const params = [];
|
|
259
|
+
|
|
260
|
+
if (agent) {
|
|
261
|
+
sql += ` AND agent = ?`;
|
|
262
|
+
params.push(agent);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (tags.length > 0) {
|
|
266
|
+
const tagConditions = tags.map(() => `tags LIKE ?`);
|
|
267
|
+
sql += ` AND (${tagConditions.join(" OR ")})`;
|
|
268
|
+
params.push(...tags.map((t) => `%"${t}"%`));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const rows = d.prepare(sql).all(...params);
|
|
272
|
+
return rows
|
|
273
|
+
.filter((r) => r.embedding !== null)
|
|
274
|
+
.map((r) => ({
|
|
275
|
+
id: r.id,
|
|
276
|
+
embedding: new Float32Array(
|
|
277
|
+
r.embedding.buffer,
|
|
278
|
+
r.embedding.byteOffset,
|
|
279
|
+
r.embedding.byteLength / 4,
|
|
280
|
+
),
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Updates only the embedding for a chunk.
|
|
286
|
+
*/
|
|
287
|
+
export function updateEmbedding(id, embedding) {
|
|
288
|
+
const d = getDb();
|
|
289
|
+
d.prepare(`UPDATE chunks SET embedding = ? WHERE id = ?`).run(
|
|
290
|
+
Buffer.from(embedding.buffer),
|
|
291
|
+
id,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Returns chunks that have no embedding yet.
|
|
297
|
+
*/
|
|
298
|
+
export function getChunksWithoutEmbedding() {
|
|
299
|
+
const d = getDb();
|
|
300
|
+
return d
|
|
301
|
+
.prepare(
|
|
302
|
+
`SELECT id, topic, tags, content FROM chunks WHERE embedding IS NULL`,
|
|
303
|
+
)
|
|
304
|
+
.all()
|
|
305
|
+
.map((r) => ({
|
|
306
|
+
id: r.id,
|
|
307
|
+
topic: r.topic,
|
|
308
|
+
tags: r.tags,
|
|
309
|
+
content: r.content,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── State Operations ───────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
export function getStateDb(key) {
|
|
316
|
+
const d = getDb();
|
|
317
|
+
const row = d.prepare(`SELECT value FROM state WHERE key = ?`).get(key);
|
|
318
|
+
if (!row) return null;
|
|
319
|
+
return JSON.parse(row.value);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function setStateDb(key, value) {
|
|
323
|
+
const d = getDb();
|
|
324
|
+
const updatedAt = new Date().toISOString();
|
|
325
|
+
d.prepare(
|
|
326
|
+
`INSERT OR REPLACE INTO state (key, value, updated_at) VALUES (?, ?, ?)`,
|
|
327
|
+
).run(key, JSON.stringify(value), updatedAt);
|
|
328
|
+
return { key, updated: updatedAt };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
function parseChunkRow(row) {
|
|
334
|
+
return {
|
|
335
|
+
id: row.id,
|
|
336
|
+
topic: row.topic,
|
|
337
|
+
agent: row.agent,
|
|
338
|
+
tags: JSON.parse(row.tags),
|
|
339
|
+
importance: row.importance,
|
|
340
|
+
content: row.content,
|
|
341
|
+
embedding: row.embedding
|
|
342
|
+
? new Float32Array(
|
|
343
|
+
row.embedding.buffer,
|
|
344
|
+
row.embedding.byteOffset,
|
|
345
|
+
row.embedding.byteLength / 4,
|
|
346
|
+
)
|
|
347
|
+
: null,
|
|
348
|
+
createdAt: row.created_at,
|
|
349
|
+
updatedAt: row.updated_at,
|
|
350
|
+
expiresAt: row.expires_at,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export { STORE_PATH };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local embedding generation via @huggingface/transformers.
|
|
3
|
+
*
|
|
4
|
+
* Uses the all-MiniLM-L6-v2 model (384 dimensions) running locally via ONNX Runtime.
|
|
5
|
+
* Model is auto-downloaded (~23MB) on first use and cached in ~/.cache/huggingface/.
|
|
6
|
+
*
|
|
7
|
+
* Graceful degradation: if the model fails to load, all functions return null
|
|
8
|
+
* and the system falls back to BM25-only search.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let pipelineInstance = null;
|
|
12
|
+
let loadFailed = false;
|
|
13
|
+
let loadingPromise = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Lazily initializes the feature-extraction pipeline.
|
|
17
|
+
* Returns null if the model cannot be loaded.
|
|
18
|
+
* Ensures only one load attempt runs at a time.
|
|
19
|
+
*/
|
|
20
|
+
async function getPipeline() {
|
|
21
|
+
if (pipelineInstance) return pipelineInstance;
|
|
22
|
+
if (loadFailed) return null;
|
|
23
|
+
|
|
24
|
+
// Deduplicate concurrent load attempts
|
|
25
|
+
if (loadingPromise) return loadingPromise;
|
|
26
|
+
|
|
27
|
+
loadingPromise = (async () => {
|
|
28
|
+
try {
|
|
29
|
+
process.stderr.write(
|
|
30
|
+
"[agent-memory-store] Loading embedding model (first run downloads ~23MB)...\n",
|
|
31
|
+
);
|
|
32
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
33
|
+
pipelineInstance = await pipeline(
|
|
34
|
+
"feature-extraction",
|
|
35
|
+
"Xenova/all-MiniLM-L6-v2",
|
|
36
|
+
{ dtype: "fp32" },
|
|
37
|
+
);
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
"[agent-memory-store] Embedding model loaded successfully.\n",
|
|
40
|
+
);
|
|
41
|
+
return pipelineInstance;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
loadFailed = true;
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
`[agent-memory-store] Embedding model failed to load: ${err.message}\n` +
|
|
46
|
+
`[agent-memory-store] Falling back to BM25-only search.\n`,
|
|
47
|
+
);
|
|
48
|
+
return null;
|
|
49
|
+
} finally {
|
|
50
|
+
loadingPromise = null;
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
|
|
54
|
+
return loadingPromise;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generates an embedding for a single text string.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} text - Text to embed (topic + tags + content)
|
|
61
|
+
* @returns {Promise<Float32Array|null>} 384-dim embedding or null if unavailable
|
|
62
|
+
*/
|
|
63
|
+
export async function embed(text) {
|
|
64
|
+
const extractor = await getPipeline();
|
|
65
|
+
if (!extractor) return null;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const output = await extractor(text, {
|
|
69
|
+
pooling: "mean",
|
|
70
|
+
normalize: true,
|
|
71
|
+
});
|
|
72
|
+
return new Float32Array(output.data);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
process.stderr.write(
|
|
75
|
+
`[agent-memory-store] Embedding error: ${err.message}\n`,
|
|
76
|
+
);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generates embeddings for multiple texts.
|
|
83
|
+
*
|
|
84
|
+
* @param {string[]} texts
|
|
85
|
+
* @returns {Promise<Array<Float32Array|null>>}
|
|
86
|
+
*/
|
|
87
|
+
export async function embedBatch(texts) {
|
|
88
|
+
const results = [];
|
|
89
|
+
for (const text of texts) {
|
|
90
|
+
results.push(await embed(text));
|
|
91
|
+
}
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Prepares searchable text from chunk fields for embedding.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} chunk
|
|
99
|
+
* @param {string} chunk.topic
|
|
100
|
+
* @param {string[]|string} chunk.tags
|
|
101
|
+
* @param {string} chunk.content
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function prepareText({ topic, tags, content }) {
|
|
105
|
+
const tagStr = Array.isArray(tags) ? tags.join(" ") : tags || "";
|
|
106
|
+
// Truncate content to ~800 chars to stay within model token limit
|
|
107
|
+
const truncated = content.length > 800 ? content.slice(0, 800) : content;
|
|
108
|
+
return `${topic} ${tagStr} ${truncated}`.trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns whether the embedding model is available.
|
|
113
|
+
*/
|
|
114
|
+
export function isEmbeddingAvailable() {
|
|
115
|
+
return pipelineInstance !== null && !loadFailed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Pre-warms the embedding model (call during startup).
|
|
120
|
+
* Non-blocking — failures are silently handled.
|
|
121
|
+
*/
|
|
122
|
+
export async function warmup() {
|
|
123
|
+
await getPipeline();
|
|
124
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* agent-store MCP server entry point.
|
|
3
|
+
* agent-memory-store MCP server entry point.
|
|
4
4
|
*
|
|
5
5
|
* Exposes 7 tools to any MCP-compatible client (Claude Code, opencode, etc.):
|
|
6
|
-
* search_context — BM25
|
|
6
|
+
* search_context — Hybrid search (BM25 + semantic) over stored chunks
|
|
7
7
|
* write_context — persist a new memory chunk
|
|
8
8
|
* read_context — retrieve a chunk by ID
|
|
9
9
|
* list_context — list chunk metadata (no body)
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* set_state — write a session state variable
|
|
13
13
|
*
|
|
14
14
|
* Usage:
|
|
15
|
-
* npx
|
|
16
|
-
*
|
|
15
|
+
* npx agent-memory-store
|
|
16
|
+
* AGENT_STORE_PATH=/your/project/.agent-memory-store npx agent-memory-store
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
listChunks,
|
|
28
28
|
getState,
|
|
29
29
|
setState,
|
|
30
|
+
initStore,
|
|
30
31
|
} from "./store.js";
|
|
31
32
|
|
|
32
33
|
const { version } = JSON.parse(
|
|
@@ -35,6 +36,9 @@ const { version } = JSON.parse(
|
|
|
35
36
|
),
|
|
36
37
|
);
|
|
37
38
|
|
|
39
|
+
// Initialize database, run migration, warm up embeddings
|
|
40
|
+
await initStore();
|
|
41
|
+
|
|
38
42
|
const server = new McpServer({
|
|
39
43
|
name: "context-store",
|
|
40
44
|
version,
|
|
@@ -45,9 +49,10 @@ const server = new McpServer({
|
|
|
45
49
|
server.tool(
|
|
46
50
|
"search_context",
|
|
47
51
|
[
|
|
48
|
-
"Search stored memory chunks
|
|
52
|
+
"Search stored memory chunks using hybrid ranking (BM25 + semantic similarity).",
|
|
49
53
|
"Call this at the start of any task to retrieve relevant prior knowledge,",
|
|
50
54
|
"decisions, and outputs before generating a response.",
|
|
55
|
+
"Supports three modes: 'hybrid' (default, best quality), 'bm25' (keyword-only), 'semantic' (meaning-only).",
|
|
51
56
|
].join(" "),
|
|
52
57
|
{
|
|
53
58
|
query: z
|
|
@@ -75,16 +80,23 @@ server.tool(
|
|
|
75
80
|
.min(0)
|
|
76
81
|
.optional()
|
|
77
82
|
.describe(
|
|
78
|
-
"Minimum
|
|
83
|
+
"Minimum relevance score. Lower = more permissive (default: 0.1).",
|
|
84
|
+
),
|
|
85
|
+
search_mode: z
|
|
86
|
+
.enum(["hybrid", "bm25", "semantic"])
|
|
87
|
+
.optional()
|
|
88
|
+
.describe(
|
|
89
|
+
"Search strategy: 'hybrid' (BM25 + semantic, default), 'bm25' (keyword only), 'semantic' (embedding similarity only).",
|
|
79
90
|
),
|
|
80
91
|
},
|
|
81
|
-
async ({ query, tags, agent, top_k, min_score }) => {
|
|
92
|
+
async ({ query, tags, agent, top_k, min_score, search_mode }) => {
|
|
82
93
|
const results = await searchChunks({
|
|
83
94
|
query,
|
|
84
95
|
tags: tags ?? [],
|
|
85
96
|
agent,
|
|
86
97
|
topK: top_k ?? 6,
|
|
87
98
|
minScore: min_score ?? 0.1,
|
|
99
|
+
mode: search_mode ?? "hybrid",
|
|
88
100
|
});
|
|
89
101
|
|
|
90
102
|
if (results.length === 0) {
|
|
@@ -111,9 +123,10 @@ server.tool(
|
|
|
111
123
|
server.tool(
|
|
112
124
|
"write_context",
|
|
113
125
|
[
|
|
114
|
-
"Persist a memory chunk to
|
|
126
|
+
"Persist a memory chunk to the database.",
|
|
115
127
|
"Call this after completing a subtask, making a key decision,",
|
|
116
128
|
"or producing output that downstream agents will need.",
|
|
129
|
+
"Embeddings are computed automatically in the background for semantic search.",
|
|
117
130
|
].join(" "),
|
|
118
131
|
{
|
|
119
132
|
topic: z
|
package/src/migrate.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration: filesystem-based storage → SQLite database.
|
|
3
|
+
*
|
|
4
|
+
* Runs automatically on first startup if the legacy chunks/ directory exists
|
|
5
|
+
* but store.db does not. Migrates all chunks and state, then renames the
|
|
6
|
+
* legacy directories to *_backup/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "fs/promises";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import matter from "gray-matter";
|
|
12
|
+
import { insertChunk, setStateDb, STORE_PATH } from "./db.js";
|
|
13
|
+
|
|
14
|
+
const CHUNKS_DIR = path.join(STORE_PATH, "chunks");
|
|
15
|
+
const STATE_DIR = path.join(STORE_PATH, "state");
|
|
16
|
+
const DB_PATH = path.join(STORE_PATH, "store.db");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Checks if migration is needed and runs it.
|
|
20
|
+
* @returns {Promise<boolean>} true if migration was performed
|
|
21
|
+
*/
|
|
22
|
+
export async function migrateIfNeeded() {
|
|
23
|
+
// Check if legacy chunks dir exists
|
|
24
|
+
const chunksExist = await fs
|
|
25
|
+
.stat(CHUNKS_DIR)
|
|
26
|
+
.then((s) => s.isDirectory())
|
|
27
|
+
.catch(() => false);
|
|
28
|
+
|
|
29
|
+
if (!chunksExist) return false;
|
|
30
|
+
|
|
31
|
+
// Check if DB already exists (already migrated)
|
|
32
|
+
const dbExists = await fs
|
|
33
|
+
.stat(DB_PATH)
|
|
34
|
+
.then((s) => s.isFile())
|
|
35
|
+
.catch(() => false);
|
|
36
|
+
|
|
37
|
+
if (dbExists) return false;
|
|
38
|
+
|
|
39
|
+
process.stderr.write(
|
|
40
|
+
"[agent-memory-store] Migrating filesystem storage to SQLite...\n",
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
let chunkCount = 0;
|
|
44
|
+
let stateCount = 0;
|
|
45
|
+
|
|
46
|
+
// Migrate chunks
|
|
47
|
+
try {
|
|
48
|
+
const files = await fs.readdir(CHUNKS_DIR);
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
if (!file.endsWith(".md")) continue;
|
|
51
|
+
try {
|
|
52
|
+
const raw = await fs.readFile(path.join(CHUNKS_DIR, file), "utf8");
|
|
53
|
+
const { data: meta, content } = matter(raw);
|
|
54
|
+
|
|
55
|
+
// Skip expired chunks
|
|
56
|
+
if (meta.expires && new Date(meta.expires) < new Date()) continue;
|
|
57
|
+
|
|
58
|
+
const now = new Date().toISOString();
|
|
59
|
+
await insertChunk({
|
|
60
|
+
id: meta.id || file.replace(".md", ""),
|
|
61
|
+
topic: meta.topic || "Untitled",
|
|
62
|
+
agent: meta.agent || "global",
|
|
63
|
+
tags: meta.tags || [],
|
|
64
|
+
importance: meta.importance || "medium",
|
|
65
|
+
content: content.trim(),
|
|
66
|
+
embedding: null, // Will be computed in background
|
|
67
|
+
createdAt: meta.updated || now,
|
|
68
|
+
updatedAt: meta.updated || now,
|
|
69
|
+
expiresAt: meta.expires || null,
|
|
70
|
+
});
|
|
71
|
+
chunkCount++;
|
|
72
|
+
} catch {
|
|
73
|
+
// Skip unreadable files
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// chunks dir not readable
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Migrate state
|
|
81
|
+
try {
|
|
82
|
+
const files = await fs.readdir(STATE_DIR);
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
if (!file.endsWith(".json")) continue;
|
|
85
|
+
try {
|
|
86
|
+
const raw = await fs.readFile(path.join(STATE_DIR, file), "utf8");
|
|
87
|
+
const { key, value } = JSON.parse(raw);
|
|
88
|
+
if (key) {
|
|
89
|
+
await setStateDb(key, value);
|
|
90
|
+
stateCount++;
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Skip unreadable files
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// state dir not readable
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Rename legacy directories to backups
|
|
101
|
+
try {
|
|
102
|
+
await fs.rename(CHUNKS_DIR, CHUNKS_DIR + "_backup");
|
|
103
|
+
} catch {
|
|
104
|
+
// Rename failed — not critical
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const stateExists = await fs
|
|
109
|
+
.stat(STATE_DIR)
|
|
110
|
+
.then((s) => s.isDirectory())
|
|
111
|
+
.catch(() => false);
|
|
112
|
+
if (stateExists) {
|
|
113
|
+
await fs.rename(STATE_DIR, STATE_DIR + "_backup");
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Rename failed — not critical
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
process.stderr.write(
|
|
120
|
+
`[agent-memory-store] Migration complete: ${chunkCount} chunks, ${stateCount} state entries.\n`,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return true;
|
|
124
|
+
}
|