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/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 full-text search over stored chunks
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 @agentops/context-store
16
- * CONTEXT_STORE_PATH=/your/project/.context npx @agentops/context-store
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 by relevance using BM25 full-text ranking.",
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 BM25 relevance score. Lower = more permissive (default: 0.1).",
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 local storage.",
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
+ }