codex-dev-mcp-suite 1.4.0 → 1.5.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/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.0",
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
+ }
@@ -26,9 +26,13 @@ import fs from "fs/promises";
26
26
  import path from "path";
27
27
  import os from "os";
28
28
  import crypto from "crypto";
29
+ import { fileURLToPath } from "url";
29
30
  import { embed, embedOne, cosine, embeddingConfig } from "./embedding.js";
30
31
  import { rerank, rerankConfig } from "./rerank.js";
31
32
  import { deterministicEnabled } from "./env.js";
33
+ import { ensureGraphState, resolveLink, loadNoteBody } from "./graph.js";
34
+ import { loadGlobalNotes } from "./global-index.js";
35
+ import { findDuplicateCandidates } from "./dedup.js";
32
36
  import { computeStats, formatText, formatJson } from "../lib/stats.js";
33
37
 
34
38
  const VAULT_ROOT =
@@ -44,7 +48,7 @@ const STOPWORDS = new Set([
44
48
  "not", "no", "do", "did", "does", "can", "will", "so", "my", "our", "your",
45
49
  ]);
46
50
 
47
- function projectSlug(dir) {
51
+ export function projectSlug(dir) {
48
52
  const resolved = path.resolve(dir || process.cwd());
49
53
  const base = path.basename(resolved) || "root";
50
54
  const hash = crypto.createHash("sha1").update(resolved).digest("hex").slice(0, 8);
@@ -76,7 +80,7 @@ function limit(value, name, max, required = true) {
76
80
  return text;
77
81
  }
78
82
 
79
- function parseFrontmatter(raw) {
83
+ export function parseFrontmatter(raw) {
80
84
  const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
81
85
  if (!m) return { meta: {}, body: raw };
82
86
  const meta = {};
@@ -105,6 +109,16 @@ function buildFrontmatter(meta) {
105
109
  return lines.join("\n");
106
110
  }
107
111
 
112
+ function graphBoost(note, queryTokens) {
113
+ let score = 0;
114
+ for (const link of note.links || []) {
115
+ for (const token of queryTokens) {
116
+ if (String(link.ref || "").toLowerCase().includes(token)) score += 0.05;
117
+ }
118
+ }
119
+ return Math.min(0.15, score);
120
+ }
121
+
108
122
  class ProjectMemoryServer {
109
123
  constructor() {
110
124
  this.server = new Server(
@@ -222,6 +236,45 @@ class ProjectMemoryServer {
222
236
  },
223
237
  },
224
238
  },
239
+ {
240
+ name: "memory_link",
241
+ description: "Resolve wiki-style note links and show backlinks for a note.",
242
+ inputSchema: {
243
+ type: "object",
244
+ properties: {
245
+ id: { type: "string", description: "Note id to inspect" },
246
+ dir: { type: "string", description: "Project directory (defaults to CWD)" },
247
+ include_unresolved: { type: "boolean", description: "Include unresolved links in output", default: true },
248
+ },
249
+ required: ["id"],
250
+ },
251
+ },
252
+ {
253
+ name: "memory_global_recall",
254
+ description: "Recall relevant notes across projects with same-project bias and graceful keyword fallback.",
255
+ inputSchema: {
256
+ type: "object",
257
+ properties: {
258
+ query: { type: "string", description: "What you need context about" },
259
+ dir: { type: "string", description: "Project directory (defaults to CWD)" },
260
+ limit: { type: "number", description: "Max notes to return", default: 5 },
261
+ full: { type: "boolean", description: "Return full bodies instead of excerpts", default: false },
262
+ },
263
+ required: ["query"],
264
+ },
265
+ },
266
+ {
267
+ name: "memory_dedup",
268
+ description: "Find likely duplicate notes and suggest non-destructive merges.",
269
+ inputSchema: {
270
+ type: "object",
271
+ properties: {
272
+ dir: { type: "string", description: "Project directory (defaults to CWD)" },
273
+ threshold: { type: "number", description: "Duplicate threshold", default: 0.9 },
274
+ scope: { type: "string", enum: ["project", "global"], description: "Dedup scope", default: "project" },
275
+ },
276
+ },
277
+ },
225
278
  {
226
279
  name: "memory_stats",
227
280
  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 +300,9 @@ class ProjectMemoryServer {
247
300
  case "memory_get": return await this.get(args || {});
248
301
  case "memory_delete": return await this.del(args || {});
249
302
  case "memory_reindex": return await this.reindex(args || {});
303
+ case "memory_link": return await this.link(args || {});
304
+ case "memory_global_recall": return await this.globalRecall(args || {});
305
+ case "memory_dedup": return await this.dedup(args || {});
250
306
  case "memory_stats": return await this.stats(args || {});
251
307
  default: throw new Error(`Unknown tool: ${name}`);
252
308
  }
@@ -321,7 +377,15 @@ class ProjectMemoryServer {
321
377
  query = limit(query, "query", 2000);
322
378
  const p = this.paths(dir);
323
379
  const index = await this.loadIndex(p);
324
- const notes = Object.values(index.notes || {});
380
+ const ensured = await ensureGraphState({
381
+ vaultRoot: VAULT_ROOT,
382
+ projectDir: p.projectDir,
383
+ slug: p.slug,
384
+ index,
385
+ noteLoader: loadNoteBody,
386
+ });
387
+ if (ensured.changed) await this.saveIndex(p, ensured.index);
388
+ const notes = Object.values(ensured.index.notes || {});
325
389
  if (notes.length === 0) {
326
390
  return { content: [{ type: "text", text: `No memories for ${p.slug} yet. Use memory_save first.` }] };
327
391
  }
@@ -352,14 +416,14 @@ class ProjectMemoryServer {
352
416
  scored = notes.map((n) => {
353
417
  const sim = Array.isArray(n.embedding) ? cosine(qVec, n.embedding) : 0;
354
418
  const kwNorm = kw(n) / maxKw;
355
- const score = 0.8 * sim + 0.2 * kwNorm;
419
+ const score = 0.8 * sim + 0.2 * kwNorm + graphBoost(n, [...qTokens]);
356
420
  return { n, score, sim };
357
421
  }).filter((x) => x.score > 0.05)
358
422
  .sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1))
359
423
  .slice(0, Math.max(1, Math.min(20, lim)));
360
424
  } else {
361
425
  const want = Math.max(1, Math.min(20, lim));
362
- const kwRanked = notes.map((n) => ({ n, score: kw(n) }))
426
+ const kwRanked = notes.map((n) => ({ n, score: kw(n) + graphBoost(n, [...qTokens]) }))
363
427
  .filter((x) => x.score > 0)
364
428
  .sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1));
365
429
 
@@ -411,6 +475,70 @@ class ProjectMemoryServer {
411
475
  return { content: [{ type: "text", text: `Recall for "${query}" in ${p.slug} [${displayMode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
412
476
  }
413
477
 
478
+ async dedup({ dir, threshold = 0.9, scope = "project" }) {
479
+ const p = this.paths(dir);
480
+ const rows = await loadGlobalNotes(VAULT_ROOT);
481
+ const filtered = scope === "project" ? rows.filter((row) => row.slug === p.slug) : rows;
482
+ const withBodies = await Promise.all(filtered.map(async ({ slug, note }) => {
483
+ const raw = await fs.readFile(path.join(VAULT_ROOT, slug, note.file), "utf8").catch(() => "");
484
+ const { body } = parseFrontmatter(raw);
485
+ return { slug, note, body };
486
+ }));
487
+ const pairs = findDuplicateCandidates({ rows: withBodies, threshold });
488
+ if (!pairs.length) {
489
+ return { content: [{ type: "text", text: `No duplicate suggestions above ${threshold} in ${scope} scope.` }] };
490
+ }
491
+ const lines = [`Duplicate suggestions [threshold=${threshold}, scope=${scope}]:`, ""];
492
+ for (const pair of pairs) {
493
+ lines.push(`- suggested merge: [${pair.left.slug}] ${pair.left.note.title} <-> [${pair.right.slug}] ${pair.right.note.title} (score=${pair.score.toFixed(3)})`);
494
+ lines.push(` reasons: ${pair.reasons.join(", ")}`);
495
+ }
496
+ return { content: [{ type: "text", text: lines.join("\n") }] };
497
+ }
498
+
499
+ async globalRecall({ query, dir, limit: lim = 5, full = false }) {
500
+ query = limit(query, "query", 2000);
501
+ const p = this.paths(dir);
502
+ const currentIndex = await this.loadIndex(p);
503
+ const ensured = await ensureGraphState({
504
+ vaultRoot: VAULT_ROOT,
505
+ projectDir: p.projectDir,
506
+ slug: p.slug,
507
+ index: currentIndex,
508
+ noteLoader: loadNoteBody,
509
+ });
510
+ if (ensured.changed) await this.saveIndex(p, ensured.index);
511
+ const rows = await loadGlobalNotes(VAULT_ROOT);
512
+ const qTokens = new Set(tokenize(query));
513
+ const qVec = deterministicEnabled() ? null : await embedOne(query);
514
+ const haveEmbeddings = !!(qVec && rows.some((row) => Array.isArray(row.note.embedding)));
515
+
516
+ const scored = rows.map(({ slug, note }) => {
517
+ let kwScore = 0;
518
+ for (const keyword of note.keywords || []) if (qTokens.has(keyword)) kwScore += 1;
519
+ for (const word of tokenize(note.title)) if (qTokens.has(word)) kwScore += 2;
520
+ const sameProjectBoost = slug === p.slug ? 0.25 : 0;
521
+ const sim = haveEmbeddings && Array.isArray(note.embedding) ? cosine(qVec, note.embedding) : 0;
522
+ const score = (haveEmbeddings ? (0.8 * sim) : 0) + (0.2 * kwScore) + sameProjectBoost + graphBoost(note, [...qTokens]);
523
+ return { slug, note, sim, score };
524
+ }).filter((entry) => entry.score > 0)
525
+ .sort((a, b) => b.score - a.score || (a.note.created < b.note.created ? 1 : -1))
526
+ .slice(0, Math.max(1, Math.min(20, lim)));
527
+
528
+ if (scored.length === 0) {
529
+ return { content: [{ type: "text", text: `No relevant global memories for "${query}". Try memory_list or memory_recall.` }] };
530
+ }
531
+
532
+ const mode = haveEmbeddings ? "semantic+graph" : "keyword+graph";
533
+ const blocks = await Promise.all(scored.map(async ({ slug, note, sim, score }) => {
534
+ const raw = await fs.readFile(path.join(VAULT_ROOT, slug, note.file), "utf8").catch(() => "");
535
+ const { body } = parseFrontmatter(raw);
536
+ const text = full ? body.trim() : body.trim().split("\n").slice(0, 12).join("\n");
537
+ return `### ${note.title} ([${slug}], ${haveEmbeddings ? `sim:${sim.toFixed(3)}` : `score:${score.toFixed(2)}`})\n${text}`;
538
+ }));
539
+ return { content: [{ type: "text", text: `Global recall for "${query}" [${mode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
540
+ }
541
+
414
542
  async list({ dir, limit: lim = 20 }) {
415
543
  const p = this.paths(dir);
416
544
  const index = await this.loadIndex(p);
@@ -444,6 +572,47 @@ class ProjectMemoryServer {
444
572
  return { content: [{ type: "text", text: `Deleted note ${id} from ${p.slug}` }] };
445
573
  }
446
574
 
575
+ async link({ id, dir, include_unresolved = true }) {
576
+ id = limit(id, "id", 100);
577
+ const p = this.paths(dir);
578
+ const index = await this.loadIndex(p);
579
+ const note = index.notes?.[id];
580
+ if (!note) throw new Error(`Note ${id} not found in ${p.slug}`);
581
+
582
+ const ensured = await ensureGraphState({
583
+ vaultRoot: VAULT_ROOT,
584
+ projectDir: p.projectDir,
585
+ slug: p.slug,
586
+ index,
587
+ noteLoader: loadNoteBody,
588
+ });
589
+ if (ensured.changed) await this.saveIndex(p, ensured.index);
590
+
591
+ const current = ensured.index.notes[id];
592
+ const links = [];
593
+ for (const link of current.links || []) {
594
+ const resolved = await resolveLink({
595
+ vaultRoot: VAULT_ROOT,
596
+ currentSlug: p.slug,
597
+ ref: link.ref,
598
+ project: link.project,
599
+ kind: link.kind,
600
+ });
601
+ if (resolved.status !== "missing" || include_unresolved) links.push({ link, resolved });
602
+ }
603
+
604
+ const lines = [
605
+ `Links for ${current.title} (${current.id})`,
606
+ "",
607
+ "Outgoing:",
608
+ ...links.map(({ link, resolved }) => `- ${link.raw} -> ${resolved.status}${resolved.match ? ` (${resolved.match.title})` : ""}`),
609
+ "",
610
+ "Backlinks:",
611
+ ...((current.backlinks || []).length ? current.backlinks.map((b) => `- ${b.title} (${b.id})`) : ["- none"]),
612
+ ];
613
+ return { content: [{ type: "text", text: lines.join("\n") }] };
614
+ }
615
+
447
616
  async reindex({ dir, force = false }) {
448
617
  const p = this.paths(dir);
449
618
  if (deterministicEnabled()) return { content: [{ type: "text", text: `Reindex ${p.slug}: skipped (deterministic no-network mode; embeddings disabled).` }] };
@@ -488,5 +657,9 @@ class ProjectMemoryServer {
488
657
  }
489
658
  }
490
659
 
491
- const server = new ProjectMemoryServer();
492
- server.run().catch(console.error);
660
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
661
+ const server = new ProjectMemoryServer();
662
+ server.run().catch(console.error);
663
+ }
664
+
665
+ export { ProjectMemoryServer };