@xultrax-web/agent-memory-mcp 0.6.0

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +367 -0
  3. package/dist/index.js +1245 -0
  4. package/package.json +71 -0
package/dist/index.js ADDED
@@ -0,0 +1,1245 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-memory-mcp
4
+ *
5
+ * File-based persistent memory for any MCP client. Markdown files with
6
+ * YAML frontmatter, indexed by a simple MEMORY.md file. Inspired by
7
+ * Claude Code's built-in memory pattern, made available to Cursor,
8
+ * Cline, Continue, and any other MCP-compatible tool.
9
+ *
10
+ * Storage resolution (first match wins):
11
+ * 1. AGENT_MEMORY_DIR env var (absolute path)
12
+ * 2. AGENT_MEMORY_SCOPE=global → ~/.agent-memory/
13
+ * 3. default → ./.agent-memory/ (per-project)
14
+ *
15
+ * Three usage modes share the same binary:
16
+ * 1. MCP server (stdio) · default when invoked with no args
17
+ * 2. CLI · agent-memory <save|search|get|list|delete>
18
+ * 3. Bulk import · agent-memory import-claude-code
19
+ *
20
+ * The tools are intentionally minimal. The whole point is that the
21
+ * storage is plain markdown — users can grep, edit, commit, and
22
+ * inspect it without going through the server.
23
+ */
24
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
25
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26
+ import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
27
+ import Fuse from "fuse.js";
28
+ import matter from "gray-matter";
29
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
30
+ import { homedir } from "node:os";
31
+ import { join, resolve } from "node:path";
32
+ import lockfile from "proper-lockfile";
33
+ // -------------------------------------------------------------
34
+ // Storage location resolution
35
+ // -------------------------------------------------------------
36
+ function resolveStorageDir() {
37
+ const explicit = process.env.AGENT_MEMORY_DIR;
38
+ if (explicit)
39
+ return resolve(explicit);
40
+ if (process.env.AGENT_MEMORY_SCOPE === "global") {
41
+ return join(homedir(), ".agent-memory");
42
+ }
43
+ return resolve(process.cwd(), ".agent-memory");
44
+ }
45
+ const MEMORY_DIR = resolveStorageDir();
46
+ const INDEX_FILE = join(MEMORY_DIR, "MEMORY.md");
47
+ const TRASH_DIR = join(MEMORY_DIR, ".trash");
48
+ const LOCK_FILE = join(MEMORY_DIR, ".lock");
49
+ const EVENT_LOG = join(MEMORY_DIR, ".events.jsonl");
50
+ const SCHEMA_VERSION = 1;
51
+ const LOG_LEVEL_ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
52
+ const CURRENT_LOG_LEVEL = process.env.AGENT_MEMORY_LOG?.toLowerCase() ?? "info";
53
+ function log(level, message, fields) {
54
+ if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[CURRENT_LOG_LEVEL])
55
+ return;
56
+ const ts = new Date().toISOString();
57
+ const fieldStr = fields ? " " + JSON.stringify(fields) : "";
58
+ process.stderr.write(`${ts} [${level}] ${message}${fieldStr}\n`);
59
+ }
60
+ // -------------------------------------------------------------
61
+ // Color · ANSI for TTY, respects NO_COLOR / FORCE_COLOR
62
+ // -------------------------------------------------------------
63
+ const ANSI = {
64
+ reset: "\x1b[0m",
65
+ dim: "\x1b[2m",
66
+ bold: "\x1b[1m",
67
+ red: "\x1b[31m",
68
+ green: "\x1b[32m",
69
+ yellow: "\x1b[33m",
70
+ cyan: "\x1b[36m",
71
+ magenta: "\x1b[35m",
72
+ };
73
+ function useColor() {
74
+ if (process.env.NO_COLOR)
75
+ return false;
76
+ if (process.env.FORCE_COLOR)
77
+ return true;
78
+ return Boolean(process.stderr.isTTY);
79
+ }
80
+ function c(code, text) {
81
+ return useColor() ? `${code}${text}${ANSI.reset}` : text;
82
+ }
83
+ function logEvent(action, fields) {
84
+ try {
85
+ ensureStorage();
86
+ const record = { ts: new Date().toISOString(), action, ...fields };
87
+ // Append is safe across processes on POSIX + Windows for small lines
88
+ // (writev guarantees atomicity below pipe buffer size). For larger
89
+ // future events we could move to the lock-wrapped pattern.
90
+ writeFileSync(EVENT_LOG, JSON.stringify(record) + "\n", { flag: "a", encoding: "utf8" });
91
+ }
92
+ catch (err) {
93
+ // Never let event-log failure break the main operation
94
+ log("warn", "event log write failed", {
95
+ error: err instanceof Error ? err.message : String(err),
96
+ });
97
+ }
98
+ }
99
+ function readEventLog(opts) {
100
+ if (!existsSync(EVENT_LOG))
101
+ return [];
102
+ const lines = readFileSync(EVENT_LOG, "utf8").split(/\r?\n/).filter(Boolean);
103
+ let records = [];
104
+ for (const line of lines) {
105
+ try {
106
+ records.push(JSON.parse(line));
107
+ }
108
+ catch {
109
+ // Skip malformed lines silently — log file may have been
110
+ // hand-edited or truncated mid-write
111
+ }
112
+ }
113
+ if (opts.action)
114
+ records = records.filter((r) => r.action === opts.action);
115
+ if (opts.tail)
116
+ records = records.slice(-opts.tail);
117
+ return records;
118
+ }
119
+ function ensureStorage() {
120
+ if (!existsSync(MEMORY_DIR))
121
+ mkdirSync(MEMORY_DIR, { recursive: true });
122
+ if (!existsSync(INDEX_FILE)) {
123
+ writeFileSync(INDEX_FILE, "# Memory Index\n\n_Auto-managed by agent-memory-mcp. Hand-edits to entries are preserved._\n\n", "utf8");
124
+ }
125
+ }
126
+ function ensureTrash() {
127
+ if (!existsSync(TRASH_DIR))
128
+ mkdirSync(TRASH_DIR, { recursive: true });
129
+ }
130
+ function ensureLockTarget() {
131
+ ensureStorage();
132
+ // proper-lockfile needs the target file to exist before locking
133
+ if (!existsSync(LOCK_FILE))
134
+ writeFileSync(LOCK_FILE, "", "utf8");
135
+ }
136
+ // -------------------------------------------------------------
137
+ // Reliability primitives
138
+ // -------------------------------------------------------------
139
+ //
140
+ // Every mutation goes through these two helpers:
141
+ // atomicWriteFile · tmp-file + rename, so power-loss never leaves
142
+ // a half-written file on disk
143
+ // withLock · proper-lockfile around any write transaction,
144
+ // so MCP server + concurrent CLI invocations
145
+ // don't corrupt the index
146
+ function atomicWriteFile(filePath, content) {
147
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
148
+ writeFileSync(tmpPath, content, "utf8");
149
+ renameSync(tmpPath, filePath);
150
+ }
151
+ function withLock(fn) {
152
+ ensureLockTarget();
153
+ // proper-lockfile's sync API doesn't support retries — a collision
154
+ // throws immediately. For a single-process MCP server + occasional
155
+ // CLI invocations that's fine; the rare contention case surfaces
156
+ // as a clear error instead of silently corrupting data. The stale
157
+ // timeout means a crashed process's lock gets auto-cleaned.
158
+ const release = lockfile.lockSync(LOCK_FILE, { stale: 10_000 });
159
+ try {
160
+ return fn();
161
+ }
162
+ finally {
163
+ release();
164
+ }
165
+ }
166
+ // -------------------------------------------------------------
167
+ // Types & validation
168
+ // -------------------------------------------------------------
169
+ const VALID_TYPES = new Set(["user", "feedback", "project", "reference"]);
170
+ // Slug rules: lowercase a-z + digits + hyphen + underscore, start with
171
+ // a letter or digit, 1-80 chars. Underscores are allowed because Claude
172
+ // Code's memory tree uses them; we want frictionless import.
173
+ const SLUG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,80}$/;
174
+ function memoryFilePath(name) {
175
+ return join(MEMORY_DIR, `${name}.md`);
176
+ }
177
+ function readMemory(name) {
178
+ const fp = memoryFilePath(name);
179
+ if (!existsSync(fp))
180
+ return null;
181
+ const raw = readFileSync(fp, "utf8");
182
+ const parsed = matter(raw);
183
+ const fm = parsed.data;
184
+ return {
185
+ name: fm.name ?? name,
186
+ description: fm.description ?? "",
187
+ type: fm.type ?? "project",
188
+ body: parsed.content.trim(),
189
+ filePath: fp,
190
+ };
191
+ }
192
+ function listMemoryFiles() {
193
+ if (!existsSync(MEMORY_DIR))
194
+ return [];
195
+ return readdirSync(MEMORY_DIR)
196
+ .filter((f) => f.endsWith(".md") && f !== "MEMORY.md")
197
+ .map((f) => f.replace(/\.md$/, ""));
198
+ }
199
+ // -------------------------------------------------------------
200
+ // Index management
201
+ // -------------------------------------------------------------
202
+ const INDEX_ENTRY_PATTERN = /^- \[([^\]]+)\]\(([^)]+)\) — (.+)$/;
203
+ function readIndex() {
204
+ if (!existsSync(INDEX_FILE))
205
+ return new Map();
206
+ const lines = readFileSync(INDEX_FILE, "utf8").split(/\r?\n/);
207
+ const entries = new Map();
208
+ for (const line of lines) {
209
+ const m = INDEX_ENTRY_PATTERN.exec(line.trim());
210
+ if (m)
211
+ entries.set(m[1], line.trim());
212
+ }
213
+ return entries;
214
+ }
215
+ function writeIndex(entries) {
216
+ const header = "# Memory Index\n\n_Auto-managed by agent-memory-mcp. Hand-edits to entries are preserved._\n\n";
217
+ const sorted = Array.from(entries.values()).sort();
218
+ atomicWriteFile(INDEX_FILE, header + sorted.join("\n") + "\n");
219
+ }
220
+ // Unlocked variants · safe to call only from inside a withLock block.
221
+ function upsertIndexEntryUnlocked(name, description) {
222
+ const entries = readIndex();
223
+ entries.set(name, `- [${name}](${name}.md) — ${description}`);
224
+ writeIndex(entries);
225
+ }
226
+ function removeIndexEntryUnlocked(name) {
227
+ const entries = readIndex();
228
+ entries.delete(name);
229
+ writeIndex(entries);
230
+ }
231
+ // -------------------------------------------------------------
232
+ // Tool handlers
233
+ // -------------------------------------------------------------
234
+ function toolSaveMemory(args) {
235
+ const name = String(args.name ?? "").trim();
236
+ const description = String(args.description ?? "").trim();
237
+ const type = String(args.type ?? "project").trim();
238
+ const content = String(args.content ?? "").trim();
239
+ if (!SLUG_PATTERN.test(name)) {
240
+ throw new Error(`Invalid name "${name}". Use lowercase (a-z, 0-9, hyphen, underscore), 1-80 chars, must start with letter or digit.`);
241
+ }
242
+ if (!VALID_TYPES.has(type)) {
243
+ throw new Error(`Invalid type "${type}". Must be one of: ${Array.from(VALID_TYPES).join(", ")}.`);
244
+ }
245
+ if (!description)
246
+ throw new Error("description is required");
247
+ if (!content)
248
+ throw new Error("content is required");
249
+ ensureStorage();
250
+ const frontmatter = `---\n` +
251
+ `name: ${name}\n` +
252
+ `description: ${JSON.stringify(description)}\n` +
253
+ `type: ${type}\n` +
254
+ `schema: ${SCHEMA_VERSION}\n` +
255
+ `---\n\n`;
256
+ const fp = memoryFilePath(name);
257
+ const isUpdate = existsSync(fp);
258
+ return withLock(() => {
259
+ atomicWriteFile(fp, frontmatter + content + "\n");
260
+ upsertIndexEntryUnlocked(name, description);
261
+ logEvent("save", { name, type, update: isUpdate, bytes: content.length });
262
+ log("debug", "save_memory", { name, type, update: isUpdate });
263
+ return `${isUpdate ? "Updated" : "Saved"} memory "${name}" (${type}) at ${fp}`;
264
+ });
265
+ }
266
+ function toolGetMemory(args) {
267
+ const name = String(args.name ?? "").trim();
268
+ const mem = readMemory(name);
269
+ if (!mem)
270
+ return `Memory "${name}" not found.`;
271
+ return [
272
+ `# ${mem.name}`,
273
+ `type: ${mem.type}`,
274
+ `description: ${mem.description}`,
275
+ "",
276
+ mem.body,
277
+ ].join("\n");
278
+ }
279
+ function toolListMemories(args) {
280
+ const typeFilter = args.type ? String(args.type) : null;
281
+ const offset = args.offset ? Math.max(0, Number(args.offset)) : 0;
282
+ const limit = args.limit ? Math.max(1, Number(args.limit)) : 50;
283
+ const names = listMemoryFiles();
284
+ const all = names
285
+ .map((n) => readMemory(n))
286
+ .filter((m) => m !== null)
287
+ .filter((m) => !typeFilter || m.type === typeFilter)
288
+ .sort((a, b) => a.name.localeCompare(b.name));
289
+ if (all.length === 0) {
290
+ return typeFilter
291
+ ? `No memories of type "${typeFilter}".`
292
+ : "No memories yet. Use save_memory to create one.";
293
+ }
294
+ const page = all.slice(offset, offset + limit);
295
+ const lines = [];
296
+ const showing = offset === 0 && page.length === all.length
297
+ ? `Found ${all.length} memor${all.length === 1 ? "y" : "ies"}:`
298
+ : `Showing ${offset + 1}-${offset + page.length} of ${all.length}:`;
299
+ lines.push(showing);
300
+ lines.push("");
301
+ for (const m of page) {
302
+ lines.push(` ${m.name} [${m.type}]`);
303
+ lines.push(` ${m.description}`);
304
+ }
305
+ if (offset + page.length < all.length) {
306
+ const nextOffset = offset + page.length;
307
+ lines.push("");
308
+ lines.push(` ... ${all.length - nextOffset} more. Use offset=${nextOffset} to continue.`);
309
+ }
310
+ return lines.join("\n");
311
+ }
312
+ // -------------------------------------------------------------
313
+ // Fuzzy search · Fuse.js with field weighting + snippets
314
+ // -------------------------------------------------------------
315
+ //
316
+ // Why Fuse over BM25 / Lunr: memory documents are small (1-10KB)
317
+ // and queries are short (3-5 words). Fuse gives typo tolerance,
318
+ // word-order tolerance, and partial-match support out of the box,
319
+ // with field weighting that approximates TF-IDF on this data size.
320
+ // BM25 would be over-engineering at this scale.
321
+ //
322
+ // Field weights (name×3 > description×2 > body×1) match the
323
+ // natural intuition: a hit in the slug or summary is more
324
+ // meaningful than a single hit in 5KB of prose.
325
+ function buildFuse(memories) {
326
+ return new Fuse(memories, {
327
+ includeScore: true,
328
+ includeMatches: true,
329
+ threshold: 0.4, // 0=exact, 1=anything; 0.4 is forgiving without going noisy
330
+ ignoreLocation: true,
331
+ minMatchCharLength: 2,
332
+ keys: [
333
+ { name: "name", weight: 3 },
334
+ { name: "description", weight: 2 },
335
+ { name: "body", weight: 1 },
336
+ ],
337
+ });
338
+ }
339
+ function extractFuseSnippet(memory, matches) {
340
+ // Prefer a body-field snippet so the operator sees context, not the
341
+ // memory's own description (which is already in the summary line).
342
+ const bodyMatch = matches.find((m) => m.key === "body");
343
+ if (!bodyMatch || !bodyMatch.indices?.length)
344
+ return null;
345
+ const text = memory.body;
346
+ const [start, end] = bodyMatch.indices[0];
347
+ const ctxStart = Math.max(0, start - 40);
348
+ const ctxEnd = Math.min(text.length, end + 1 + 40);
349
+ let snippet = text.slice(ctxStart, ctxEnd).replace(/\s+/g, " ").trim();
350
+ if (ctxStart > 0)
351
+ snippet = "... " + snippet;
352
+ if (ctxEnd < text.length)
353
+ snippet = snippet + " ...";
354
+ return snippet;
355
+ }
356
+ function toolSearchMemories(args) {
357
+ const query = String(args.query ?? "").trim();
358
+ if (!query)
359
+ throw new Error("query is required");
360
+ const limit = args.limit ? Math.max(1, Number(args.limit)) : 10;
361
+ const names = listMemoryFiles();
362
+ const memories = names.map((n) => readMemory(n)).filter((m) => m !== null);
363
+ if (memories.length === 0)
364
+ return "No memories to search.";
365
+ const fuse = buildFuse(memories);
366
+ const results = fuse.search(query, { limit });
367
+ if (results.length === 0) {
368
+ return `No memories matched "${query}". (Fuzzy threshold 0.4; try a shorter or simpler query.)`;
369
+ }
370
+ const lines = [];
371
+ lines.push(`Found ${results.length} match${results.length === 1 ? "" : "es"} for "${query}":`);
372
+ lines.push("");
373
+ for (const r of results) {
374
+ const m = r.item;
375
+ // Fuse score: 0 = perfect, 1 = no match. Invert + scale for human display.
376
+ const relevance = Math.round((1 - (r.score ?? 0)) * 100);
377
+ lines.push(` ${c(ANSI.bold, m.name)} [${m.type}] ${c(ANSI.dim, `· relevance ${relevance}%`)}`);
378
+ lines.push(` ${m.description}`);
379
+ const snippet = extractFuseSnippet(m, r.matches ?? []);
380
+ if (snippet)
381
+ lines.push(` ${c(ANSI.dim, snippet)}`);
382
+ }
383
+ return lines.join("\n");
384
+ }
385
+ // -------------------------------------------------------------
386
+ // Relevant memories · for LLM consumption (full content)
387
+ // -------------------------------------------------------------
388
+ //
389
+ // Where search_memories returns human-readable matches with snippets,
390
+ // relevant_memories returns the FULL memory bodies. The intended
391
+ // caller is an LLM asking "what do I know about X?" so it can pull
392
+ // just-in-time context without a second round trip.
393
+ //
394
+ // Default max=5 keeps the context window cost bounded.
395
+ function toolRelevantMemories(args) {
396
+ const query = String(args.query ?? "").trim();
397
+ if (!query)
398
+ throw new Error("query is required");
399
+ const max = args.max ? Math.max(1, Math.min(20, Number(args.max))) : 5;
400
+ const names = listMemoryFiles();
401
+ const memories = names.map((n) => readMemory(n)).filter((m) => m !== null);
402
+ if (memories.length === 0)
403
+ return "No memories available.";
404
+ const fuse = buildFuse(memories);
405
+ const results = fuse.search(query, { limit: max });
406
+ if (results.length === 0) {
407
+ return `No memories relevant to "${query}".`;
408
+ }
409
+ // Emit each memory as a markdown section so the LLM can ingest
410
+ // multiple memories in one shot without further parsing.
411
+ const sections = [];
412
+ sections.push(`# Memories relevant to "${query}"\n`);
413
+ for (const r of results) {
414
+ const m = r.item;
415
+ const relevance = Math.round((1 - (r.score ?? 0)) * 100);
416
+ sections.push(`## ${m.name} · [${m.type}] · relevance ${relevance}%`);
417
+ sections.push(`> ${m.description}`);
418
+ sections.push("");
419
+ sections.push(m.body);
420
+ sections.push("");
421
+ sections.push("---");
422
+ }
423
+ return sections.join("\n");
424
+ }
425
+ function toolDeleteMemory(args) {
426
+ const name = String(args.name ?? "").trim();
427
+ if (!SLUG_PATTERN.test(name))
428
+ throw new Error(`Invalid name "${name}".`);
429
+ const fp = memoryFilePath(name);
430
+ if (!existsSync(fp))
431
+ return `Memory "${name}" not found.`;
432
+ return withLock(() => {
433
+ ensureTrash();
434
+ // Trash filename: <unix-ms>-<name>.md so restore can pick the
435
+ // most recent version and the operator can see when it was binned.
436
+ const ts = Date.now();
437
+ const trashPath = join(TRASH_DIR, `${ts}-${name}.md`);
438
+ renameSync(fp, trashPath);
439
+ removeIndexEntryUnlocked(name);
440
+ logEvent("delete", { name, trash: `${ts}-${name}.md` });
441
+ log("debug", "delete_memory", { name });
442
+ return `Moved "${name}" to trash. Restore with: agent-memory restore ${name}`;
443
+ });
444
+ }
445
+ function toolRestoreMemory(args) {
446
+ const name = String(args.name ?? "").trim();
447
+ if (!SLUG_PATTERN.test(name))
448
+ throw new Error(`Invalid name "${name}".`);
449
+ ensureTrash();
450
+ // Most recent trash entry wins (timestamp prefix sorts lexically).
451
+ const matches = readdirSync(TRASH_DIR)
452
+ .filter((f) => f.endsWith(`-${name}.md`))
453
+ .sort()
454
+ .reverse();
455
+ if (matches.length === 0)
456
+ return `No trashed memory named "${name}" found.`;
457
+ return withLock(() => {
458
+ const trashPath = join(TRASH_DIR, matches[0]);
459
+ const fp = memoryFilePath(name);
460
+ if (existsSync(fp)) {
461
+ throw new Error(`Cannot restore: "${name}" already exists in the active store. ` +
462
+ `Delete it first (it'll get its own trash entry) then restore.`);
463
+ }
464
+ renameSync(trashPath, fp);
465
+ // Re-add to index using the restored file's frontmatter description.
466
+ const mem = readMemory(name);
467
+ if (mem)
468
+ upsertIndexEntryUnlocked(name, mem.description);
469
+ const binnedAt = new Date(Number(matches[0].split("-")[0])).toISOString();
470
+ logEvent("restore", { name, binnedAt });
471
+ log("debug", "restore_memory", { name });
472
+ return `Restored "${name}" from trash (was binned ${binnedAt}).`;
473
+ });
474
+ }
475
+ function runDoctor(rebuildIndex) {
476
+ ensureStorage();
477
+ const diskFiles = listMemoryFiles();
478
+ const indexEntries = readIndex();
479
+ const indexNames = Array.from(indexEntries.keys());
480
+ const orphans = diskFiles.filter((n) => !indexEntries.has(n));
481
+ const dangling = indexNames.filter((n) => !diskFiles.includes(n));
482
+ const unreadable = [];
483
+ const invalidType = [];
484
+ for (const name of diskFiles) {
485
+ try {
486
+ const mem = readMemory(name);
487
+ if (!mem) {
488
+ unreadable.push(name);
489
+ }
490
+ else if (!VALID_TYPES.has(mem.type)) {
491
+ invalidType.push(`${name} (type="${mem.type}")`);
492
+ }
493
+ }
494
+ catch (err) {
495
+ const msg = err instanceof Error ? err.message : String(err);
496
+ unreadable.push(`${name} (${msg.split("\n")[0]})`);
497
+ }
498
+ }
499
+ let rebuilt = false;
500
+ if (rebuildIndex && (orphans.length > 0 || dangling.length > 0)) {
501
+ withLock(() => {
502
+ const newEntries = new Map();
503
+ for (const name of diskFiles) {
504
+ const mem = readMemory(name);
505
+ if (mem) {
506
+ newEntries.set(name, `- [${name}](${name}.md) — ${mem.description}`);
507
+ }
508
+ }
509
+ writeIndex(newEntries);
510
+ });
511
+ rebuilt = true;
512
+ }
513
+ return {
514
+ storageDir: MEMORY_DIR,
515
+ diskFiles,
516
+ indexEntries: indexNames,
517
+ orphans,
518
+ dangling,
519
+ unreadable,
520
+ invalidType,
521
+ rebuilt,
522
+ };
523
+ }
524
+ function formatDoctorReport(r, rebuildRequested) {
525
+ const lines = [];
526
+ lines.push(`agent-memory doctor`);
527
+ lines.push(`storage : ${r.storageDir}`);
528
+ lines.push(`on disk : ${r.diskFiles.length} memor${r.diskFiles.length === 1 ? "y" : "ies"}`);
529
+ lines.push(`indexed : ${r.indexEntries.length}`);
530
+ lines.push("");
531
+ const issueCount = r.orphans.length + r.dangling.length + r.unreadable.length + r.invalidType.length;
532
+ if (issueCount === 0) {
533
+ lines.push("OK · no issues found");
534
+ return lines.join("\n");
535
+ }
536
+ lines.push(`Found ${issueCount} issue${issueCount === 1 ? "" : "s"}:`);
537
+ for (const o of r.orphans)
538
+ lines.push(` orphan · ${o}.md exists on disk but not in MEMORY.md`);
539
+ for (const d of r.dangling)
540
+ lines.push(` dangling · ${d} indexed but file missing`);
541
+ for (const u of r.unreadable)
542
+ lines.push(` bad · ${u}`);
543
+ for (const v of r.invalidType)
544
+ lines.push(` type · ${v}`);
545
+ lines.push("");
546
+ if (r.rebuilt) {
547
+ lines.push(`Fixed · rebuilt MEMORY.md from disk (${r.diskFiles.length} entries).`);
548
+ lines.push(`Note · unreadable / invalid-type files were NOT removed. Inspect and fix by hand.`);
549
+ }
550
+ else if (!rebuildRequested) {
551
+ lines.push("Re-run with --rebuild-index to reconstruct MEMORY.md from disk.");
552
+ }
553
+ return lines.join("\n");
554
+ }
555
+ function toolDoctor(args) {
556
+ const rebuild = Boolean(args["rebuild-index"]);
557
+ const report = runDoctor(rebuild);
558
+ return formatDoctorReport(report, rebuild);
559
+ }
560
+ // -------------------------------------------------------------
561
+ // Stats · operator dashboard
562
+ // -------------------------------------------------------------
563
+ function toolStats(_args) {
564
+ ensureStorage();
565
+ const diskFiles = listMemoryFiles();
566
+ const memories = diskFiles.map((n) => readMemory(n)).filter((m) => m !== null);
567
+ const byType = {};
568
+ for (const t of VALID_TYPES)
569
+ byType[t] = 0;
570
+ for (const m of memories)
571
+ byType[m.type] = (byType[m.type] ?? 0) + 1;
572
+ // File sizes via stat on each file (read body length doesn't include
573
+ // frontmatter bytes — stat gives the on-disk truth).
574
+ let totalBytes = 0;
575
+ let largestBytes = 0;
576
+ let largestName = "";
577
+ for (const n of diskFiles) {
578
+ const fp = memoryFilePath(n);
579
+ try {
580
+ const stats = readFileSync(fp, "utf8").length;
581
+ totalBytes += stats;
582
+ if (stats > largestBytes) {
583
+ largestBytes = stats;
584
+ largestName = n;
585
+ }
586
+ }
587
+ catch {
588
+ // skip unreadable
589
+ }
590
+ }
591
+ // Oldest/newest by file mtime would need a stat call; use the
592
+ // index ordering as a proxy (alphabetical) for simplicity. Real
593
+ // mtime-based age would need `statSync` which adds an N read.
594
+ // Skipping that for v0.4; defer to a `--detailed` flag if asked.
595
+ const events = readEventLog({});
596
+ const trashCount = existsSync(TRASH_DIR)
597
+ ? readdirSync(TRASH_DIR).filter((f) => f.endsWith(".md")).length
598
+ : 0;
599
+ const lines = [];
600
+ lines.push(c(ANSI.bold, "agent-memory stats"));
601
+ lines.push(c(ANSI.dim, `storage : ${MEMORY_DIR}`));
602
+ lines.push("");
603
+ lines.push(c(ANSI.bold, `memories: ${memories.length} total`));
604
+ for (const t of ["user", "feedback", "project", "reference"]) {
605
+ const count = byType[t] ?? 0;
606
+ const bar = count > 0 ? "█".repeat(Math.min(count, 40)) : "";
607
+ lines.push(` ${t.padEnd(10)} ${String(count).padStart(4)} ${c(ANSI.cyan, bar)}`);
608
+ }
609
+ lines.push("");
610
+ lines.push(c(ANSI.bold, "storage:"));
611
+ lines.push(` total size ${fmtBytes(totalBytes)}`);
612
+ lines.push(` avg size ${memories.length > 0 ? fmtBytes(totalBytes / memories.length) : "—"}`);
613
+ if (largestName) {
614
+ lines.push(` largest ${fmtBytes(largestBytes)} (${largestName})`);
615
+ }
616
+ lines.push("");
617
+ lines.push(c(ANSI.bold, "audit:"));
618
+ lines.push(` events logged ${events.length}`);
619
+ lines.push(` items in trash ${trashCount}`);
620
+ if (events.length > 0) {
621
+ lines.push(` last event ${events[events.length - 1].ts} (${events[events.length - 1].action})`);
622
+ }
623
+ return lines.join("\n");
624
+ }
625
+ function fmtBytes(n) {
626
+ if (n < 1024)
627
+ return `${Math.round(n)} B`;
628
+ if (n < 1024 * 1024)
629
+ return `${(n / 1024).toFixed(1)} KB`;
630
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
631
+ }
632
+ // -------------------------------------------------------------
633
+ // Log browser · paginated audit-trail view
634
+ // -------------------------------------------------------------
635
+ function toolLogEvents(args) {
636
+ const tail = args.tail ? Number(args.tail) : 20;
637
+ const action = args.action ? String(args.action) : undefined;
638
+ const events = readEventLog({ tail, action });
639
+ if (events.length === 0) {
640
+ return action ? `No events of action "${action}" in the log.` : "No events logged yet.";
641
+ }
642
+ const lines = [];
643
+ lines.push(c(ANSI.bold, `Last ${events.length} event${events.length === 1 ? "" : "s"}${action ? ` (action=${action})` : ""}:`));
644
+ lines.push("");
645
+ for (const e of events) {
646
+ const { ts, action: a, ...rest } = e;
647
+ const tsStr = c(ANSI.dim, String(ts)
648
+ .replace("T", " ")
649
+ .replace(/\.\d+Z$/, "Z"));
650
+ const actionStr = c(actionColor(String(a)), String(a).padEnd(7));
651
+ const fields = Object.entries(rest)
652
+ .map(([k, v]) => `${c(ANSI.dim, k + "=")}${String(v)}`)
653
+ .join(" ");
654
+ lines.push(` ${tsStr} ${actionStr} ${fields}`);
655
+ }
656
+ return lines.join("\n");
657
+ }
658
+ function actionColor(action) {
659
+ if (action === "save")
660
+ return ANSI.green;
661
+ if (action === "delete")
662
+ return ANSI.yellow;
663
+ if (action === "restore")
664
+ return ANSI.cyan;
665
+ return ANSI.dim;
666
+ }
667
+ // -------------------------------------------------------------
668
+ // Server wiring
669
+ // -------------------------------------------------------------
670
+ const server = new Server({ name: "agent-memory", version: "0.2.0" }, { capabilities: { tools: {}, resources: {} } });
671
+ // -------------------------------------------------------------
672
+ // Resource URI scheme
673
+ // -------------------------------------------------------------
674
+ //
675
+ // agent-memory://index → the MEMORY.md index
676
+ // agent-memory://memory/{name} → an individual memory file
677
+ //
678
+ // Clients (Cursor, Claude Desktop, etc.) can pin the index as
679
+ // always-visible context. Per-memory URIs are exposed so a client
680
+ // can pin specific memories the user marks as "always relevant"
681
+ // (e.g. their user profile memory, the project's prime directive).
682
+ const URI_INDEX = "agent-memory://index";
683
+ const URI_MEMORY_PREFIX = "agent-memory://memory/";
684
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
685
+ ensureStorage();
686
+ const memories = listMemoryFiles()
687
+ .map((n) => readMemory(n))
688
+ .filter((m) => m !== null)
689
+ .sort((a, b) => a.name.localeCompare(b.name));
690
+ return {
691
+ resources: [
692
+ {
693
+ uri: URI_INDEX,
694
+ name: "Memory index",
695
+ description: "Auto-managed index of every stored memory. Pin this as always-visible context so the assistant sees what's known before deciding what to look up.",
696
+ mimeType: "text/markdown",
697
+ },
698
+ ...memories.map((m) => ({
699
+ uri: `${URI_MEMORY_PREFIX}${m.name}`,
700
+ name: m.name,
701
+ description: `[${m.type}] ${m.description}`,
702
+ mimeType: "text/markdown",
703
+ })),
704
+ ],
705
+ };
706
+ });
707
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
708
+ const uri = request.params.uri;
709
+ ensureStorage();
710
+ if (uri === URI_INDEX) {
711
+ const text = readFileSync(INDEX_FILE, "utf8");
712
+ return { contents: [{ uri, mimeType: "text/markdown", text }] };
713
+ }
714
+ if (uri.startsWith(URI_MEMORY_PREFIX)) {
715
+ const name = uri.slice(URI_MEMORY_PREFIX.length);
716
+ // Defense in depth: strict slug validation prevents path traversal
717
+ // even though the URI parser should already reject "../" segments.
718
+ if (!SLUG_PATTERN.test(name)) {
719
+ throw new Error(`Invalid memory name in URI: "${name}"`);
720
+ }
721
+ const fp = memoryFilePath(name);
722
+ if (!existsSync(fp)) {
723
+ throw new Error(`Resource not found: ${uri}`);
724
+ }
725
+ const text = readFileSync(fp, "utf8");
726
+ return { contents: [{ uri, mimeType: "text/markdown", text }] };
727
+ }
728
+ throw new Error(`Unknown resource URI: ${uri}. Supported: ${URI_INDEX}, ${URI_MEMORY_PREFIX}{name}`);
729
+ });
730
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
731
+ tools: [
732
+ {
733
+ name: "save_memory",
734
+ description: "Save (or update) a memory. Memories are markdown files with YAML frontmatter, " +
735
+ "stored at the resolved memory dir. Use a short kebab-case name; the description " +
736
+ "is what's shown in the index and used for search ranking.",
737
+ inputSchema: {
738
+ type: "object",
739
+ properties: {
740
+ name: {
741
+ type: "string",
742
+ description: "Short kebab-case slug, 1-80 chars (e.g. 'user-prefers-tabs')",
743
+ },
744
+ description: {
745
+ type: "string",
746
+ description: "One-line summary, shown in the index and ranked highly in search",
747
+ },
748
+ type: {
749
+ type: "string",
750
+ enum: ["user", "feedback", "project", "reference"],
751
+ description: "Memory type: user (about the person), feedback (rules to follow), project (state/context), reference (external pointers)",
752
+ },
753
+ content: {
754
+ type: "string",
755
+ description: "Markdown body. For feedback/project, include **Why:** and **How to apply:** lines.",
756
+ },
757
+ },
758
+ required: ["name", "description", "type", "content"],
759
+ },
760
+ },
761
+ {
762
+ name: "search_memories",
763
+ description: "Fuzzy search across name, description, and body. Tolerates typos, word-order shifts, and partial matches. Returns top matches with relevance scores (0-100) and body-context snippets. Use this for human-readable browsing.",
764
+ inputSchema: {
765
+ type: "object",
766
+ properties: {
767
+ query: {
768
+ type: "string",
769
+ description: "What to look for. Fuzzy match (typo-tolerant).",
770
+ },
771
+ limit: { type: "number", description: "Max results to return (default 10)." },
772
+ },
773
+ required: ["query"],
774
+ },
775
+ },
776
+ {
777
+ name: "relevant_memories",
778
+ description: "Find memories relevant to a query and return their FULL content (not summaries). Designed for LLM ingestion — call this when the assistant needs context on a topic and the memory index alone isn't specific enough. Returns up to `max` memories as a markdown document.",
779
+ inputSchema: {
780
+ type: "object",
781
+ properties: {
782
+ query: { type: "string", description: "The topic the assistant needs context on." },
783
+ max: {
784
+ type: "number",
785
+ description: "Max memories to include (default 5, capped at 20).",
786
+ },
787
+ },
788
+ required: ["query"],
789
+ },
790
+ },
791
+ {
792
+ name: "get_memory",
793
+ description: "Fetch a single memory by name.",
794
+ inputSchema: {
795
+ type: "object",
796
+ properties: {
797
+ name: { type: "string", description: "The memory's name slug" },
798
+ },
799
+ required: ["name"],
800
+ },
801
+ },
802
+ {
803
+ name: "list_memories",
804
+ description: "List stored memories, optionally filtered by type. Paginated.",
805
+ inputSchema: {
806
+ type: "object",
807
+ properties: {
808
+ type: {
809
+ type: "string",
810
+ enum: ["user", "feedback", "project", "reference"],
811
+ description: "Optional filter — only list memories of this type",
812
+ },
813
+ offset: { type: "number", description: "Skip this many results (default 0)." },
814
+ limit: { type: "number", description: "Max results per page (default 50)." },
815
+ },
816
+ },
817
+ },
818
+ {
819
+ name: "delete_memory",
820
+ description: "Move a memory to .trash/ (soft delete). The file is removed from the index but recoverable via restore_memory until you manually empty .trash/.",
821
+ inputSchema: {
822
+ type: "object",
823
+ properties: {
824
+ name: { type: "string", description: "The memory's name slug" },
825
+ },
826
+ required: ["name"],
827
+ },
828
+ },
829
+ {
830
+ name: "restore_memory",
831
+ description: "Restore a memory from .trash/ back into the active store. Picks the most recent trash entry for the name.",
832
+ inputSchema: {
833
+ type: "object",
834
+ properties: {
835
+ name: { type: "string", description: "The memory's name slug" },
836
+ },
837
+ required: ["name"],
838
+ },
839
+ },
840
+ {
841
+ name: "doctor",
842
+ description: "Check storage integrity. Reports orphan files (on disk but not indexed), dangling index entries (no file), unreadable files, and invalid types. Pass rebuild-index=true to reconstruct MEMORY.md from disk.",
843
+ inputSchema: {
844
+ type: "object",
845
+ properties: {
846
+ "rebuild-index": {
847
+ type: "boolean",
848
+ description: "If true, rewrite MEMORY.md to match what's on disk.",
849
+ },
850
+ },
851
+ },
852
+ },
853
+ {
854
+ name: "stats",
855
+ description: "Dashboard of memory-store state: counts per type, total size, largest memory, audit-log size, trash count.",
856
+ inputSchema: { type: "object", properties: {} },
857
+ },
858
+ {
859
+ name: "log_events",
860
+ description: "Read recent entries from the audit event log (.events.jsonl). Returns the last N events, optionally filtered by action.",
861
+ inputSchema: {
862
+ type: "object",
863
+ properties: {
864
+ tail: { type: "number", description: "How many recent events to return (default 20)" },
865
+ action: {
866
+ type: "string",
867
+ description: "Filter by action (save | delete | restore)",
868
+ },
869
+ },
870
+ },
871
+ },
872
+ ],
873
+ }));
874
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
875
+ const { name, arguments: args = {} } = request.params;
876
+ try {
877
+ let result;
878
+ switch (name) {
879
+ case "save_memory":
880
+ result = toolSaveMemory(args);
881
+ break;
882
+ case "search_memories":
883
+ result = toolSearchMemories(args);
884
+ break;
885
+ case "relevant_memories":
886
+ result = toolRelevantMemories(args);
887
+ break;
888
+ case "get_memory":
889
+ result = toolGetMemory(args);
890
+ break;
891
+ case "list_memories":
892
+ result = toolListMemories(args);
893
+ break;
894
+ case "delete_memory":
895
+ result = toolDeleteMemory(args);
896
+ break;
897
+ case "restore_memory":
898
+ result = toolRestoreMemory(args);
899
+ break;
900
+ case "doctor":
901
+ result = toolDoctor(args);
902
+ break;
903
+ case "stats":
904
+ result = toolStats(args);
905
+ break;
906
+ case "log_events":
907
+ result = toolLogEvents(args);
908
+ break;
909
+ default:
910
+ throw new Error(`Unknown tool: ${name}`);
911
+ }
912
+ return { content: [{ type: "text", text: result }] };
913
+ }
914
+ catch (err) {
915
+ const message = err instanceof Error ? err.message : String(err);
916
+ return {
917
+ content: [{ type: "text", text: `Error: ${message}` }],
918
+ isError: true,
919
+ };
920
+ }
921
+ });
922
+ // -------------------------------------------------------------
923
+ // CLI mode
924
+ // -------------------------------------------------------------
925
+ //
926
+ // When invoked with no arguments, the entry point starts the MCP
927
+ // stdio server (as MCP clients expect). When invoked with a known
928
+ // subcommand as argv[2], it runs CLI mode and exits — making the
929
+ // same binary usable from shell scripts, cron, git hooks, etc.
930
+ const CLI_COMMANDS = new Set([
931
+ "save",
932
+ "search",
933
+ "relevant",
934
+ "get",
935
+ "list",
936
+ "delete",
937
+ "restore",
938
+ "doctor",
939
+ "stats",
940
+ "log",
941
+ "import-claude-code",
942
+ "help",
943
+ "--help",
944
+ "-h",
945
+ "--version",
946
+ "-v",
947
+ ]);
948
+ function parseFlags(argv) {
949
+ const flags = {};
950
+ const positional = [];
951
+ for (let i = 0; i < argv.length; i++) {
952
+ const arg = argv[i];
953
+ if (arg.startsWith("--")) {
954
+ const eq = arg.indexOf("=");
955
+ if (eq >= 0) {
956
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
957
+ }
958
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
959
+ flags[arg.slice(2)] = argv[i + 1];
960
+ i++;
961
+ }
962
+ else {
963
+ flags[arg.slice(2)] = true;
964
+ }
965
+ }
966
+ else {
967
+ positional.push(arg);
968
+ }
969
+ }
970
+ return { flags, positional };
971
+ }
972
+ function readStdin() {
973
+ return new Promise((resolve) => {
974
+ let data = "";
975
+ process.stdin.setEncoding("utf8");
976
+ process.stdin.on("data", (chunk) => (data += chunk));
977
+ process.stdin.on("end", () => resolve(data));
978
+ });
979
+ }
980
+ async function cliMain(command, rest) {
981
+ if (command === "help" || command === "--help" || command === "-h") {
982
+ printHelp();
983
+ return 0;
984
+ }
985
+ if (command === "--version" || command === "-v") {
986
+ process.stdout.write("agent-memory-mcp 0.2.0\n");
987
+ return 0;
988
+ }
989
+ ensureStorage();
990
+ const { flags, positional } = parseFlags(rest);
991
+ try {
992
+ switch (command) {
993
+ case "save": {
994
+ const name = positional[0];
995
+ if (!name)
996
+ throw new Error("Usage: agent-memory save <name> --type <t> --description <d> [--content <c> | --content-file <path> | --stdin]");
997
+ let content = String(flags.content ?? "");
998
+ if (flags["content-file"]) {
999
+ content = readFileSync(String(flags["content-file"]), "utf8");
1000
+ }
1001
+ else if (flags.stdin) {
1002
+ content = await readStdin();
1003
+ }
1004
+ const result = toolSaveMemory({
1005
+ name,
1006
+ type: String(flags.type ?? "project"),
1007
+ description: String(flags.description ?? ""),
1008
+ content,
1009
+ });
1010
+ process.stdout.write(result + "\n");
1011
+ return 0;
1012
+ }
1013
+ case "search": {
1014
+ const query = positional[0];
1015
+ if (!query)
1016
+ throw new Error("Usage: agent-memory search <query> [--limit N]");
1017
+ process.stdout.write(toolSearchMemories({
1018
+ query,
1019
+ limit: flags.limit ? Number(flags.limit) : undefined,
1020
+ }) + "\n");
1021
+ return 0;
1022
+ }
1023
+ case "relevant": {
1024
+ const query = positional[0];
1025
+ if (!query)
1026
+ throw new Error("Usage: agent-memory relevant <query> [--max N]");
1027
+ process.stdout.write(toolRelevantMemories({
1028
+ query,
1029
+ max: flags.max ? Number(flags.max) : undefined,
1030
+ }) + "\n");
1031
+ return 0;
1032
+ }
1033
+ case "get": {
1034
+ const name = positional[0];
1035
+ if (!name)
1036
+ throw new Error("Usage: agent-memory get <name>");
1037
+ process.stdout.write(toolGetMemory({ name }) + "\n");
1038
+ return 0;
1039
+ }
1040
+ case "list": {
1041
+ process.stdout.write(toolListMemories({
1042
+ type: flags.type,
1043
+ offset: flags.offset ? Number(flags.offset) : undefined,
1044
+ limit: flags.limit ? Number(flags.limit) : undefined,
1045
+ }) + "\n");
1046
+ return 0;
1047
+ }
1048
+ case "delete": {
1049
+ const name = positional[0];
1050
+ if (!name)
1051
+ throw new Error("Usage: agent-memory delete <name>");
1052
+ process.stdout.write(toolDeleteMemory({ name }) + "\n");
1053
+ return 0;
1054
+ }
1055
+ case "restore": {
1056
+ const name = positional[0];
1057
+ if (!name)
1058
+ throw new Error("Usage: agent-memory restore <name>");
1059
+ process.stdout.write(toolRestoreMemory({ name }) + "\n");
1060
+ return 0;
1061
+ }
1062
+ case "doctor": {
1063
+ process.stdout.write(toolDoctor({ "rebuild-index": Boolean(flags["rebuild-index"]) }) + "\n");
1064
+ return 0;
1065
+ }
1066
+ case "stats": {
1067
+ process.stdout.write(toolStats({}) + "\n");
1068
+ return 0;
1069
+ }
1070
+ case "log": {
1071
+ process.stdout.write(toolLogEvents({
1072
+ tail: flags.tail ? Number(flags.tail) : undefined,
1073
+ action: flags.action ? String(flags.action) : undefined,
1074
+ }) + "\n");
1075
+ return 0;
1076
+ }
1077
+ case "import-claude-code": {
1078
+ return importClaudeCode({
1079
+ source: flags.source ? String(flags.source) : undefined,
1080
+ project: flags.project ? String(flags.project) : undefined,
1081
+ overwrite: Boolean(flags.overwrite),
1082
+ dryRun: Boolean(flags["dry-run"]),
1083
+ });
1084
+ }
1085
+ default:
1086
+ throw new Error(`Unknown command: ${command}. Try 'agent-memory help'.`);
1087
+ }
1088
+ }
1089
+ catch (err) {
1090
+ const message = err instanceof Error ? err.message : String(err);
1091
+ process.stderr.write(`error: ${message}\n`);
1092
+ return 1;
1093
+ }
1094
+ }
1095
+ function printHelp() {
1096
+ process.stdout.write(`agent-memory-mcp · markdown memory for AI agents
1097
+
1098
+ USAGE
1099
+ agent-memory <command> [args] CLI mode
1100
+ agent-memory-mcp MCP server mode (default when no args)
1101
+
1102
+ COMMANDS
1103
+ save <name> --type <t> --description <d> --content <c>
1104
+ Save or update a memory.
1105
+ Type: user | feedback | project | reference
1106
+ Content sources: --content "..." | --content-file <path> | --stdin
1107
+ search <query> [--limit N] Fuzzy search (typo-tolerant), top N (default 10)
1108
+ relevant <query> [--max N] Top N matches as full markdown for LLM ingestion
1109
+ get <name> Print one memory's full contents
1110
+ list [--type <t>] [--offset N] [--limit N]
1111
+ List memories (paginated, default limit 50)
1112
+ delete <name> Soft-delete: move to .trash/, removable later
1113
+ restore <name> Restore the most recent trash entry for <name>
1114
+ doctor [--rebuild-index] Check storage integrity (orphans, dangling
1115
+ index entries, unreadable files). With
1116
+ --rebuild-index, regenerates MEMORY.md from disk.
1117
+ stats Dashboard: counts per type, sizes, audit/trash counts.
1118
+ log [--tail N] [--action save|delete|restore]
1119
+ Recent audit-log entries.
1120
+ import-claude-code [--source <path>] [--project <pat>] [--overwrite] [--dry-run]
1121
+ Walk ~/.claude/projects/*/memory/ and
1122
+ import each memory into the current store.
1123
+ --project filters by substring match.
1124
+ help Show this help
1125
+ --version Print version
1126
+
1127
+ STORAGE
1128
+ Memories live in ./.agent-memory/ (per-project, default).
1129
+ Set AGENT_MEMORY_SCOPE=global for ~/.agent-memory/.
1130
+ Set AGENT_MEMORY_DIR=/path for any custom location.
1131
+
1132
+ CURRENT STORE
1133
+ ${MEMORY_DIR}
1134
+
1135
+ DOCS
1136
+ https://github.com/xultrax-web/agent-memory-mcp
1137
+ `);
1138
+ }
1139
+ function importClaudeCode(opts) {
1140
+ const source = opts.source ?? join(homedir(), ".claude", "projects");
1141
+ if (!existsSync(source)) {
1142
+ process.stderr.write(`error: Claude Code projects dir not found: ${source}\n`);
1143
+ return 1;
1144
+ }
1145
+ const projectDirs = readdirSync(source).filter((d) => {
1146
+ const memDir = join(source, d, "memory");
1147
+ return existsSync(memDir);
1148
+ });
1149
+ if (projectDirs.length === 0) {
1150
+ process.stderr.write(`no Claude Code projects with memory found at ${source}\n`);
1151
+ return 1;
1152
+ }
1153
+ const filtered = opts.project
1154
+ ? projectDirs.filter((d) => d.toLowerCase().includes(opts.project.toLowerCase()))
1155
+ : projectDirs;
1156
+ if (filtered.length === 0) {
1157
+ process.stderr.write(`no projects matched filter "${opts.project}". Available: ${projectDirs.join(", ")}\n`);
1158
+ return 1;
1159
+ }
1160
+ let imported = 0;
1161
+ let skipped = 0;
1162
+ let errors = 0;
1163
+ for (const projectDir of filtered) {
1164
+ const memDir = join(source, projectDir, "memory");
1165
+ process.stdout.write(`\n[${projectDir}]\n`);
1166
+ const files = readdirSync(memDir).filter((f) => f.endsWith(".md") && f !== "MEMORY.md");
1167
+ for (const file of files) {
1168
+ const fp = join(memDir, file);
1169
+ const raw = readFileSync(fp, "utf8");
1170
+ // Tolerate malformed frontmatter — fall back to filename + raw content.
1171
+ let parsedData = {};
1172
+ let parsedContent = raw;
1173
+ try {
1174
+ const parsed = matter(raw);
1175
+ parsedData = parsed.data;
1176
+ parsedContent = parsed.content;
1177
+ }
1178
+ catch (err) {
1179
+ const msg = err instanceof Error ? err.message : String(err);
1180
+ process.stdout.write(` warn ${file} · frontmatter parse failed (${msg.split("\n")[0]}); importing as-is\n`);
1181
+ }
1182
+ // The slug comes from the filename — Claude Code's `name` field is
1183
+ // a human-readable title, not a slug. We lowercase to enforce our
1184
+ // case rule. Special chars rejected downstream by SLUG_PATTERN.
1185
+ const name = file.replace(/\.md$/, "").toLowerCase();
1186
+ const metadata = parsedData.metadata ?? {};
1187
+ const type = String(parsedData.type ?? metadata.type ?? "project");
1188
+ const description = String(parsedData.description ?? parsedData.name ?? "(imported from Claude Code)");
1189
+ const content = parsedContent.trim();
1190
+ if (!SLUG_PATTERN.test(name)) {
1191
+ process.stdout.write(` skip ${file} · slug "${name}" has unsupported characters\n`);
1192
+ skipped++;
1193
+ continue;
1194
+ }
1195
+ if (!VALID_TYPES.has(type)) {
1196
+ process.stdout.write(` skip ${name} · invalid type "${type}"\n`);
1197
+ skipped++;
1198
+ continue;
1199
+ }
1200
+ if (!opts.overwrite && existsSync(memoryFilePath(name))) {
1201
+ process.stdout.write(` skip ${name} · already exists (use --overwrite to replace)\n`);
1202
+ skipped++;
1203
+ continue;
1204
+ }
1205
+ if (opts.dryRun) {
1206
+ process.stdout.write(` would import ${name} [${type}]\n`);
1207
+ imported++;
1208
+ continue;
1209
+ }
1210
+ try {
1211
+ toolSaveMemory({ name, type, description, content });
1212
+ process.stdout.write(` imported ${name} [${type}]\n`);
1213
+ imported++;
1214
+ }
1215
+ catch (err) {
1216
+ const msg = err instanceof Error ? err.message : String(err);
1217
+ process.stdout.write(` error ${name} · ${msg}\n`);
1218
+ errors++;
1219
+ }
1220
+ }
1221
+ }
1222
+ process.stdout.write(`\n${opts.dryRun ? "dry run" : "import"} complete · imported=${imported} skipped=${skipped} errors=${errors}\n`);
1223
+ if (opts.dryRun) {
1224
+ process.stdout.write(`(re-run without --dry-run to actually save)\n`);
1225
+ }
1226
+ return errors > 0 ? 1 : 0;
1227
+ }
1228
+ // -------------------------------------------------------------
1229
+ // Boot · dispatch CLI vs MCP server based on argv
1230
+ // -------------------------------------------------------------
1231
+ async function main() {
1232
+ const command = process.argv[2];
1233
+ if (command && CLI_COMMANDS.has(command)) {
1234
+ const exitCode = await cliMain(command, process.argv.slice(3));
1235
+ process.exit(exitCode);
1236
+ }
1237
+ ensureStorage();
1238
+ const transport = new StdioServerTransport();
1239
+ await server.connect(transport);
1240
+ process.stderr.write(`agent-memory-mcp · storage: ${MEMORY_DIR}\n`);
1241
+ }
1242
+ main().catch((err) => {
1243
+ process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
1244
+ process.exit(1);
1245
+ });