agent-memory-store 0.0.6 → 0.0.8
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 +19 -9
- package/package.json +1 -1
- package/src/db.js +40 -27
- package/src/index.js +20 -2
- package/src/search.js +77 -29
- package/src/store.js +2 -2
package/README.MD
CHANGED
|
@@ -63,15 +63,25 @@ AGENT_STORE_PATH=/your/project/.agent-memory-store npx agent-memory-store
|
|
|
63
63
|
|
|
64
64
|
## Performance
|
|
65
65
|
|
|
66
|
-
Benchmarked on Apple Silicon (Node v25, darwin arm64):
|
|
67
|
-
|
|
68
|
-
| Operation |
|
|
69
|
-
|
|
70
|
-
| **write** |
|
|
71
|
-
| **read** | 0.
|
|
72
|
-
| **search (BM25)** |
|
|
73
|
-
| **list** | 0.2 ms |
|
|
74
|
-
| **state get/set** | 0.03 ms | 0.03 ms | 0.
|
|
66
|
+
Benchmarked on Apple Silicon (Node v25, darwin arm64, BM25 mode):
|
|
67
|
+
|
|
68
|
+
| Operation | 1K chunks | 10K chunks | 50K chunks | 100K chunks | 250K chunks |
|
|
69
|
+
|-----------|-----------|------------|------------|-------------|-------------|
|
|
70
|
+
| **write** | 0.17 ms | 0.19 ms | 0.23 ms | 0.21 ms | 0.25 ms |
|
|
71
|
+
| **read** | 0.01 ms | 0.05 ms | 0.21 ms | 0.22 ms | 0.85 ms |
|
|
72
|
+
| **search (BM25)** | ~5 ms† | ~10 ms† | ~60 ms† | ~110 ms† | ~390 ms† |
|
|
73
|
+
| **list** | 0.2 ms | 0.3 ms | 0.3 ms | 0.3 ms | 1.1 ms |
|
|
74
|
+
| **state get/set** | 0.03 ms | 0.03 ms | 0.07 ms | 0.05 ms | 0.03 ms |
|
|
75
|
+
|
|
76
|
+
† Search times from isolated run (no model loading interference). During warmup, first queries may be slower.
|
|
77
|
+
|
|
78
|
+
**Key insights:**
|
|
79
|
+
|
|
80
|
+
- **list is O(1) in practice** — pagination caps results at 100 rows by default, so list time stays flat regardless of corpus size (0.2–1.1 ms at any scale)
|
|
81
|
+
- **write is stable at ~0.2 ms/op** — FTS5 triggers and embedding backfill are non-blocking; inserts stay constant
|
|
82
|
+
- **read is a single index lookup** — sub-millisecond up to 50K chunks, still <1 ms at 250K
|
|
83
|
+
- **search scales linearly with FTS5 corpus** — this is inherent to BM25 full-text scan; for typical agent memory usage (≤25K chunks), search stays under 30 ms
|
|
84
|
+
- **state ops are O(1)** — key/value store backed by a B-tree primary key, constant at all scales
|
|
75
85
|
|
|
76
86
|
## Configuration
|
|
77
87
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-memory-store",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "Local-first MCP memory server for multi-agent systems. Hybrid search (BM25 + semantic embeddings), SQLite-backed, zero-config.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": "./src/index.js",
|
package/src/db.js
CHANGED
|
@@ -17,6 +17,20 @@ const STORE_PATH = process.env.AGENT_STORE_PATH
|
|
|
17
17
|
const DB_PATH = path.join(STORE_PATH, "store.db");
|
|
18
18
|
|
|
19
19
|
let db = null;
|
|
20
|
+
const stmtCache = new Map();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns a cached prepared statement for static SQL.
|
|
24
|
+
* Avoids re-preparing the same SQL on every call.
|
|
25
|
+
*/
|
|
26
|
+
function stmt(sql) {
|
|
27
|
+
let s = stmtCache.get(sql);
|
|
28
|
+
if (!s) {
|
|
29
|
+
s = getDb().prepare(sql);
|
|
30
|
+
stmtCache.set(sql, s);
|
|
31
|
+
}
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
20
34
|
|
|
21
35
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
|
22
36
|
|
|
@@ -98,9 +112,9 @@ export function getDb() {
|
|
|
98
112
|
db.exec(SCHEMA_TRIGGERS);
|
|
99
113
|
|
|
100
114
|
// Purge expired chunks
|
|
101
|
-
db.
|
|
115
|
+
db.exec(
|
|
102
116
|
`DELETE FROM chunks WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`,
|
|
103
|
-
)
|
|
117
|
+
);
|
|
104
118
|
|
|
105
119
|
// Graceful shutdown
|
|
106
120
|
const shutdown = () => {
|
|
@@ -130,8 +144,7 @@ export function insertChunk({
|
|
|
130
144
|
updatedAt,
|
|
131
145
|
expiresAt,
|
|
132
146
|
}) {
|
|
133
|
-
|
|
134
|
-
d.prepare(
|
|
147
|
+
stmt(
|
|
135
148
|
`INSERT OR REPLACE INTO chunks (id, topic, agent, tags, importance, content, embedding, created_at, updated_at, expires_at)
|
|
136
149
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
137
150
|
).run(
|
|
@@ -153,8 +166,7 @@ export function insertChunk({
|
|
|
153
166
|
* @returns {object|null}
|
|
154
167
|
*/
|
|
155
168
|
export function getChunk(id) {
|
|
156
|
-
const
|
|
157
|
-
const row = d.prepare(`SELECT * FROM chunks WHERE id = ?`).get(id);
|
|
169
|
+
const row = stmt(`SELECT * FROM chunks WHERE id = ?`).get(id);
|
|
158
170
|
if (!row) return null;
|
|
159
171
|
return parseChunkRow(row);
|
|
160
172
|
}
|
|
@@ -164,8 +176,7 @@ export function getChunk(id) {
|
|
|
164
176
|
* @returns {boolean} true if a row was deleted
|
|
165
177
|
*/
|
|
166
178
|
export function deleteChunkById(id) {
|
|
167
|
-
const
|
|
168
|
-
const result = d.prepare(`DELETE FROM chunks WHERE id = ?`).run(id);
|
|
179
|
+
const result = stmt(`DELETE FROM chunks WHERE id = ?`).run(id);
|
|
169
180
|
return result.changes > 0;
|
|
170
181
|
}
|
|
171
182
|
|
|
@@ -173,7 +184,7 @@ export function deleteChunkById(id) {
|
|
|
173
184
|
* Lists chunk metadata, with optional agent/tags filters.
|
|
174
185
|
* Sorted by updated_at descending.
|
|
175
186
|
*/
|
|
176
|
-
export function listChunksDb({ agent, tags = [] } = {}) {
|
|
187
|
+
export function listChunksDb({ agent, tags = [], limit = 100, offset = 0 } = {}) {
|
|
177
188
|
const d = getDb();
|
|
178
189
|
let sql = `SELECT id, topic, agent, tags, importance, updated_at FROM chunks`;
|
|
179
190
|
const conditions = [];
|
|
@@ -191,7 +202,8 @@ export function listChunksDb({ agent, tags = [] } = {}) {
|
|
|
191
202
|
}
|
|
192
203
|
|
|
193
204
|
if (conditions.length) sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
194
|
-
sql += ` ORDER BY updated_at DESC
|
|
205
|
+
sql += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?`;
|
|
206
|
+
params.push(limit, offset);
|
|
195
207
|
|
|
196
208
|
const rows = d.prepare(sql).all(...params);
|
|
197
209
|
return rows.map((r) => ({
|
|
@@ -206,7 +218,7 @@ export function listChunksDb({ agent, tags = [] } = {}) {
|
|
|
206
218
|
|
|
207
219
|
/**
|
|
208
220
|
* Full-text search via FTS5 (BM25).
|
|
209
|
-
* Returns ranked results with
|
|
221
|
+
* Returns ranked results with full chunk data (avoids separate lookups).
|
|
210
222
|
*/
|
|
211
223
|
export function searchFTS({ query, agent, tags = [], topK = 18 }) {
|
|
212
224
|
const d = getDb();
|
|
@@ -221,19 +233,19 @@ export function searchFTS({ query, agent, tags = [], topK = 18 }) {
|
|
|
221
233
|
if (!ftsQuery) return [];
|
|
222
234
|
|
|
223
235
|
let sql = `
|
|
224
|
-
SELECT
|
|
236
|
+
SELECT c.id, c.topic, c.agent, c.tags, c.importance, c.content, c.updated_at, rank
|
|
225
237
|
FROM chunks_fts
|
|
226
|
-
JOIN chunks ON
|
|
238
|
+
JOIN chunks c ON c.id = chunks_fts.id
|
|
227
239
|
WHERE chunks_fts MATCH ?`;
|
|
228
240
|
const params = [ftsQuery];
|
|
229
241
|
|
|
230
242
|
if (agent) {
|
|
231
|
-
sql += ` AND
|
|
243
|
+
sql += ` AND c.agent = ?`;
|
|
232
244
|
params.push(agent);
|
|
233
245
|
}
|
|
234
246
|
|
|
235
247
|
if (tags.length > 0) {
|
|
236
|
-
const tagConditions = tags.map(() => `
|
|
248
|
+
const tagConditions = tags.map(() => `c.tags LIKE ?`);
|
|
237
249
|
sql += ` AND (${tagConditions.join(" OR ")})`;
|
|
238
250
|
params.push(...tags.map((t) => `%"${t}"%`));
|
|
239
251
|
}
|
|
@@ -244,7 +256,13 @@ export function searchFTS({ query, agent, tags = [], topK = 18 }) {
|
|
|
244
256
|
const rows = d.prepare(sql).all(...params);
|
|
245
257
|
return rows.map((r) => ({
|
|
246
258
|
id: r.id,
|
|
247
|
-
|
|
259
|
+
topic: r.topic,
|
|
260
|
+
agent: r.agent,
|
|
261
|
+
tags: JSON.parse(r.tags),
|
|
262
|
+
importance: r.importance,
|
|
263
|
+
content: r.content,
|
|
264
|
+
updated: r.updated_at,
|
|
265
|
+
score: -r.rank,
|
|
248
266
|
}));
|
|
249
267
|
}
|
|
250
268
|
|
|
@@ -285,8 +303,7 @@ export function getAllEmbeddings({ agent, tags = [] } = {}) {
|
|
|
285
303
|
* Updates only the embedding for a chunk.
|
|
286
304
|
*/
|
|
287
305
|
export function updateEmbedding(id, embedding) {
|
|
288
|
-
|
|
289
|
-
d.prepare(`UPDATE chunks SET embedding = ? WHERE id = ?`).run(
|
|
306
|
+
stmt(`UPDATE chunks SET embedding = ? WHERE id = ?`).run(
|
|
290
307
|
Buffer.from(embedding.buffer),
|
|
291
308
|
id,
|
|
292
309
|
);
|
|
@@ -296,11 +313,9 @@ export function updateEmbedding(id, embedding) {
|
|
|
296
313
|
* Returns chunks that have no embedding yet.
|
|
297
314
|
*/
|
|
298
315
|
export function getChunksWithoutEmbedding() {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
`SELECT id, topic, tags, content FROM chunks WHERE embedding IS NULL`,
|
|
303
|
-
)
|
|
316
|
+
return stmt(
|
|
317
|
+
`SELECT id, topic, tags, content FROM chunks WHERE embedding IS NULL`,
|
|
318
|
+
)
|
|
304
319
|
.all()
|
|
305
320
|
.map((r) => ({
|
|
306
321
|
id: r.id,
|
|
@@ -313,16 +328,14 @@ export function getChunksWithoutEmbedding() {
|
|
|
313
328
|
// ─── State Operations ───────────────────────────────────────────────────────
|
|
314
329
|
|
|
315
330
|
export function getStateDb(key) {
|
|
316
|
-
const
|
|
317
|
-
const row = d.prepare(`SELECT value FROM state WHERE key = ?`).get(key);
|
|
331
|
+
const row = stmt(`SELECT value FROM state WHERE key = ?`).get(key);
|
|
318
332
|
if (!row) return null;
|
|
319
333
|
return JSON.parse(row.value);
|
|
320
334
|
}
|
|
321
335
|
|
|
322
336
|
export function setStateDb(key, value) {
|
|
323
|
-
const d = getDb();
|
|
324
337
|
const updatedAt = new Date().toISOString();
|
|
325
|
-
|
|
338
|
+
stmt(
|
|
326
339
|
`INSERT OR REPLACE INTO state (key, value, updated_at) VALUES (?, ?, ?)`,
|
|
327
340
|
).run(key, JSON.stringify(value), updatedAt);
|
|
328
341
|
return { key, updated: updatedAt };
|
package/src/index.js
CHANGED
|
@@ -223,9 +223,27 @@ server.tool(
|
|
|
223
223
|
{
|
|
224
224
|
agent: z.string().optional().describe("Filter by agent ID."),
|
|
225
225
|
tags: z.array(z.string()).optional().describe("Filter by tags."),
|
|
226
|
+
limit: z
|
|
227
|
+
.number()
|
|
228
|
+
.int()
|
|
229
|
+
.min(1)
|
|
230
|
+
.max(500)
|
|
231
|
+
.optional()
|
|
232
|
+
.describe("Maximum number of results to return (default: 100)."),
|
|
233
|
+
offset: z
|
|
234
|
+
.number()
|
|
235
|
+
.int()
|
|
236
|
+
.min(0)
|
|
237
|
+
.optional()
|
|
238
|
+
.describe("Number of results to skip for pagination (default: 0)."),
|
|
226
239
|
},
|
|
227
|
-
async ({ agent, tags }) => {
|
|
228
|
-
const chunks = await listChunks({
|
|
240
|
+
async ({ agent, tags, limit, offset }) => {
|
|
241
|
+
const chunks = await listChunks({
|
|
242
|
+
agent,
|
|
243
|
+
tags: tags ?? [],
|
|
244
|
+
limit: limit ?? 100,
|
|
245
|
+
offset: offset ?? 0,
|
|
246
|
+
});
|
|
229
247
|
|
|
230
248
|
if (chunks.length === 0) {
|
|
231
249
|
return { content: [{ type: "text", text: "Memory store is empty." }] };
|
package/src/search.js
CHANGED
|
@@ -12,6 +12,22 @@
|
|
|
12
12
|
import { searchFTS, getAllEmbeddings, getChunk } from "./db.js";
|
|
13
13
|
import { embed, isEmbeddingAvailable } from "./embeddings.js";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Converts a full FTS result into the enriched output format.
|
|
17
|
+
*/
|
|
18
|
+
function ftsResultToEnriched(r) {
|
|
19
|
+
return {
|
|
20
|
+
id: r.id,
|
|
21
|
+
topic: r.topic,
|
|
22
|
+
agent: r.agent,
|
|
23
|
+
tags: r.tags,
|
|
24
|
+
importance: r.importance,
|
|
25
|
+
score: Math.round(r.score * 100) / 100,
|
|
26
|
+
content: r.content,
|
|
27
|
+
updated: r.updated,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
// ─── Vector Search ──────────────────────────────────────────────────────────
|
|
16
32
|
|
|
17
33
|
/**
|
|
@@ -94,47 +110,80 @@ export async function hybridSearch({
|
|
|
94
110
|
effectiveMode = "bm25";
|
|
95
111
|
}
|
|
96
112
|
|
|
97
|
-
|
|
98
|
-
|
|
113
|
+
// BM25-only: searchFTS already returns full chunk data, no enrichment needed
|
|
99
114
|
if (effectiveMode === "bm25") {
|
|
100
|
-
|
|
101
|
-
|
|
115
|
+
const results = searchFTS({ query, agent, tags, topK: candidateK });
|
|
116
|
+
return results.slice(0, topK).map(ftsResultToEnriched);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Semantic-only
|
|
120
|
+
if (effectiveMode === "semantic") {
|
|
102
121
|
const queryEmbedding = await embed(query);
|
|
103
122
|
if (!queryEmbedding) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
fusedResults = vectorSearch(queryEmbedding, {
|
|
107
|
-
agent,
|
|
108
|
-
tags,
|
|
109
|
-
topK: candidateK,
|
|
110
|
-
});
|
|
123
|
+
const results = searchFTS({ query, agent, tags, topK: candidateK });
|
|
124
|
+
return results.slice(0, topK).map(ftsResultToEnriched);
|
|
111
125
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const bm25Hits = searchFTS({ query, agent, tags, topK: candidateK });
|
|
116
|
-
const queryEmbedding = await queryEmbeddingPromise;
|
|
126
|
+
const vecHits = vectorSearch(queryEmbedding, { agent, tags, topK: candidateK });
|
|
127
|
+
return enrichVectorResults(vecHits.slice(0, topK));
|
|
128
|
+
}
|
|
117
129
|
|
|
118
|
-
|
|
119
|
-
|
|
130
|
+
// Hybrid: run FTS5 (sync) and embed query (async) in parallel
|
|
131
|
+
const queryEmbeddingPromise = embed(query);
|
|
132
|
+
const bm25Hits = searchFTS({ query, agent, tags, topK: candidateK });
|
|
133
|
+
const queryEmbedding = await queryEmbeddingPromise;
|
|
134
|
+
|
|
135
|
+
if (!queryEmbedding) {
|
|
136
|
+
return bm25Hits.slice(0, topK).map(ftsResultToEnriched);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const vecHits = vectorSearch(queryEmbedding, { agent, tags, topK: candidateK });
|
|
140
|
+
const fused = reciprocalRankFusion(bm25Hits, vecHits);
|
|
141
|
+
|
|
142
|
+
// Enrich fused results: build lookup from BM25 data, only fetch missing from DB
|
|
143
|
+
const bm25Map = new Map(bm25Hits.map((r) => [r.id, r]));
|
|
144
|
+
const topResults = fused.slice(0, topK);
|
|
145
|
+
const enriched = [];
|
|
146
|
+
|
|
147
|
+
for (const { id, score } of topResults) {
|
|
148
|
+
const cached = bm25Map.get(id);
|
|
149
|
+
if (cached) {
|
|
150
|
+
enriched.push({
|
|
151
|
+
id: cached.id,
|
|
152
|
+
topic: cached.topic,
|
|
153
|
+
agent: cached.agent,
|
|
154
|
+
tags: cached.tags,
|
|
155
|
+
importance: cached.importance,
|
|
156
|
+
score: Math.round(score * 100) / 100,
|
|
157
|
+
content: cached.content,
|
|
158
|
+
updated: cached.updated,
|
|
159
|
+
});
|
|
120
160
|
} else {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
161
|
+
const chunk = getChunk(id);
|
|
162
|
+
if (!chunk) continue;
|
|
163
|
+
enriched.push({
|
|
164
|
+
id: chunk.id,
|
|
165
|
+
topic: chunk.topic,
|
|
166
|
+
agent: chunk.agent,
|
|
167
|
+
tags: chunk.tags,
|
|
168
|
+
importance: chunk.importance,
|
|
169
|
+
score: Math.round(score * 100) / 100,
|
|
170
|
+
content: chunk.content,
|
|
171
|
+
updated: chunk.updatedAt,
|
|
125
172
|
});
|
|
126
|
-
fusedResults = reciprocalRankFusion(bm25Hits, vecHits);
|
|
127
173
|
}
|
|
128
174
|
}
|
|
129
175
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const enriched = [];
|
|
176
|
+
return enriched;
|
|
177
|
+
}
|
|
133
178
|
|
|
134
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Enriches vector-only results by fetching full chunk data from DB.
|
|
181
|
+
*/
|
|
182
|
+
function enrichVectorResults(vecHits) {
|
|
183
|
+
const enriched = [];
|
|
184
|
+
for (const { id, score } of vecHits) {
|
|
135
185
|
const chunk = getChunk(id);
|
|
136
186
|
if (!chunk) continue;
|
|
137
|
-
|
|
138
187
|
enriched.push({
|
|
139
188
|
id: chunk.id,
|
|
140
189
|
topic: chunk.topic,
|
|
@@ -146,6 +195,5 @@ export async function hybridSearch({
|
|
|
146
195
|
updated: chunk.updatedAt,
|
|
147
196
|
});
|
|
148
197
|
}
|
|
149
|
-
|
|
150
198
|
return enriched;
|
|
151
199
|
}
|
package/src/store.js
CHANGED
|
@@ -193,8 +193,8 @@ export async function deleteChunk(id) {
|
|
|
193
193
|
* @param {string[]} [opts.tags]
|
|
194
194
|
* @returns {Promise<Array>}
|
|
195
195
|
*/
|
|
196
|
-
export async function listChunks({ agent, tags = [] } = {}) {
|
|
197
|
-
return listChunksDb({ agent, tags });
|
|
196
|
+
export async function listChunks({ agent, tags = [], limit = 100, offset = 0 } = {}) {
|
|
197
|
+
return listChunksDb({ agent, tags, limit, offset });
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
/**
|