@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.
- package/LICENSE +21 -0
- package/README.md +367 -0
- package/dist/index.js +1245 -0
- 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
|
+
});
|