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 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 | 100 chunks | 1K chunks | 5K chunks | 10K chunks |
69
- |-----------|-----------|-----------|-----------|------------|
70
- | **write** | 2.16 ms | 0.15 ms | 0.15 ms | 0.15 ms |
71
- | **read** | 0.02 ms | 0.02 ms | 0.02 ms | 0.02 ms |
72
- | **search (BM25)** | 0.4 ms | 1.2 ms | 5.3 ms | 9.9 ms |
73
- | **list** | 0.2 ms | 1.4 ms | 9.9 ms | 14.7 ms |
74
- | **state get/set** | 0.03 ms | 0.03 ms | 0.03 ms | 0.03 ms |
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.6",
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.prepare(
115
+ db.exec(
102
116
  `DELETE FROM chunks WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`,
103
- ).run();
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
- const d = getDb();
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 d = getDb();
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 d = getDb();
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 scores.
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 chunks_fts.id, rank
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 chunks.id = chunks_fts.id
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 chunks.agent = ?`;
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(() => `chunks.tags LIKE ?`);
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
- score: -r.rank, // FTS5 rank is negative (lower = better), invert
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
- const d = getDb();
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
- const d = getDb();
300
- return d
301
- .prepare(
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 d = getDb();
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
- d.prepare(
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({ agent, tags: tags ?? [] });
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
- let fusedResults;
98
-
113
+ // BM25-only: searchFTS already returns full chunk data, no enrichment needed
99
114
  if (effectiveMode === "bm25") {
100
- fusedResults = searchFTS({ query, agent, tags, topK: candidateK });
101
- } else if (effectiveMode === "semantic") {
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
- fusedResults = searchFTS({ query, agent, tags, topK: candidateK });
105
- } else {
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
- } else {
113
- // Hybrid: run FTS5 (sync) and embed query (async) in parallel
114
- const queryEmbeddingPromise = embed(query);
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
- if (!queryEmbedding) {
119
- fusedResults = bm25Hits;
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 vecHits = vectorSearch(queryEmbedding, {
122
- agent,
123
- tags,
124
- topK: candidateK,
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
- // Take topK and enrich with full chunk data
131
- const topResults = fusedResults.slice(0, topK);
132
- const enriched = [];
176
+ return enriched;
177
+ }
133
178
 
134
- for (const { id, score } of topResults) {
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
  /**