codex-dev-mcp-suite 1.4.0 → 1.5.1

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/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 1.5.0
2
+
3
+ - add `memory_link` with wiki-link resolution for `[[id]]`, `[[title]]`, `[[project:id]]`, and `[[project:title]]`, plus backlink inspection
4
+ - add `memory_global_recall` with same-project bias and graceful keyword/semantic fallback across project vaults
5
+ - add `memory_dedup` with non-destructive duplicate suggestions for project or global scope
6
+ - add hybrid lazy graph backfill so link metadata is derived on first graph-aware use and remains rebuildable from canonical notes
7
+ - add graph-aware soft boost in recall paths without making embeddings mandatory
8
+ - 36 project-memory tests pass after graph/global/dedup coverage expansion
9
+
1
10
  # Changelog
2
11
 
3
12
  ## 1.4.0 - 2026-06-17
package/README.md CHANGED
@@ -66,7 +66,7 @@ Other clients (Claude Code, Cursor, Cline, ...): see
66
66
 
67
67
  | Server | What it does | Key tools |
68
68
  |---|---|---|
69
- | **project-memory** | Searchable Markdown knowledge vault (Obsidian-style notes + on-demand recall). Notes are also exposed as MCP resources. | `memory_save`, `memory_recall`, `memory_list`, `memory_get`, `memory_delete`, `memory_reindex` |
69
+ | **project-memory** | Searchable Markdown knowledge vault (Obsidian-style notes + on-demand recall). Notes are also exposed as MCP resources. | `memory_save`, `memory_recall`, `memory_list`, `memory_get`, `memory_delete`, `memory_reindex`, `memory_link`, `memory_global_recall`, `memory_dedup` |
70
70
  | **devjournal** | Per-project session timeline + handoff/resume (anti-compaction). | `journal_log`, `journal_handoff`, `journal_resume`, `journal_timeline`, `journal_search`, `journal_clear_handoff` |
71
71
  | **checkpoint** | Git-independent file snapshots for safe experimentation. | `checkpoint_create`, `checkpoint_list`, `checkpoint_diff`, `checkpoint_restore`, `checkpoint_delete` |
72
72
  | **context-pack** | Token-efficient project briefing (stack, tree, symbols, search). | `pack_overview`, `pack_tree`, `pack_outline`, `pack_search` |
@@ -192,6 +192,16 @@ With `MCP_DETERMINISTIC_FALLBACK=true`, the mode is always `[deterministic]`.
192
192
  This means you can run codex-dev-mcp-suite with **zero API keys configured** —
193
193
  `memory_recall` still works via keyword scoring, just no semantic similarity.
194
194
 
195
+ ### New in v1.5.0
196
+
197
+ `project-memory` now adds a lightweight knowledge-graph layer:
198
+
199
+ - `memory_link` resolves wiki-style links like `[[id]]`, `[[title]]`, and `[[project:title]]`, and shows backlinks
200
+ - `memory_global_recall` searches across projects with same-project bias and graceful keyword fallback
201
+ - `memory_dedup` suggests duplicate-note merges without deleting anything
202
+
203
+ Link resolution always prefers the active project first, then falls back globally when appropriate.
204
+
195
205
  ## Daily workflow
196
206
 
197
207
  - Session start: `pack_overview` + `journal_resume` + `memory_recall "<topic>"`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-dev-mcp-suite",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Four local, file-based MCP servers for solo devs/vibecoders: persistent project memory, session handoff/resume, git-independent file checkpoints, and token-efficient project briefings. Works with any MCP client.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,49 @@
1
+ function jaccard(left, right) {
2
+ const leftSet = new Set(left.filter(Boolean));
3
+ const rightSet = new Set(right.filter(Boolean));
4
+ const intersection = [...leftSet].filter((item) => rightSet.has(item)).length;
5
+ const union = new Set([...leftSet, ...rightSet]).size || 1;
6
+ return intersection / union;
7
+ }
8
+
9
+ function tokenizeBody(text) {
10
+ return String(text || "").toLowerCase().split(/\W+/).filter(Boolean);
11
+ }
12
+
13
+ export function scoreDuplicatePair({ a, b, aBody, bBody }) {
14
+ const reasons = [];
15
+ let score = 0;
16
+
17
+ if (String(a.title || "").trim().toLowerCase() === String(b.title || "").trim().toLowerCase()) {
18
+ score += 0.5;
19
+ reasons.push("exact normalized title match");
20
+ }
21
+
22
+ const bodyScore = jaccard(tokenizeBody(aBody), tokenizeBody(bBody));
23
+ score += bodyScore * 0.4;
24
+ if (bodyScore >= 0.75) reasons.push("strong content overlap");
25
+
26
+ const linkScore = jaccard((a.links || []).map((link) => `${link.project || ""}:${link.ref || ""}`), (b.links || []).map((link) => `${link.project || ""}:${link.ref || ""}`));
27
+ score += linkScore * 0.1;
28
+ if (linkScore >= 0.75 && (a.links || []).length && (b.links || []).length) reasons.push("similar link neighborhood");
29
+
30
+ return { score: Math.min(1, score), reasons };
31
+ }
32
+
33
+ export function findDuplicateCandidates({ rows, threshold }) {
34
+ const out = [];
35
+ for (let i = 0; i < rows.length; i += 1) {
36
+ for (let j = i + 1; j < rows.length; j += 1) {
37
+ const result = scoreDuplicatePair({
38
+ a: rows[i].note,
39
+ b: rows[j].note,
40
+ aBody: rows[i].body,
41
+ bBody: rows[j].body,
42
+ });
43
+ if (result.score >= threshold) {
44
+ out.push({ left: rows[i], right: rows[j], score: result.score, reasons: result.reasons });
45
+ }
46
+ }
47
+ }
48
+ return out.sort((a, b) => b.score - a.score);
49
+ }
@@ -0,0 +1,46 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+
4
+ export async function listProjects(vaultRoot) {
5
+ let slugs = [];
6
+ try {
7
+ slugs = await fs.readdir(vaultRoot);
8
+ } catch {
9
+ slugs = [];
10
+ }
11
+ return slugs.map((slug) => ({
12
+ slug,
13
+ projectDir: path.join(vaultRoot, slug),
14
+ indexFile: path.join(vaultRoot, slug, "index.json"),
15
+ }));
16
+ }
17
+
18
+ export async function loadGlobalNotes(vaultRoot) {
19
+ const projects = await listProjects(vaultRoot);
20
+ const rows = [];
21
+ for (const project of projects) {
22
+ try {
23
+ const raw = await fs.readFile(project.indexFile, "utf8");
24
+ const index = JSON.parse(raw);
25
+ for (const note of Object.values(index.notes || {})) rows.push({ slug: project.slug, note });
26
+ } catch {
27
+ // ignore invalid or incomplete project index
28
+ }
29
+ }
30
+ return rows;
31
+ }
32
+
33
+ export async function findGlobalCandidates({ vaultRoot, currentSlug, query }) {
34
+ const q = String(query || "").toLowerCase();
35
+ const rows = await loadGlobalNotes(vaultRoot);
36
+ return rows
37
+ .filter(({ note }) =>
38
+ String(note.title || "").toLowerCase().includes(q) ||
39
+ (note.keywords || []).some((keyword) => String(keyword).toLowerCase().includes(q))
40
+ )
41
+ .sort((a, b) => {
42
+ const ap = a.slug === currentSlug ? 0 : 1;
43
+ const bp = b.slug === currentSlug ? 0 : 1;
44
+ return ap - bp || (a.note.created < b.note.created ? 1 : -1);
45
+ });
46
+ }
@@ -0,0 +1,86 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { parseFrontmatter } from "./server.js";
4
+
5
+ export function extractWikiLinks(body) {
6
+ const out = [];
7
+ const re = /\[\[([^\]]+)\]\]/g;
8
+ let match;
9
+ while ((match = re.exec(String(body || "")))) {
10
+ const raw = match[0];
11
+ const inner = match[1].trim();
12
+ const colon = inner.indexOf(":");
13
+ const project = colon > 0 ? inner.slice(0, colon).trim() : null;
14
+ const ref = colon > 0 ? inner.slice(colon + 1).trim() : inner;
15
+ const kind = /^[0-9A-Za-z_-]{6,}$/.test(ref) && !/\s/.test(ref) ? "id" : "title";
16
+ out.push({ raw, ref, project, kind });
17
+ }
18
+ return out;
19
+ }
20
+
21
+ export async function loadNoteBody(projectDir, file) {
22
+ const raw = await fs.readFile(path.join(projectDir, file), "utf8");
23
+ return parseFrontmatter(raw).body;
24
+ }
25
+
26
+ export async function ensureGraphState({ vaultRoot, projectDir, slug, index, noteLoader }) {
27
+ void vaultRoot;
28
+ void slug;
29
+ let changed = false;
30
+ const backlinks = new Map();
31
+ for (const note of Object.values(index.notes || {})) {
32
+ const body = await noteLoader(projectDir, note.file);
33
+ const links = extractWikiLinks(body).map((link) => ({
34
+ raw: link.raw,
35
+ ref: link.ref,
36
+ project: link.project,
37
+ kind: link.kind,
38
+ }));
39
+ const next = JSON.stringify(links);
40
+ const prev = JSON.stringify(note.links || []);
41
+ if (next !== prev) {
42
+ note.links = links;
43
+ changed = true;
44
+ }
45
+ }
46
+ for (const note of Object.values(index.notes || {})) {
47
+ for (const link of note.links || []) {
48
+ if (link.project || link.kind !== "id") continue;
49
+ const arr = backlinks.get(link.ref) || [];
50
+ arr.push({ id: note.id, title: note.title });
51
+ backlinks.set(link.ref, arr);
52
+ }
53
+ }
54
+ for (const note of Object.values(index.notes || {})) {
55
+ const next = backlinks.get(note.id) || [];
56
+ if (JSON.stringify(next) !== JSON.stringify(note.backlinks || [])) {
57
+ note.backlinks = next;
58
+ changed = true;
59
+ }
60
+ }
61
+ return { index, changed };
62
+ }
63
+
64
+ export async function resolveLink({ vaultRoot, currentSlug, ref, project, kind }) {
65
+ const targetSlug = project || currentSlug;
66
+ const indexFile = path.join(vaultRoot, targetSlug, "index.json");
67
+ let index;
68
+ try {
69
+ index = JSON.parse(await fs.readFile(indexFile, "utf8"));
70
+ } catch {
71
+ return { status: "missing" };
72
+ }
73
+
74
+ const notes = Object.values(index.notes || {});
75
+ let candidates;
76
+ if (kind === "id") {
77
+ candidates = notes.filter((note) => note.id === ref);
78
+ } else {
79
+ const target = String(ref || "").trim().toLowerCase();
80
+ candidates = notes.filter((note) => String(note.title || "").trim().toLowerCase() === target);
81
+ }
82
+
83
+ if (candidates.length === 0) return { status: "missing" };
84
+ if (candidates.length === 1) return { status: "resolved", match: candidates[0] };
85
+ return { status: "ambiguous", candidates };
86
+ }
@@ -20,15 +20,20 @@ import {
20
20
  CallToolRequestSchema,
21
21
  ListToolsRequestSchema,
22
22
  ListResourcesRequestSchema,
23
+ ListResourceTemplatesRequestSchema,
23
24
  ReadResourceRequestSchema,
24
25
  } from "@modelcontextprotocol/sdk/types.js";
25
26
  import fs from "fs/promises";
26
27
  import path from "path";
27
28
  import os from "os";
28
29
  import crypto from "crypto";
30
+ import { fileURLToPath } from "url";
29
31
  import { embed, embedOne, cosine, embeddingConfig } from "./embedding.js";
30
32
  import { rerank, rerankConfig } from "./rerank.js";
31
33
  import { deterministicEnabled } from "./env.js";
34
+ import { ensureGraphState, resolveLink, loadNoteBody } from "./graph.js";
35
+ import { loadGlobalNotes } from "./global-index.js";
36
+ import { findDuplicateCandidates } from "./dedup.js";
32
37
  import { computeStats, formatText, formatJson } from "../lib/stats.js";
33
38
 
34
39
  const VAULT_ROOT =
@@ -44,7 +49,7 @@ const STOPWORDS = new Set([
44
49
  "not", "no", "do", "did", "does", "can", "will", "so", "my", "our", "your",
45
50
  ]);
46
51
 
47
- function projectSlug(dir) {
52
+ export function projectSlug(dir) {
48
53
  const resolved = path.resolve(dir || process.cwd());
49
54
  const base = path.basename(resolved) || "root";
50
55
  const hash = crypto.createHash("sha1").update(resolved).digest("hex").slice(0, 8);
@@ -76,7 +81,7 @@ function limit(value, name, max, required = true) {
76
81
  return text;
77
82
  }
78
83
 
79
- function parseFrontmatter(raw) {
84
+ export function parseFrontmatter(raw) {
80
85
  const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
81
86
  if (!m) return { meta: {}, body: raw };
82
87
  const meta = {};
@@ -105,6 +110,16 @@ function buildFrontmatter(meta) {
105
110
  return lines.join("\n");
106
111
  }
107
112
 
113
+ function graphBoost(note, queryTokens) {
114
+ let score = 0;
115
+ for (const link of note.links || []) {
116
+ for (const token of queryTokens) {
117
+ if (String(link.ref || "").toLowerCase().includes(token)) score += 0.05;
118
+ }
119
+ }
120
+ return Math.min(0.15, score);
121
+ }
122
+
108
123
  class ProjectMemoryServer {
109
124
  constructor() {
110
125
  this.server = new Server(
@@ -222,6 +237,45 @@ class ProjectMemoryServer {
222
237
  },
223
238
  },
224
239
  },
240
+ {
241
+ name: "memory_link",
242
+ description: "Resolve wiki-style note links and show backlinks for a note.",
243
+ inputSchema: {
244
+ type: "object",
245
+ properties: {
246
+ id: { type: "string", description: "Note id to inspect" },
247
+ dir: { type: "string", description: "Project directory (defaults to CWD)" },
248
+ include_unresolved: { type: "boolean", description: "Include unresolved links in output", default: true },
249
+ },
250
+ required: ["id"],
251
+ },
252
+ },
253
+ {
254
+ name: "memory_global_recall",
255
+ description: "Recall relevant notes across projects with same-project bias and graceful keyword fallback.",
256
+ inputSchema: {
257
+ type: "object",
258
+ properties: {
259
+ query: { type: "string", description: "What you need context about" },
260
+ dir: { type: "string", description: "Project directory (defaults to CWD)" },
261
+ limit: { type: "number", description: "Max notes to return", default: 5 },
262
+ full: { type: "boolean", description: "Return full bodies instead of excerpts", default: false },
263
+ },
264
+ required: ["query"],
265
+ },
266
+ },
267
+ {
268
+ name: "memory_dedup",
269
+ description: "Find likely duplicate notes and suggest non-destructive merges.",
270
+ inputSchema: {
271
+ type: "object",
272
+ properties: {
273
+ dir: { type: "string", description: "Project directory (defaults to CWD)" },
274
+ threshold: { type: "number", description: "Duplicate threshold", default: 0.9 },
275
+ scope: { type: "string", enum: ["project", "global"], description: "Dedup scope", default: "project" },
276
+ },
277
+ },
278
+ },
225
279
  {
226
280
  name: "memory_stats",
227
281
  description: "Summarize local memory storage across vault / journal / checkpoints: totals, top projects, recent activity, and temp-slug cleanup candidates. Returns the same text as the `stats` CLI.",
@@ -247,6 +301,9 @@ class ProjectMemoryServer {
247
301
  case "memory_get": return await this.get(args || {});
248
302
  case "memory_delete": return await this.del(args || {});
249
303
  case "memory_reindex": return await this.reindex(args || {});
304
+ case "memory_link": return await this.link(args || {});
305
+ case "memory_global_recall": return await this.globalRecall(args || {});
306
+ case "memory_dedup": return await this.dedup(args || {});
250
307
  case "memory_stats": return await this.stats(args || {});
251
308
  default: throw new Error(`Unknown tool: ${name}`);
252
309
  }
@@ -276,6 +333,8 @@ class ProjectMemoryServer {
276
333
  return { resources };
277
334
  });
278
335
 
336
+ this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
337
+
279
338
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
280
339
  const uri = request.params.uri || "";
281
340
  const m = uri.match(/^memory:\/\/([^/]+)\/(.+)$/);
@@ -321,7 +380,15 @@ class ProjectMemoryServer {
321
380
  query = limit(query, "query", 2000);
322
381
  const p = this.paths(dir);
323
382
  const index = await this.loadIndex(p);
324
- const notes = Object.values(index.notes || {});
383
+ const ensured = await ensureGraphState({
384
+ vaultRoot: VAULT_ROOT,
385
+ projectDir: p.projectDir,
386
+ slug: p.slug,
387
+ index,
388
+ noteLoader: loadNoteBody,
389
+ });
390
+ if (ensured.changed) await this.saveIndex(p, ensured.index);
391
+ const notes = Object.values(ensured.index.notes || {});
325
392
  if (notes.length === 0) {
326
393
  return { content: [{ type: "text", text: `No memories for ${p.slug} yet. Use memory_save first.` }] };
327
394
  }
@@ -352,14 +419,14 @@ class ProjectMemoryServer {
352
419
  scored = notes.map((n) => {
353
420
  const sim = Array.isArray(n.embedding) ? cosine(qVec, n.embedding) : 0;
354
421
  const kwNorm = kw(n) / maxKw;
355
- const score = 0.8 * sim + 0.2 * kwNorm;
422
+ const score = 0.8 * sim + 0.2 * kwNorm + graphBoost(n, [...qTokens]);
356
423
  return { n, score, sim };
357
424
  }).filter((x) => x.score > 0.05)
358
425
  .sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1))
359
426
  .slice(0, Math.max(1, Math.min(20, lim)));
360
427
  } else {
361
428
  const want = Math.max(1, Math.min(20, lim));
362
- const kwRanked = notes.map((n) => ({ n, score: kw(n) }))
429
+ const kwRanked = notes.map((n) => ({ n, score: kw(n) + graphBoost(n, [...qTokens]) }))
363
430
  .filter((x) => x.score > 0)
364
431
  .sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1));
365
432
 
@@ -411,6 +478,70 @@ class ProjectMemoryServer {
411
478
  return { content: [{ type: "text", text: `Recall for "${query}" in ${p.slug} [${displayMode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
412
479
  }
413
480
 
481
+ async dedup({ dir, threshold = 0.9, scope = "project" }) {
482
+ const p = this.paths(dir);
483
+ const rows = await loadGlobalNotes(VAULT_ROOT);
484
+ const filtered = scope === "project" ? rows.filter((row) => row.slug === p.slug) : rows;
485
+ const withBodies = await Promise.all(filtered.map(async ({ slug, note }) => {
486
+ const raw = await fs.readFile(path.join(VAULT_ROOT, slug, note.file), "utf8").catch(() => "");
487
+ const { body } = parseFrontmatter(raw);
488
+ return { slug, note, body };
489
+ }));
490
+ const pairs = findDuplicateCandidates({ rows: withBodies, threshold });
491
+ if (!pairs.length) {
492
+ return { content: [{ type: "text", text: `No duplicate suggestions above ${threshold} in ${scope} scope.` }] };
493
+ }
494
+ const lines = [`Duplicate suggestions [threshold=${threshold}, scope=${scope}]:`, ""];
495
+ for (const pair of pairs) {
496
+ lines.push(`- suggested merge: [${pair.left.slug}] ${pair.left.note.title} <-> [${pair.right.slug}] ${pair.right.note.title} (score=${pair.score.toFixed(3)})`);
497
+ lines.push(` reasons: ${pair.reasons.join(", ")}`);
498
+ }
499
+ return { content: [{ type: "text", text: lines.join("\n") }] };
500
+ }
501
+
502
+ async globalRecall({ query, dir, limit: lim = 5, full = false }) {
503
+ query = limit(query, "query", 2000);
504
+ const p = this.paths(dir);
505
+ const currentIndex = await this.loadIndex(p);
506
+ const ensured = await ensureGraphState({
507
+ vaultRoot: VAULT_ROOT,
508
+ projectDir: p.projectDir,
509
+ slug: p.slug,
510
+ index: currentIndex,
511
+ noteLoader: loadNoteBody,
512
+ });
513
+ if (ensured.changed) await this.saveIndex(p, ensured.index);
514
+ const rows = await loadGlobalNotes(VAULT_ROOT);
515
+ const qTokens = new Set(tokenize(query));
516
+ const qVec = deterministicEnabled() ? null : await embedOne(query);
517
+ const haveEmbeddings = !!(qVec && rows.some((row) => Array.isArray(row.note.embedding)));
518
+
519
+ const scored = rows.map(({ slug, note }) => {
520
+ let kwScore = 0;
521
+ for (const keyword of note.keywords || []) if (qTokens.has(keyword)) kwScore += 1;
522
+ for (const word of tokenize(note.title)) if (qTokens.has(word)) kwScore += 2;
523
+ const sameProjectBoost = slug === p.slug ? 0.25 : 0;
524
+ const sim = haveEmbeddings && Array.isArray(note.embedding) ? cosine(qVec, note.embedding) : 0;
525
+ const score = (haveEmbeddings ? (0.8 * sim) : 0) + (0.2 * kwScore) + sameProjectBoost + graphBoost(note, [...qTokens]);
526
+ return { slug, note, sim, score };
527
+ }).filter((entry) => entry.score > 0)
528
+ .sort((a, b) => b.score - a.score || (a.note.created < b.note.created ? 1 : -1))
529
+ .slice(0, Math.max(1, Math.min(20, lim)));
530
+
531
+ if (scored.length === 0) {
532
+ return { content: [{ type: "text", text: `No relevant global memories for "${query}". Try memory_list or memory_recall.` }] };
533
+ }
534
+
535
+ const mode = haveEmbeddings ? "semantic+graph" : "keyword+graph";
536
+ const blocks = await Promise.all(scored.map(async ({ slug, note, sim, score }) => {
537
+ const raw = await fs.readFile(path.join(VAULT_ROOT, slug, note.file), "utf8").catch(() => "");
538
+ const { body } = parseFrontmatter(raw);
539
+ const text = full ? body.trim() : body.trim().split("\n").slice(0, 12).join("\n");
540
+ return `### ${note.title} ([${slug}], ${haveEmbeddings ? `sim:${sim.toFixed(3)}` : `score:${score.toFixed(2)}`})\n${text}`;
541
+ }));
542
+ return { content: [{ type: "text", text: `Global recall for "${query}" [${mode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
543
+ }
544
+
414
545
  async list({ dir, limit: lim = 20 }) {
415
546
  const p = this.paths(dir);
416
547
  const index = await this.loadIndex(p);
@@ -444,6 +575,47 @@ class ProjectMemoryServer {
444
575
  return { content: [{ type: "text", text: `Deleted note ${id} from ${p.slug}` }] };
445
576
  }
446
577
 
578
+ async link({ id, dir, include_unresolved = true }) {
579
+ id = limit(id, "id", 100);
580
+ const p = this.paths(dir);
581
+ const index = await this.loadIndex(p);
582
+ const note = index.notes?.[id];
583
+ if (!note) throw new Error(`Note ${id} not found in ${p.slug}`);
584
+
585
+ const ensured = await ensureGraphState({
586
+ vaultRoot: VAULT_ROOT,
587
+ projectDir: p.projectDir,
588
+ slug: p.slug,
589
+ index,
590
+ noteLoader: loadNoteBody,
591
+ });
592
+ if (ensured.changed) await this.saveIndex(p, ensured.index);
593
+
594
+ const current = ensured.index.notes[id];
595
+ const links = [];
596
+ for (const link of current.links || []) {
597
+ const resolved = await resolveLink({
598
+ vaultRoot: VAULT_ROOT,
599
+ currentSlug: p.slug,
600
+ ref: link.ref,
601
+ project: link.project,
602
+ kind: link.kind,
603
+ });
604
+ if (resolved.status !== "missing" || include_unresolved) links.push({ link, resolved });
605
+ }
606
+
607
+ const lines = [
608
+ `Links for ${current.title} (${current.id})`,
609
+ "",
610
+ "Outgoing:",
611
+ ...links.map(({ link, resolved }) => `- ${link.raw} -> ${resolved.status}${resolved.match ? ` (${resolved.match.title})` : ""}`),
612
+ "",
613
+ "Backlinks:",
614
+ ...((current.backlinks || []).length ? current.backlinks.map((b) => `- ${b.title} (${b.id})`) : ["- none"]),
615
+ ];
616
+ return { content: [{ type: "text", text: lines.join("\n") }] };
617
+ }
618
+
447
619
  async reindex({ dir, force = false }) {
448
620
  const p = this.paths(dir);
449
621
  if (deterministicEnabled()) return { content: [{ type: "text", text: `Reindex ${p.slug}: skipped (deterministic no-network mode; embeddings disabled).` }] };
@@ -488,5 +660,9 @@ class ProjectMemoryServer {
488
660
  }
489
661
  }
490
662
 
491
- const server = new ProjectMemoryServer();
492
- server.run().catch(console.error);
663
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
664
+ const server = new ProjectMemoryServer();
665
+ server.run().catch(console.error);
666
+ }
667
+
668
+ export { ProjectMemoryServer };