@xultrax-web/agent-memory-mcp 0.6.0 → 0.8.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 -21
  2. package/README.md +68 -18
  3. package/dist/index.js +626 -15
  4. package/package.json +1 -1
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 xultrax-web
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xultrax-web
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -185,18 +185,32 @@ Custom path:
185
185
 
186
186
  ## Tools
187
187
 
188
- | Tool | Purpose |
189
- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
190
- | `save_memory` | Create or update a memory. Atomic write + locked. Validates name + type. Updates the index. |
191
- | `search_memories` | Fuzzy search (Fuse.js · typo-tolerant, word-order tolerant, partial matches). Returns top N with relevance 0-100 + body snippet. |
192
- | `relevant_memories` | Same matching as search, but returns full memory bodies as one markdown doc. Built for LLM auto-context. |
193
- | `get_memory` | Fetch one memory by name. Returns frontmatter + body. |
194
- | `list_memories` | List memories. Optional `type` filter. Paginated (default 50/page). |
195
- | `delete_memory` | Soft delete: moves the memory to `.trash/<ts>-<name>.md`. Recoverable until you empty `.trash/` by hand. |
196
- | `restore_memory` | Restore a soft-deleted memory from `.trash/`. Picks the most recent trash entry for the name. |
197
- | `doctor` | Storage integrity check. Reports orphans, dangling index entries, unreadable files. Pass `rebuild-index=true` to repair `MEMORY.md` from disk. |
198
- | `stats` | Dashboard: counts per type, total size, largest memory, audit-log size, trash count. |
199
- | `log_events` | Read recent entries from the audit event log. Optional `tail` (default 20) + `action` filter. |
188
+ | Tool | Purpose |
189
+ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
190
+ | `save_memory` | Create or update a memory. Atomic write + locked. Validates name + type. Updates the index. |
191
+ | `search_memories` | Fuzzy search (Fuse.js · typo-tolerant, word-order tolerant, partial matches). Returns top N with relevance 0-100 + body snippet. |
192
+ | `relevant_memories` | Same matching as search, but returns full memory bodies as one markdown doc. Built for LLM auto-context. |
193
+ | `get_memory` | Fetch one memory by name. Returns frontmatter + body. |
194
+ | `list_memories` | List memories. Optional `type` filter. Paginated (default 50/page). |
195
+ | `delete_memory` | Soft delete: moves the memory to `.trash/<ts>-<name>.md`. Recoverable until you empty `.trash/` by hand. |
196
+ | `restore_memory` | Restore a soft-deleted memory from `.trash/`. Picks the most recent trash entry for the name. |
197
+ | `doctor` | Storage integrity check. Reports orphans, dangling index entries, unreadable files. Pass `rebuild-index=true` to repair `MEMORY.md` from disk. |
198
+ | `stats` | Dashboard: counts per type, total size, largest memory, audit-log size, trash count. |
199
+ | `log_events` | Read recent entries from the audit event log. Optional `tail` (default 20) + `action` filter. |
200
+ | `verify_memory` | Re-evaluate a memory's claims. Extracts URLs/dates/file refs, flags stale-date signals, returns type-specific verification heuristics. Pairs with the `audit_stale` prompt. |
201
+ | `find_backlinks` | List memories that link to the given memory via `[[wiki-link]]` syntax in their bodies. Useful for "what references this" views. |
202
+ | `find_related` | Surface memories related to one by combining outbound links, inbound backlinks, shared tags, type match, and content similarity. Navigates the memory graph by association. |
203
+
204
+ ### Prompts
205
+
206
+ The server exposes 4 built-in MCP prompts that clients (Claude Desktop, Cursor, etc.) surface as slash-commands. These turn memory into an active workflow layer, not just a passive store:
207
+
208
+ | Prompt | Arguments | What it does |
209
+ | ------------------ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
210
+ | `extract_memories` | none | LLM scans the current conversation, proposes candidate memories, and calls `save_memory` for each one (with type + description chosen). |
211
+ | `summarize_topic` | `topic` | LLM pulls memories relevant to the topic via `relevant_memories` and synthesizes them into a single summary with citations. |
212
+ | `prepare_handoff` | `project` (optional) | LLM walks project-type memories matching the filter and assembles a structured handoff doc (current state, open items, watch-outs). |
213
+ | `audit_stale` | none | LLM evaluates project + reference memories for staleness and produces a triage list (likely stale / worth verifying / still fresh). |
200
214
 
201
215
  ### Memory types
202
216
 
@@ -207,6 +221,23 @@ Four built-in types, matching the Claude Code convention:
207
221
  - **project** — current-state context that isn't in the code (deadlines, in-flight work)
208
222
  - **reference** — pointers to external systems (Linear board URL, monitoring dashboard)
209
223
 
224
+ ### Tags + wiki-links
225
+
226
+ Beyond types, two cross-cutting organization features:
227
+
228
+ **Tags** — optional `tags: [a, b, c]` array in frontmatter. Queryable via `list_memories({tags: [...]})` and the `agent-memory list --tags "a,b"` CLI. Filter is intersection — memories must have all listed tags. Tag names are lowercase a-z + digits + hyphen/underscore, max 40 chars.
229
+
230
+ ```markdown
231
+ ---
232
+ name: deploy-process
233
+ description: Blue-green prod deployment
234
+ type: project
235
+ tags: [deployment, production, critical]
236
+ ---
237
+ ```
238
+
239
+ **Wiki-links** — write `[[memory-name]]` anywhere in a memory body and it becomes a link. `find_backlinks` returns memories that reference a given one; `find_related` ranks the full graph (outbound links, inbound backlinks, shared tags, content similarity) for discovery navigation.
240
+
210
241
  ---
211
242
 
212
243
  ## CLI
@@ -229,6 +260,11 @@ agent-memory doctor --rebuild-index # repair MEMORY.md from disk
229
260
  agent-memory stats # dashboard: counts, sizes, audit/trash
230
261
  agent-memory log # last 20 entries from the audit log
231
262
  agent-memory log --tail 50 --action delete # filter by action, tail size
263
+ agent-memory verify deploy-process # extract URLs/dates/file refs + staleness heuristics
264
+ agent-memory save my-mem --type project --description "X" --content "Body" --tags "production,critical"
265
+ agent-memory list --tags "production" # filter by tag (intersection)
266
+ agent-memory backlinks deploy-process # memories that link to deploy-process
267
+ agent-memory related deploy-process # ranked discovery: links + tags + similarity
232
268
  ```
233
269
 
234
270
  ### Audit log + structured logging
@@ -326,15 +362,29 @@ This server is built to be used daily, not to demo well once.
326
362
 
327
363
  **Shipped in v0.6:**
328
364
 
329
- - **Vitest test suite** — 20+ blackbox tests covering CLI + MCP server paths.
330
- - **GitHub Actions CI** — runs tests on every push/PR across Node 18/20/22.
365
+ - **Vitest test suite** — 25+ blackbox tests covering CLI + MCP server paths.
366
+ - **GitHub Actions CI** — runs tests on every push/PR across Node 20/22/24.
331
367
  - **[COMPATIBILITY.md](COMPATIBILITY.md)** — known-working client matrix + quirks.
332
368
 
333
- **Landing in v0.7:**
369
+ **Shipped in v0.7 · the active context layer:**
370
+
371
+ - **MCP Prompts capability** — 4 built-in workflows (`extract_memories`, `summarize_topic`, `prepare_handoff`, `audit_stale`) that the client surfaces as slash-commands.
372
+ - **`verify_memory` tool** — static analysis of a memory's URLs/dates/file refs with type-specific staleness heuristics. Plus the matching `agent-memory verify <name>` CLI.
373
+ - **Conflict detection on save** — fuzzy-matches new memories against existing ones; warns on near-duplicates without blocking the save (so the LLM can decide whether to merge, rename, or proceed).
374
+
375
+ **Shipped in v0.8 · organization at scale:**
376
+
377
+ - **Tags** — optional `tags: [...]` array in frontmatter. Queryable via `list_memories` and `agent-memory list --tags "a,b"`. Intersection filter.
378
+ - **`[[wiki-links]]`** — write `[[memory-name]]` in any memory body, auto-detected.
379
+ - **`find_backlinks`** tool + `agent-memory backlinks <name>` CLI — "what links to this".
380
+ - **`find_related`** tool + `agent-memory related <name>` CLI — combines outbound + inbound links, shared tags, type match, and content similarity into a ranked discovery view.
381
+
382
+ **Landing in v0.9+:**
334
383
 
335
- - Read-only mode (`AGENT_MEMORY_READ_ONLY=1`)
336
- - Live multi-client verification (Cursor, Cline, Claude Desktop, Continue)
337
- - Demo GIF / screencast
384
+ - Folder support (`.agent-memory/work/`, `.agent-memory/personal/`)
385
+ - TUI / web UI for browsing + editing memories in a clean interface
386
+ - `agent-memory sync` for git-backed multi-machine memory (the moat)
387
+ - Memory packs for shareable curated bundles
338
388
 
339
389
  ---
340
390
 
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
25
25
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26
- import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
26
+ import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
27
27
  import Fuse from "fuse.js";
28
28
  import matter from "gray-matter";
29
29
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
@@ -171,6 +171,12 @@ const VALID_TYPES = new Set(["user", "feedback", "project", "reference"]);
171
171
  // a letter or digit, 1-80 chars. Underscores are allowed because Claude
172
172
  // Code's memory tree uses them; we want frictionless import.
173
173
  const SLUG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,80}$/;
174
+ // Tags: lowercase, digits, hyphen/underscore. Max 40 chars per tag.
175
+ // Same alphabet as slugs but shorter — meant for "container-industry",
176
+ // "weekly", "deprecated", etc.
177
+ const TAG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,40}$/;
178
+ // Wiki-links: [[memory-name]] · names follow SLUG_PATTERN rules
179
+ const WIKI_LINK_PATTERN = /\[\[([a-z0-9][a-z0-9_-]{0,80})\]\]/g;
174
180
  function memoryFilePath(name) {
175
181
  return join(MEMORY_DIR, `${name}.md`);
176
182
  }
@@ -185,6 +191,7 @@ function readMemory(name) {
185
191
  name: fm.name ?? name,
186
192
  description: fm.description ?? "",
187
193
  type: fm.type ?? "project",
194
+ tags: Array.isArray(fm.tags) ? fm.tags.filter((t) => typeof t === "string") : [],
188
195
  body: parsed.content.trim(),
189
196
  filePath: fp,
190
197
  };
@@ -196,6 +203,50 @@ function listMemoryFiles() {
196
203
  .filter((f) => f.endsWith(".md") && f !== "MEMORY.md")
197
204
  .map((f) => f.replace(/\.md$/, ""));
198
205
  }
206
+ // Tags can arrive as a string array (MCP/CLI args) or a comma-separated
207
+ // string (CLI flag). Normalize to a deduped lowercase string[] preserving
208
+ // order of first appearance.
209
+ function normalizeTags(input) {
210
+ if (!input)
211
+ return [];
212
+ let raw;
213
+ if (Array.isArray(input)) {
214
+ raw = input.map((t) => String(t).trim()).filter((t) => t.length > 0);
215
+ }
216
+ else if (typeof input === "string") {
217
+ raw = input
218
+ .split(",")
219
+ .map((t) => t.trim())
220
+ .filter((t) => t.length > 0);
221
+ }
222
+ else {
223
+ return [];
224
+ }
225
+ const seen = new Set();
226
+ const out = [];
227
+ for (const t of raw) {
228
+ const lower = t.toLowerCase();
229
+ if (!seen.has(lower)) {
230
+ seen.add(lower);
231
+ out.push(lower);
232
+ }
233
+ }
234
+ return out;
235
+ }
236
+ // Extract [[wiki-link]] targets from a memory body. Returns the set of
237
+ // referenced memory names (deduped, lowercase). Self-references stripped.
238
+ function extractWikiLinks(body, selfName) {
239
+ const found = new Set();
240
+ let m;
241
+ // RegExp with /g flag needs reset of lastIndex per call
242
+ WIKI_LINK_PATTERN.lastIndex = 0;
243
+ while ((m = WIKI_LINK_PATTERN.exec(body)) !== null) {
244
+ const target = m[1].toLowerCase();
245
+ if (target !== selfName)
246
+ found.add(target);
247
+ }
248
+ return Array.from(found);
249
+ }
199
250
  // -------------------------------------------------------------
200
251
  // Index management
201
252
  // -------------------------------------------------------------
@@ -236,48 +287,137 @@ function toolSaveMemory(args) {
236
287
  const description = String(args.description ?? "").trim();
237
288
  const type = String(args.type ?? "project").trim();
238
289
  const content = String(args.content ?? "").trim();
290
+ const tags = normalizeTags(args.tags);
239
291
  if (!SLUG_PATTERN.test(name)) {
240
292
  throw new Error(`Invalid name "${name}". Use lowercase (a-z, 0-9, hyphen, underscore), 1-80 chars, must start with letter or digit.`);
241
293
  }
242
294
  if (!VALID_TYPES.has(type)) {
243
295
  throw new Error(`Invalid type "${type}". Must be one of: ${Array.from(VALID_TYPES).join(", ")}.`);
244
296
  }
297
+ for (const tag of tags) {
298
+ if (!TAG_PATTERN.test(tag)) {
299
+ throw new Error(`Invalid tag "${tag}". Tags use lowercase (a-z, 0-9, hyphen, underscore), 1-40 chars.`);
300
+ }
301
+ }
245
302
  if (!description)
246
303
  throw new Error("description is required");
247
304
  if (!content)
248
305
  throw new Error("content is required");
249
306
  ensureStorage();
307
+ const tagsLine = tags.length > 0 ? `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]\n` : "";
250
308
  const frontmatter = `---\n` +
251
309
  `name: ${name}\n` +
252
310
  `description: ${JSON.stringify(description)}\n` +
253
311
  `type: ${type}\n` +
312
+ tagsLine +
254
313
  `schema: ${SCHEMA_VERSION}\n` +
255
314
  `---\n\n`;
256
315
  const fp = memoryFilePath(name);
257
316
  const isUpdate = existsSync(fp);
317
+ // Conflict detection · only when creating a NEW memory (updates are
318
+ // intentional re-saves and don't need a similarity warning). Fuzzy
319
+ // match against existing names + descriptions; if anything scores
320
+ // above the conflict threshold, warn but don't block — the LLM can
321
+ // decide whether to merge, rename, or proceed.
322
+ let conflictWarning = "";
323
+ if (!isUpdate) {
324
+ const conflicts = detectConflicts({ name, description, type });
325
+ if (conflicts.length > 0) {
326
+ const list = conflicts
327
+ .map((c) => ` - ${c.name} [${c.type}] (${c.similarity}% similar)`)
328
+ .join("\n");
329
+ conflictWarning =
330
+ `\n\nWARNING · potentially similar existing memory(ies):\n${list}\n` +
331
+ `Consider merging or renaming. To proceed anyway, the save has already been completed.`;
332
+ }
333
+ }
258
334
  return withLock(() => {
259
335
  atomicWriteFile(fp, frontmatter + content + "\n");
260
336
  upsertIndexEntryUnlocked(name, description);
261
- logEvent("save", { name, type, update: isUpdate, bytes: content.length });
337
+ logEvent("save", {
338
+ name,
339
+ type,
340
+ update: isUpdate,
341
+ bytes: content.length,
342
+ conflicts: conflictWarning ? "warned" : undefined,
343
+ });
262
344
  log("debug", "save_memory", { name, type, update: isUpdate });
263
- return `${isUpdate ? "Updated" : "Saved"} memory "${name}" (${type}) at ${fp}`;
345
+ return `${isUpdate ? "Updated" : "Saved"} memory "${name}" (${type}) at ${fp}${conflictWarning}`;
346
+ });
347
+ }
348
+ function detectConflicts(candidate) {
349
+ const names = listMemoryFiles();
350
+ // Skip the candidate name itself (will be an update, not a conflict)
351
+ const others = names.filter((n) => n !== candidate.name);
352
+ if (others.length === 0)
353
+ return [];
354
+ const existing = others.map((n) => readMemory(n)).filter((m) => m !== null);
355
+ if (existing.length === 0)
356
+ return [];
357
+ // Two separate searches catch different conflict shapes:
358
+ // - Name-based: matches when the new slug is very close to an
359
+ // existing slug (e.g. "deploy-process" vs "deployment-strategy")
360
+ // - Description-based: matches when descriptions are paraphrases
361
+ // of each other regardless of name
362
+ // We merge results + dedupe. Threshold 0.5 here is intentionally
363
+ // looser than search (0.4) because we'd rather warn on a few false
364
+ // positives than miss an obvious duplicate.
365
+ // Threshold 0.6 catches paraphrases like "deployment-strategy" vs
366
+ // "deploy-process" (Fuse Bitap scores those around 0.5). The 45%
367
+ // similarity floor below then trims out the long tail.
368
+ const nameFuse = new Fuse(existing, {
369
+ includeScore: true,
370
+ threshold: 0.6,
371
+ ignoreLocation: true,
372
+ minMatchCharLength: 3,
373
+ keys: ["name"],
374
+ });
375
+ const descFuse = new Fuse(existing, {
376
+ includeScore: true,
377
+ threshold: 0.6,
378
+ ignoreLocation: true,
379
+ minMatchCharLength: 3,
380
+ keys: ["description"],
264
381
  });
382
+ const merged = new Map();
383
+ const addHit = (name, type, score) => {
384
+ const existingHit = merged.get(name);
385
+ const similarity = Math.round((1 - score) * 100);
386
+ if (!existingHit || similarity > existingHit.similarity) {
387
+ merged.set(name, { name, type, similarity });
388
+ }
389
+ };
390
+ for (const r of nameFuse.search(candidate.name, { limit: 3 })) {
391
+ addHit(r.item.name, r.item.type, r.score ?? 1);
392
+ }
393
+ for (const r of descFuse.search(candidate.description, { limit: 3 })) {
394
+ addHit(r.item.name, r.item.type, r.score ?? 1);
395
+ }
396
+ return Array.from(merged.values())
397
+ .filter((h) => h.similarity >= 45) // 45%+ similarity = worth surfacing
398
+ .sort((a, b) => b.similarity - a.similarity)
399
+ .slice(0, 3);
265
400
  }
266
401
  function toolGetMemory(args) {
267
402
  const name = String(args.name ?? "").trim();
268
403
  const mem = readMemory(name);
269
404
  if (!mem)
270
405
  return `Memory "${name}" not found.`;
406
+ const tagsLine = mem.tags.length > 0 ? `tags : ${mem.tags.join(", ")}` : "";
271
407
  return [
272
408
  `# ${mem.name}`,
273
- `type: ${mem.type}`,
274
- `description: ${mem.description}`,
409
+ `type : ${mem.type}`,
410
+ tagsLine,
411
+ `description : ${mem.description}`,
275
412
  "",
276
413
  mem.body,
277
- ].join("\n");
414
+ ]
415
+ .filter((l) => l !== "")
416
+ .join("\n");
278
417
  }
279
418
  function toolListMemories(args) {
280
419
  const typeFilter = args.type ? String(args.type) : null;
420
+ const tagFilter = normalizeTags(args.tags);
281
421
  const offset = args.offset ? Math.max(0, Number(args.offset)) : 0;
282
422
  const limit = args.limit ? Math.max(1, Number(args.limit)) : 50;
283
423
  const names = listMemoryFiles();
@@ -285,21 +425,34 @@ function toolListMemories(args) {
285
425
  .map((n) => readMemory(n))
286
426
  .filter((m) => m !== null)
287
427
  .filter((m) => !typeFilter || m.type === typeFilter)
428
+ .filter((m) => tagFilter.length === 0 || tagFilter.every((t) => m.tags.includes(t)))
288
429
  .sort((a, b) => a.name.localeCompare(b.name));
289
430
  if (all.length === 0) {
290
- return typeFilter
291
- ? `No memories of type "${typeFilter}".`
431
+ const parts = [];
432
+ if (typeFilter)
433
+ parts.push(`type "${typeFilter}"`);
434
+ if (tagFilter.length > 0)
435
+ parts.push(`tags [${tagFilter.join(", ")}]`);
436
+ return parts.length > 0
437
+ ? `No memories matching ${parts.join(" and ")}.`
292
438
  : "No memories yet. Use save_memory to create one.";
293
439
  }
294
440
  const page = all.slice(offset, offset + limit);
295
441
  const lines = [];
442
+ const filterDesc = [
443
+ typeFilter ? `type=${typeFilter}` : null,
444
+ tagFilter.length > 0 ? `tags=[${tagFilter.join(",")}]` : null,
445
+ ]
446
+ .filter(Boolean)
447
+ .join(" ");
296
448
  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}:`;
449
+ ? `Found ${all.length} memor${all.length === 1 ? "y" : "ies"}${filterDesc ? ` (${filterDesc})` : ""}:`
450
+ : `Showing ${offset + 1}-${offset + page.length} of ${all.length}${filterDesc ? ` (${filterDesc})` : ""}:`;
299
451
  lines.push(showing);
300
452
  lines.push("");
301
453
  for (const m of page) {
302
- lines.push(` ${m.name} [${m.type}]`);
454
+ const tagSuffix = m.tags.length > 0 ? ` ${c(ANSI.dim, `· ${m.tags.join(" · ")}`)}` : "";
455
+ lines.push(` ${m.name} [${m.type}]${tagSuffix}`);
303
456
  lines.push(` ${m.description}`);
304
457
  }
305
458
  if (offset + page.length < all.length) {
@@ -558,6 +711,208 @@ function toolDoctor(args) {
558
711
  return formatDoctorReport(report, rebuild);
559
712
  }
560
713
  // -------------------------------------------------------------
714
+ // verify_memory · re-evaluate a memory's claims
715
+ // -------------------------------------------------------------
716
+ //
717
+ // Static analysis only (no network calls — MCP servers may run
718
+ // offline). Extracts URLs + dates + file paths from the body and
719
+ // returns a structured report the LLM can act on. Pairs with the
720
+ // audit_stale prompt to triage memory hygiene.
721
+ const URL_PATTERN = /\bhttps?:\/\/[^\s<>"')]+/g;
722
+ const DATE_PATTERN = /\b(20\d{2})-(\d{2})-(\d{2})\b/g;
723
+ const FILE_PATH_PATTERN = /\b(?:[A-Za-z]:\\|\/)?(?:[\w.-]+[\\/])+[\w.-]+\.\w+\b/g;
724
+ function toolVerifyMemory(args) {
725
+ const name = String(args.name ?? "").trim();
726
+ if (!SLUG_PATTERN.test(name))
727
+ throw new Error(`Invalid name "${name}".`);
728
+ const mem = readMemory(name);
729
+ if (!mem)
730
+ return `Memory "${name}" not found.`;
731
+ const urls = Array.from(new Set(mem.body.match(URL_PATTERN) ?? []));
732
+ const dates = Array.from(new Set((mem.body.match(DATE_PATTERN) ?? []).map((d) => d)));
733
+ const filePaths = Array.from(new Set(mem.body.match(FILE_PATH_PATTERN) ?? []));
734
+ // Staleness heuristic: if any date in the body is more than 60 days
735
+ // old AND the memory type is project, flag it for review.
736
+ const now = Date.now();
737
+ const SIXTY_DAYS = 60 * 24 * 3600 * 1000;
738
+ const oldDates = dates.filter((d) => {
739
+ const parsed = Date.parse(d);
740
+ return !isNaN(parsed) && now - parsed > SIXTY_DAYS;
741
+ });
742
+ const lines = [];
743
+ lines.push(c(ANSI.bold, `verify_memory · ${mem.name}`));
744
+ lines.push(`type : ${mem.type}`);
745
+ lines.push(`description : ${mem.description}`);
746
+ lines.push("");
747
+ lines.push(c(ANSI.bold, "Static signals:"));
748
+ lines.push(` URLs found : ${urls.length}`);
749
+ lines.push(` Dates referenced : ${dates.length}${oldDates.length > 0 ? ` (${oldDates.length} > 60 days old)` : ""}`);
750
+ lines.push(` File-path refs : ${filePaths.length}`);
751
+ if (urls.length > 0) {
752
+ lines.push("");
753
+ lines.push(c(ANSI.bold, "URLs to verify:"));
754
+ for (const u of urls.slice(0, 10))
755
+ lines.push(` - ${u}`);
756
+ if (urls.length > 10)
757
+ lines.push(` ... +${urls.length - 10} more`);
758
+ }
759
+ if (oldDates.length > 0) {
760
+ lines.push("");
761
+ lines.push(c(ANSI.yellow, "Stale-date signals (consider whether claims are still current):"));
762
+ for (const d of oldDates.slice(0, 5))
763
+ lines.push(` - ${d}`);
764
+ }
765
+ if (filePaths.length > 0 && filePaths.length <= 10) {
766
+ lines.push("");
767
+ lines.push(c(ANSI.bold, "File paths referenced:"));
768
+ for (const fp of filePaths)
769
+ lines.push(` - ${fp}`);
770
+ }
771
+ lines.push("");
772
+ lines.push(c(ANSI.bold, "Type-specific verification heuristics:"));
773
+ switch (mem.type) {
774
+ case "reference":
775
+ lines.push(" - HEAD-check each URL above (200 = alive, 404 = dead, 410 = gone)");
776
+ lines.push(" - Verify the resource still says what the memory claims it says");
777
+ break;
778
+ case "project":
779
+ lines.push(" - Check if any dates above are stale relative to current project state");
780
+ lines.push(" - Cross-reference any names/people mentioned against current org chart");
781
+ lines.push(" - Verify any deadlines or commitments haven't already passed");
782
+ break;
783
+ case "feedback":
784
+ lines.push(" - Confirm the rule still applies (operator hasn't changed their mind)");
785
+ lines.push(" - Check the **Why:** is still load-bearing — if the original reason is gone, the rule may be obsolete");
786
+ break;
787
+ case "user":
788
+ lines.push(" - User preferences drift over time; ask the operator if a 6+ month old memory still holds");
789
+ break;
790
+ }
791
+ lines.push("");
792
+ lines.push(c(ANSI.dim, "Memory body for review:"));
793
+ lines.push("");
794
+ lines.push(mem.body);
795
+ return lines.join("\n");
796
+ }
797
+ // -------------------------------------------------------------
798
+ // Backlinks · which memories link to this one via [[wiki-link]]
799
+ // -------------------------------------------------------------
800
+ function toolFindBacklinks(args) {
801
+ const name = String(args.name ?? "").trim();
802
+ if (!SLUG_PATTERN.test(name))
803
+ throw new Error(`Invalid name "${name}".`);
804
+ const target = name.toLowerCase();
805
+ const all = listMemoryFiles()
806
+ .map((n) => readMemory(n))
807
+ .filter((m) => m !== null);
808
+ const backlinks = [];
809
+ for (const m of all) {
810
+ if (m.name === name)
811
+ continue;
812
+ const links = extractWikiLinks(m.body, m.name);
813
+ if (links.includes(target))
814
+ backlinks.push(m);
815
+ }
816
+ if (backlinks.length === 0)
817
+ return `No memories link to [[${name}]].`;
818
+ const lines = [];
819
+ lines.push(c(ANSI.bold, `Found ${backlinks.length} memor${backlinks.length === 1 ? "y" : "ies"} linking to [[${name}]]:`));
820
+ lines.push("");
821
+ for (const m of backlinks.sort((a, b) => a.name.localeCompare(b.name))) {
822
+ lines.push(` ${m.name} [${m.type}]`);
823
+ lines.push(` ${m.description}`);
824
+ }
825
+ return lines.join("\n");
826
+ }
827
+ function toolFindRelated(args) {
828
+ const name = String(args.name ?? "").trim();
829
+ if (!SLUG_PATTERN.test(name))
830
+ throw new Error(`Invalid name "${name}".`);
831
+ const mem = readMemory(name);
832
+ if (!mem)
833
+ return `Memory "${name}" not found.`;
834
+ const max = args.max ? Math.max(1, Math.min(20, Number(args.max))) : 8;
835
+ const others = listMemoryFiles()
836
+ .filter((n) => n !== name)
837
+ .map((n) => readMemory(n))
838
+ .filter((m) => m !== null);
839
+ if (others.length === 0)
840
+ return `No other memories to compare against.`;
841
+ const outbound = new Set(extractWikiLinks(mem.body, mem.name));
842
+ const myTags = new Set(mem.tags);
843
+ // Fuzzy similarity against name + description only (body content is
844
+ // too noisy for relatedness; we already cover semantic overlap via
845
+ // search_memories).
846
+ const fuse = new Fuse(others, {
847
+ includeScore: true,
848
+ threshold: 0.7,
849
+ ignoreLocation: true,
850
+ minMatchCharLength: 3,
851
+ keys: [
852
+ { name: "name", weight: 2 },
853
+ { name: "description", weight: 1 },
854
+ ],
855
+ });
856
+ const fuzzy = new Map();
857
+ const query = `${mem.name} ${mem.description}`;
858
+ for (const r of fuse.search(query, { limit: 20 })) {
859
+ fuzzy.set(r.item.name, 1 - (r.score ?? 1));
860
+ }
861
+ const scored = new Map();
862
+ for (const other of others) {
863
+ let score = 0;
864
+ const reasons = [];
865
+ if (outbound.has(other.name)) {
866
+ score += 5;
867
+ reasons.push("linked from this memory");
868
+ }
869
+ const otherOutbound = extractWikiLinks(other.body, other.name);
870
+ if (otherOutbound.includes(mem.name)) {
871
+ score += 5;
872
+ reasons.push("links to this memory");
873
+ }
874
+ const sharedTags = other.tags.filter((t) => myTags.has(t));
875
+ if (sharedTags.length > 0) {
876
+ score += sharedTags.length * 3;
877
+ reasons.push(`shared tags: ${sharedTags.join(", ")}`);
878
+ }
879
+ if (other.type === mem.type) {
880
+ score += 1;
881
+ }
882
+ const sim = fuzzy.get(other.name);
883
+ if (sim && sim > 0.3) {
884
+ score += Math.round(sim * 4);
885
+ reasons.push(`content similarity ${Math.round(sim * 100)}%`);
886
+ }
887
+ if (score > 0) {
888
+ scored.set(other.name, {
889
+ name: other.name,
890
+ type: other.type,
891
+ tags: other.tags,
892
+ description: other.description,
893
+ score,
894
+ reasons,
895
+ });
896
+ }
897
+ }
898
+ const ranked = Array.from(scored.values())
899
+ .sort((a, b) => b.score - a.score)
900
+ .slice(0, max);
901
+ if (ranked.length === 0)
902
+ return `No memories related to "${name}" found.`;
903
+ const lines = [];
904
+ lines.push(c(ANSI.bold, `Memories related to ${name}:`));
905
+ lines.push("");
906
+ for (const r of ranked) {
907
+ lines.push(` ${r.name} [${r.type}] ${c(ANSI.dim, `· score ${r.score}`)}`);
908
+ lines.push(` ${r.description}`);
909
+ for (const reason of r.reasons) {
910
+ lines.push(` ${c(ANSI.dim, `· ${reason}`)}`);
911
+ }
912
+ }
913
+ return lines.join("\n");
914
+ }
915
+ // -------------------------------------------------------------
561
916
  // Stats · operator dashboard
562
917
  // -------------------------------------------------------------
563
918
  function toolStats(_args) {
@@ -667,7 +1022,7 @@ function actionColor(action) {
667
1022
  // -------------------------------------------------------------
668
1023
  // Server wiring
669
1024
  // -------------------------------------------------------------
670
- const server = new Server({ name: "agent-memory", version: "0.2.0" }, { capabilities: { tools: {}, resources: {} } });
1025
+ const server = new Server({ name: "agent-memory", version: "0.8.0" }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
671
1026
  // -------------------------------------------------------------
672
1027
  // Resource URI scheme
673
1028
  // -------------------------------------------------------------
@@ -727,6 +1082,169 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
727
1082
  }
728
1083
  throw new Error(`Unknown resource URI: ${uri}. Supported: ${URI_INDEX}, ${URI_MEMORY_PREFIX}{name}`);
729
1084
  });
1085
+ const PROMPTS = [
1086
+ {
1087
+ name: "extract_memories",
1088
+ description: "Scan the current conversation for things worth remembering. Returns a structured prompt asking the LLM to identify candidate memories and call save_memory for each one.",
1089
+ },
1090
+ {
1091
+ name: "summarize_topic",
1092
+ description: "Pull memories relevant to a topic and ask the LLM to synthesize them into a single coherent summary.",
1093
+ arguments: [
1094
+ { name: "topic", description: "What to summarize what's known about.", required: true },
1095
+ ],
1096
+ },
1097
+ {
1098
+ name: "prepare_handoff",
1099
+ description: "Generate a project state snapshot from all project-type memories matching a filter. Useful for rotating on-call, end-of-day handoffs, or onboarding a collaborator.",
1100
+ arguments: [
1101
+ {
1102
+ name: "project",
1103
+ description: "Substring to filter project memories by (matches name + description + tags).",
1104
+ required: false,
1105
+ },
1106
+ ],
1107
+ },
1108
+ {
1109
+ name: "audit_stale",
1110
+ description: "Walk recent project and reference memories and ask the LLM to flag which ones are likely stale or contradicted by current state. Pairs with verify_memory for follow-up.",
1111
+ },
1112
+ ];
1113
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
1114
+ prompts: PROMPTS,
1115
+ }));
1116
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
1117
+ const { name, arguments: args = {} } = request.params;
1118
+ ensureStorage();
1119
+ switch (name) {
1120
+ case "extract_memories":
1121
+ return {
1122
+ description: PROMPTS[0].description,
1123
+ messages: [
1124
+ {
1125
+ role: "user",
1126
+ content: {
1127
+ type: "text",
1128
+ text: [
1129
+ "Scan the current conversation for facts, rules, preferences, decisions, or context that would be useful to remember across future sessions. Focus on things that:",
1130
+ "",
1131
+ "- Reflect the operator's stable preferences or working style (type=user)",
1132
+ "- Represent rules the assistant should follow going forward (type=feedback)",
1133
+ "- Capture current-state context not derivable from code or docs (type=project)",
1134
+ "- Point at external resources the operator references (type=reference)",
1135
+ "",
1136
+ "For each candidate, call the save_memory tool with:",
1137
+ " - name: a short kebab-case slug (a-z, 0-9, -, _)",
1138
+ " - type: one of user | feedback | project | reference",
1139
+ " - description: a one-line summary used in the index",
1140
+ " - content: the memory body in markdown. For feedback/project, include `**Why:**` and `**How to apply:**` lines.",
1141
+ "",
1142
+ "Before saving each one, briefly explain why it's worth remembering. Skip anything that's already obvious from code or that's only relevant to the current session.",
1143
+ "",
1144
+ "If there's nothing worth saving, say so plainly and stop.",
1145
+ ].join("\n"),
1146
+ },
1147
+ },
1148
+ ],
1149
+ };
1150
+ case "summarize_topic": {
1151
+ const topic = String(args.topic ?? "").trim();
1152
+ if (!topic)
1153
+ throw new Error("summarize_topic requires a 'topic' argument");
1154
+ return {
1155
+ description: PROMPTS[1].description,
1156
+ messages: [
1157
+ {
1158
+ role: "user",
1159
+ content: {
1160
+ type: "text",
1161
+ text: [
1162
+ `Call the relevant_memories tool with query="${topic}" (max=10).`,
1163
+ "",
1164
+ "Then synthesize the returned memories into a single coherent summary covering:",
1165
+ "",
1166
+ "1. What's established / known",
1167
+ "2. Open questions or unresolved tensions across the memories",
1168
+ "3. Any stale or contradicted claims worth flagging",
1169
+ "",
1170
+ "Keep it tight — aim for one paragraph per section unless the material is genuinely dense. Cite the source memory names inline as `[memory-name]`.",
1171
+ ].join("\n"),
1172
+ },
1173
+ },
1174
+ ],
1175
+ };
1176
+ }
1177
+ case "prepare_handoff": {
1178
+ const project = String(args.project ?? "").trim();
1179
+ const filterClause = project
1180
+ ? `filtered to project memories matching "${project}"`
1181
+ : "across all project memories";
1182
+ return {
1183
+ description: PROMPTS[2].description,
1184
+ messages: [
1185
+ {
1186
+ role: "user",
1187
+ content: {
1188
+ type: "text",
1189
+ text: [
1190
+ `Generate a project handoff document ${filterClause}.`,
1191
+ "",
1192
+ project
1193
+ ? `Start by calling list_memories with type="project", then filter the results by substring match against "${project}".`
1194
+ : `Start by calling list_memories with type="project" to get the full set.`,
1195
+ "",
1196
+ "For each relevant memory, call get_memory to load its full body. Then produce a single handoff document with these sections:",
1197
+ "",
1198
+ "## Current state",
1199
+ "What's in flight or recently shipped, distilled from project memories.",
1200
+ "",
1201
+ "## Open items",
1202
+ "Anything explicitly noted as pending, waiting, or unresolved.",
1203
+ "",
1204
+ "## Watch-outs",
1205
+ "Constraints, deadlines, or hidden gotchas captured in memory.",
1206
+ "",
1207
+ "## Reference material",
1208
+ "Links and external resources the next person should know about (from reference memories if they're relevant to the project).",
1209
+ "",
1210
+ "Cite source memories inline as `[memory-name]`. Keep prose dense; this is meant to be read by an experienced operator, not explained from scratch.",
1211
+ ].join("\n"),
1212
+ },
1213
+ },
1214
+ ],
1215
+ };
1216
+ }
1217
+ case "audit_stale":
1218
+ return {
1219
+ description: PROMPTS[3].description,
1220
+ messages: [
1221
+ {
1222
+ role: "user",
1223
+ content: {
1224
+ type: "text",
1225
+ text: [
1226
+ 'Call list_memories with type="project", then list_memories with type="reference". For each memory returned, evaluate whether its claims are likely still true based on:',
1227
+ "",
1228
+ "- Dates in the body (anything more than 30 days old in a `project` memory deserves scrutiny)",
1229
+ "- References to people, dependencies, or systems that may have changed",
1230
+ "- Claims about external state (URLs, dashboards, APIs) that you can't verify without external access",
1231
+ "",
1232
+ "Produce a triage list with three buckets:",
1233
+ "",
1234
+ "**Likely stale** (high confidence they're outdated) — explain why, suggest action.",
1235
+ "**Worth verifying** (claims you can't evaluate without external access) — suggest using the verify_memory tool.",
1236
+ "**Still fresh** (nothing in the content suggests staleness).",
1237
+ "",
1238
+ "Be conservative on the 'likely stale' bucket — false positives there create work for the operator.",
1239
+ ].join("\n"),
1240
+ },
1241
+ },
1242
+ ],
1243
+ };
1244
+ default:
1245
+ throw new Error(`Unknown prompt: ${name}`);
1246
+ }
1247
+ });
730
1248
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
731
1249
  tools: [
732
1250
  {
@@ -754,6 +1272,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
754
1272
  type: "string",
755
1273
  description: "Markdown body. For feedback/project, include **Why:** and **How to apply:** lines.",
756
1274
  },
1275
+ tags: {
1276
+ type: "array",
1277
+ items: { type: "string" },
1278
+ description: "Optional tags for cross-cutting categorization. Lowercase, kebab/underscore, max 40 chars each. Queryable in list_memories + search_memories.",
1279
+ },
757
1280
  },
758
1281
  required: ["name", "description", "type", "content"],
759
1282
  },
@@ -801,7 +1324,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
801
1324
  },
802
1325
  {
803
1326
  name: "list_memories",
804
- description: "List stored memories, optionally filtered by type. Paginated.",
1327
+ description: "List stored memories, optionally filtered by type and/or tags. Paginated.",
805
1328
  inputSchema: {
806
1329
  type: "object",
807
1330
  properties: {
@@ -810,6 +1333,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
810
1333
  enum: ["user", "feedback", "project", "reference"],
811
1334
  description: "Optional filter — only list memories of this type",
812
1335
  },
1336
+ tags: {
1337
+ type: "array",
1338
+ items: { type: "string" },
1339
+ description: "Optional tag filter — memories must have ALL listed tags (intersection). Can also be passed as a comma-separated string.",
1340
+ },
813
1341
  offset: { type: "number", description: "Skip this many results (default 0)." },
814
1342
  limit: { type: "number", description: "Max results per page (default 50)." },
815
1343
  },
@@ -869,6 +1397,43 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
869
1397
  },
870
1398
  },
871
1399
  },
1400
+ {
1401
+ name: "verify_memory",
1402
+ description: "Re-evaluate a memory's claims against signals in its content. Extracts URLs, dates, and file paths from the body; flags stale-date signals on project-type memories; returns type-specific verification heuristics for the LLM or operator to act on. Pairs with the audit_stale prompt.",
1403
+ inputSchema: {
1404
+ type: "object",
1405
+ properties: {
1406
+ name: { type: "string", description: "The memory's name slug" },
1407
+ },
1408
+ required: ["name"],
1409
+ },
1410
+ },
1411
+ {
1412
+ name: "find_backlinks",
1413
+ description: "List memories that link to the given memory via [[wiki-link]] syntax in their bodies. Useful for building a 'what references this' view.",
1414
+ inputSchema: {
1415
+ type: "object",
1416
+ properties: {
1417
+ name: { type: "string", description: "The memory's name slug" },
1418
+ },
1419
+ required: ["name"],
1420
+ },
1421
+ },
1422
+ {
1423
+ name: "find_related",
1424
+ description: "Surface memories related to a given one. Ranks by combining: outbound [[wiki-links]], inbound backlinks, shared tags, same type, and content similarity (name + description). Use this to navigate the memory graph by association rather than by exact lookup.",
1425
+ inputSchema: {
1426
+ type: "object",
1427
+ properties: {
1428
+ name: { type: "string", description: "The starting memory's name slug" },
1429
+ max: {
1430
+ type: "number",
1431
+ description: "Max related memories to return (default 8, capped at 20).",
1432
+ },
1433
+ },
1434
+ required: ["name"],
1435
+ },
1436
+ },
872
1437
  ],
873
1438
  }));
874
1439
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -906,6 +1471,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
906
1471
  case "log_events":
907
1472
  result = toolLogEvents(args);
908
1473
  break;
1474
+ case "verify_memory":
1475
+ result = toolVerifyMemory(args);
1476
+ break;
1477
+ case "find_backlinks":
1478
+ result = toolFindBacklinks(args);
1479
+ break;
1480
+ case "find_related":
1481
+ result = toolFindRelated(args);
1482
+ break;
909
1483
  default:
910
1484
  throw new Error(`Unknown tool: ${name}`);
911
1485
  }
@@ -938,6 +1512,9 @@ const CLI_COMMANDS = new Set([
938
1512
  "doctor",
939
1513
  "stats",
940
1514
  "log",
1515
+ "verify",
1516
+ "backlinks",
1517
+ "related",
941
1518
  "import-claude-code",
942
1519
  "help",
943
1520
  "--help",
@@ -1006,6 +1583,7 @@ async function cliMain(command, rest) {
1006
1583
  type: String(flags.type ?? "project"),
1007
1584
  description: String(flags.description ?? ""),
1008
1585
  content,
1586
+ tags: flags.tags,
1009
1587
  });
1010
1588
  process.stdout.write(result + "\n");
1011
1589
  return 0;
@@ -1040,6 +1618,7 @@ async function cliMain(command, rest) {
1040
1618
  case "list": {
1041
1619
  process.stdout.write(toolListMemories({
1042
1620
  type: flags.type,
1621
+ tags: flags.tags,
1043
1622
  offset: flags.offset ? Number(flags.offset) : undefined,
1044
1623
  limit: flags.limit ? Number(flags.limit) : undefined,
1045
1624
  }) + "\n");
@@ -1067,6 +1646,30 @@ async function cliMain(command, rest) {
1067
1646
  process.stdout.write(toolStats({}) + "\n");
1068
1647
  return 0;
1069
1648
  }
1649
+ case "verify": {
1650
+ const name = positional[0];
1651
+ if (!name)
1652
+ throw new Error("Usage: agent-memory verify <name>");
1653
+ process.stdout.write(toolVerifyMemory({ name }) + "\n");
1654
+ return 0;
1655
+ }
1656
+ case "backlinks": {
1657
+ const name = positional[0];
1658
+ if (!name)
1659
+ throw new Error("Usage: agent-memory backlinks <name>");
1660
+ process.stdout.write(toolFindBacklinks({ name }) + "\n");
1661
+ return 0;
1662
+ }
1663
+ case "related": {
1664
+ const name = positional[0];
1665
+ if (!name)
1666
+ throw new Error("Usage: agent-memory related <name> [--max N]");
1667
+ process.stdout.write(toolFindRelated({
1668
+ name,
1669
+ max: flags.max ? Number(flags.max) : undefined,
1670
+ }) + "\n");
1671
+ return 0;
1672
+ }
1070
1673
  case "log": {
1071
1674
  process.stdout.write(toolLogEvents({
1072
1675
  tail: flags.tail ? Number(flags.tail) : undefined,
@@ -1100,15 +1703,17 @@ USAGE
1100
1703
  agent-memory-mcp MCP server mode (default when no args)
1101
1704
 
1102
1705
  COMMANDS
1103
- save <name> --type <t> --description <d> --content <c>
1706
+ save <name> --type <t> --description <d> --content <c> [--tags "a,b,c"]
1104
1707
  Save or update a memory.
1105
1708
  Type: user | feedback | project | reference
1106
1709
  Content sources: --content "..." | --content-file <path> | --stdin
1710
+ Tags: comma-separated, lowercase, max 40 chars each.
1107
1711
  search <query> [--limit N] Fuzzy search (typo-tolerant), top N (default 10)
1108
1712
  relevant <query> [--max N] Top N matches as full markdown for LLM ingestion
1109
1713
  get <name> Print one memory's full contents
1110
- list [--type <t>] [--offset N] [--limit N]
1714
+ list [--type <t>] [--tags "a,b"] [--offset N] [--limit N]
1111
1715
  List memories (paginated, default limit 50)
1716
+ Tag filter requires ALL listed tags (intersection).
1112
1717
  delete <name> Soft-delete: move to .trash/, removable later
1113
1718
  restore <name> Restore the most recent trash entry for <name>
1114
1719
  doctor [--rebuild-index] Check storage integrity (orphans, dangling
@@ -1117,6 +1722,12 @@ COMMANDS
1117
1722
  stats Dashboard: counts per type, sizes, audit/trash counts.
1118
1723
  log [--tail N] [--action save|delete|restore]
1119
1724
  Recent audit-log entries.
1725
+ verify <name> Re-evaluate a memory's claims (URLs, dates, file refs,
1726
+ type-specific staleness heuristics). Static analysis;
1727
+ no network calls.
1728
+ backlinks <name> List memories that link to <name> via [[wiki-links]].
1729
+ related <name> [--max N] Surface related memories via outbound + inbound links,
1730
+ shared tags, type match, content similarity.
1120
1731
  import-claude-code [--source <path>] [--project <pat>] [--overwrite] [--dry-run]
1121
1732
  Walk ~/.claude/projects/*/memory/ and
1122
1733
  import each memory into the current store.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xultrax-web/agent-memory-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "mcpName": "io.github.xultrax-web/agent-memory-mcp",
5
5
  "description": "Markdown memory for AI agents. Plain files you can read, edit, grep, and commit. The only MCP memory server that isn't a database.",
6
6
  "type": "module",