codex-dev-mcp-suite 1.5.1 → 1.7.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.
@@ -38,7 +38,7 @@ import { computeStats, formatText, formatJson } from "../lib/stats.js";
38
38
 
39
39
  const VAULT_ROOT =
40
40
  process.env.MEMORY_VAULT_DIR ||
41
- path.join(os.homedir(), ".codex", "memories", "vault");
41
+ path.join(os.homedir(), ".ai-shared-memory", "vault");
42
42
 
43
43
  const MAX_CONTENT = 200_000;
44
44
  const MAX_TITLE = 200;
@@ -155,6 +155,103 @@ class ProjectMemoryServer {
155
155
  await fs.writeFile(p.indexFile, JSON.stringify(index, null, 2));
156
156
  }
157
157
 
158
+ // Ensure an Obsidian-friendly workspace exists so the vault can be opened
159
+ // directly in the Obsidian app (graph view, tag pages, MOC navigation).
160
+ async ensureObsidianFolder(p) {
161
+ const obsDir = path.join(p.projectDir, ".obsidian");
162
+ try { await fs.access(obsDir); return; } catch {}
163
+ await fs.mkdir(obsDir, { recursive: true });
164
+ await fs.writeFile(path.join(obsDir, "app.json"), JSON.stringify({
165
+ alwaysUpdateLinks: true,
166
+ newLinkFormat: "shortest",
167
+ useMarkdownLinks: false,
168
+ showViewHeader: true,
169
+ }, null, 2));
170
+ await fs.writeFile(path.join(obsDir, "workspace.json"), JSON.stringify({
171
+ _comment: "Auto-managed by codex-dev-mcp-suite (project-memory).",
172
+ }, null, 2));
173
+ }
174
+
175
+ // Build an Obsidian-style Map of Content (MOC) listing every note, its
176
+ // outgoing links, backlinks, and a #tag index. Refreshed on save/delete.
177
+ async writeMoc(p, index) {
178
+ const notes = Object.values(index.notes || {})
179
+ .sort((a, b) => (a.created < b.created ? 1 : -1));
180
+ if (notes.length === 0) return;
181
+ const tagMap = new Map();
182
+ for (const n of notes) {
183
+ for (const t of n.tags || []) {
184
+ if (!tagMap.has(t)) tagMap.set(t, []);
185
+ tagMap.get(t).push(n);
186
+ }
187
+ }
188
+ const lines = [
189
+ "# MOC — Map of Content",
190
+ "",
191
+ `> Auto-generated by project-memory for project \`${p.slug}\`. Do not edit by hand; it is regenerated on every save/delete.`,
192
+ "",
193
+ `**${notes.length} notes**`,
194
+ "",
195
+ "## Notes",
196
+ ];
197
+ for (const n of notes) {
198
+ const link = `[[${n.title}]]`;
199
+ const tags = (n.tags || []).length ? ` #${(n.tags || []).join(" #")}` : "";
200
+ lines.push(`- ${link} — *${n.kind || "note"}*${tags}`);
201
+ }
202
+ lines.push("", "## By tag");
203
+ if (tagMap.size === 0) lines.push("- (no tags yet)");
204
+ for (const [tag, ns] of [...tagMap.entries()].sort()) {
205
+ lines.push(`- #${tag}: ${ns.map((n) => `[[${n.title}]]`).join(", ")}`);
206
+ }
207
+ lines.push("", "## Graph (backlinks)");
208
+ for (const n of notes) {
209
+ const bl = (n.backlinks || []).map((b) => `[[${b.title}]]`).join(", ");
210
+ if (bl) lines.push(`- [[${n.title}]] <- ${bl}`);
211
+ }
212
+ lines.push("");
213
+ await fs.writeFile(path.join(p.projectDir, "MOC.md"), lines.join("\n"));
214
+ // Also emit a standalone #tag index page per tag (Obsidian-style tag pages).
215
+ await this.writeTagPages(p, tagMap);
216
+ }
217
+
218
+ // Emit one `tags/<tag>.md` page per tag, listing all notes carrying it.
219
+ async writeTagPages(p, tagMap) {
220
+ const tagDir = path.join(p.projectDir, "tags");
221
+ await fs.mkdir(tagDir, { recursive: true });
222
+ for (const [tag, ns] of tagMap.entries()) {
223
+ const body = [
224
+ `# Tag: ${tag}`,
225
+ "",
226
+ ns.map((n) => `- [[${n.title}]] — *${n.kind || "note"}*`).join("\n"),
227
+ "",
228
+ ].join("\n");
229
+ await fs.writeFile(path.join(tagDir, `${String(tag).replace(/[^a-zA-Z0-9._-]/g, "-")}.md`), body);
230
+ }
231
+ // prune tag pages with no notes left
232
+ let files = [];
233
+ try { files = await fs.readdir(tagDir); } catch { return; }
234
+ for (const f of files) {
235
+ if (!f.endsWith(".md")) continue;
236
+ const t = f.slice(0, -3);
237
+ if (!tagMap.has(t)) await fs.unlink(path.join(tagDir, f)).catch(() => {});
238
+ }
239
+ }
240
+
241
+ // Export the note graph (nodes + directed edges) for external graph views.
242
+ buildGraph(index) {
243
+ const nodes = Object.values(index.notes || {}).map((n) => ({
244
+ id: n.id, title: n.title, kind: n.kind || "note", tags: n.tags || [],
245
+ }));
246
+ const edges = [];
247
+ for (const n of Object.values(index.notes || {})) {
248
+ for (const link of n.links || []) {
249
+ edges.push({ from: n.id, raw: link.raw, ref: link.ref, kind: link.kind });
250
+ }
251
+ }
252
+ return { nodes, edges };
253
+ }
254
+
158
255
  setupHandlers() {
159
256
  this.server.setRequestHandler(ListToolsRequestSchema, () => ({
160
257
  tools: [
@@ -170,6 +267,7 @@ class ProjectMemoryServer {
170
267
  dir: { type: "string", description: "Project directory (defaults to CWD)" },
171
268
  tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
172
269
  kind: { type: "string", description: "note | decision | task | log | snippet", default: "note" },
270
+ aliases: { type: "array", items: { type: "string" }, description: "Optional Obsidian-style aliases for the note (alt titles wikilinks can reference)" },
173
271
  created: { type: "string", description: "Optional ISO timestamp to backdate the note (e.g. original session time)" },
174
272
  },
175
273
  required: ["title", "content"],
@@ -288,6 +386,26 @@ class ProjectMemoryServer {
288
386
  },
289
387
  },
290
388
  },
389
+ {
390
+ name: "memory_moc",
391
+ description: "Generate / refresh the Obsidian-style Map of Content (MOC.md) for the project: lists every note with wikilinks, a #tag index, and backlink graph. Run after saves, or on demand to open the vault in Obsidian.",
392
+ inputSchema: {
393
+ type: "object",
394
+ properties: {
395
+ dir: { type: "string", description: "Project directory (defaults to CWD)" },
396
+ },
397
+ },
398
+ },
399
+ {
400
+ name: "memory_graph",
401
+ description: "Export the note graph (nodes + directed edges from [[wikilinks]]) as JSON, for external graph views or analysis. Backlinks are resolved via graph state.",
402
+ inputSchema: {
403
+ type: "object",
404
+ properties: {
405
+ dir: { type: "string", description: "Project directory (defaults to CWD)" },
406
+ },
407
+ },
408
+ },
291
409
  ],
292
410
  }));
293
411
 
@@ -305,6 +423,8 @@ class ProjectMemoryServer {
305
423
  case "memory_global_recall": return await this.globalRecall(args || {});
306
424
  case "memory_dedup": return await this.dedup(args || {});
307
425
  case "memory_stats": return await this.stats(args || {});
426
+ case "memory_moc": return await this.moc(args || {});
427
+ case "memory_graph": return await this.graph(args || {});
308
428
  default: throw new Error(`Unknown tool: ${name}`);
309
429
  }
310
430
  } catch (error) {
@@ -349,22 +469,23 @@ class ProjectMemoryServer {
349
469
  });
350
470
  }
351
471
 
352
- async save({ title, content, dir, tags, kind = "note", created: createdArg }) {
472
+ async save({ title, content, dir, tags, kind = "note", aliases, created: createdArg }) {
353
473
  title = limit(title, "title", MAX_TITLE);
354
474
  content = limit(content, "content", MAX_CONTENT);
355
475
  const tagList = Array.isArray(tags) ? tags.map((t) => String(t).trim()).filter(Boolean) : [];
476
+ const aliasList = Array.isArray(aliases) ? aliases.map((t) => String(t).trim()).filter(Boolean) : [];
356
477
  const p = this.paths(dir);
357
478
  await fs.mkdir(p.notesDir, { recursive: true });
358
479
 
359
480
  const id = genId();
360
481
  const created = (createdArg && /^\d{4}-\d{2}-\d{2}/.test(String(createdArg))) ? String(createdArg) : nowIso();
361
- const meta = { id, title, kind, tags: tagList, created, dir: path.resolve(dir || process.cwd()) };
482
+ const meta = { id, title, kind, tags: tagList, aliases: aliasList, created, dir: path.resolve(dir || process.cwd()) };
362
483
  const file = path.join(p.notesDir, `${id}.md`);
363
484
  await fs.writeFile(file, buildFrontmatter(meta) + `# ${title}\n\n${content}\n`);
364
485
 
365
486
  const index = await this.loadIndex(p);
366
487
  const keywords = [...new Set([...tokenize(title), ...tokenize(content), ...tagList.map((t) => t.toLowerCase())])];
367
- const note = { id, title, kind, tags: tagList, created, keywords, file: path.relative(p.projectDir, file) };
488
+ const note = { id, title, kind, tags: tagList, aliases: aliasList, created, keywords, file: path.relative(p.projectDir, file) };
368
489
 
369
490
  const vec = await embedOne(`${title}\n\n${content}`);
370
491
  let embedded = false;
@@ -372,8 +493,19 @@ class ProjectMemoryServer {
372
493
 
373
494
  index.notes[id] = note;
374
495
  await this.saveIndex(p, index);
496
+ await this.ensureObsidianFolder(p);
497
+ // Resolve [[wikilinks]] + backlinks immediately so the graph is fresh on save.
498
+ const ensured = await ensureGraphState({
499
+ vaultRoot: VAULT_ROOT,
500
+ projectDir: p.projectDir,
501
+ slug: p.slug,
502
+ index,
503
+ noteLoader: loadNoteBody,
504
+ });
505
+ if (ensured.changed) await this.saveIndex(p, ensured.index);
506
+ await this.writeMoc(p, ensured.index);
375
507
 
376
- return { content: [{ type: "text", text: `Saved note ${id} → ${p.slug}\nFile: ${file}\nKeywords indexed: ${keywords.length}${embedded ? " (semantic embedding stored)" : " (keyword-only; embeddings unavailable)"}` }] };
508
+ return { content: [{ type: "text", text: `Saved note ${id} → ${p.slug}\nFile: ${file}\nKeywords indexed: ${keywords.length}${embedded ? " (semantic embedding stored)" : " (keyword-only; embeddings unavailable)"}\nMOC refreshed: ${path.join(p.projectDir, "MOC.md")}` }] };
377
509
  }
378
510
 
379
511
  async recall({ query, dir, limit: lim = 5, full = false, mode: requestedMode = "auto" }) {
@@ -572,6 +704,7 @@ class ProjectMemoryServer {
572
704
  await fs.unlink(path.join(p.projectDir, meta.file)).catch(() => {});
573
705
  delete index.notes[id];
574
706
  await this.saveIndex(p, index);
707
+ await this.writeMoc(p, index);
575
708
  return { content: [{ type: "text", text: `Deleted note ${id} from ${p.slug}` }] };
576
709
  }
577
710
 
@@ -647,12 +780,37 @@ class ProjectMemoryServer {
647
780
  ? path.dirname(process.env.JOURNAL_DIR)
648
781
  : process.env.CHECKPOINT_DIR
649
782
  ? path.dirname(process.env.CHECKPOINT_DIR)
650
- : path.join(os.homedir(), ".codex", "memories"));
783
+ : path.join(os.homedir(), ".ai-shared-memory"));
651
784
  const stats = computeStats({ root, topLimit: top });
652
785
  const text = json ? formatJson(stats) : formatText(stats);
653
786
  return { content: [{ type: "text", text }] };
654
787
  }
655
788
 
789
+ async moc({ dir } = {}) {
790
+ const p = this.paths(dir);
791
+ await this.ensureObsidianFolder(p);
792
+ const index = await this.loadIndex(p);
793
+ await this.writeMoc(p, index);
794
+ const mocFile = path.join(p.projectDir, "MOC.md");
795
+ const count = Object.keys(index.notes || {}).length;
796
+ return { content: [{ type: "text", text: `MOC refreshed for ${p.slug}: ${count} notes → ${mocFile}\nOpen the project folder in Obsidian to see the graph + tag pages.` }] };
797
+ }
798
+
799
+ async graph({ dir } = {}) {
800
+ const p = this.paths(dir);
801
+ const index = await this.loadIndex(p);
802
+ const ensured = await ensureGraphState({
803
+ vaultRoot: VAULT_ROOT,
804
+ projectDir: p.projectDir,
805
+ slug: p.slug,
806
+ index,
807
+ noteLoader: loadNoteBody,
808
+ });
809
+ if (ensured.changed) await this.saveIndex(p, ensured.index);
810
+ const g = this.buildGraph(ensured.index);
811
+ return { content: [{ type: "text", text: JSON.stringify(g, null, 2) }] };
812
+ }
813
+
656
814
  async run() {
657
815
  const transport = new StdioServerTransport();
658
816
  await this.server.connect(transport);
package/run-tests.mjs ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runs every server's test.mjs and any top-level tests/*.test.mjs,
4
+ * then prints a combined summary. Usage: node run-tests.mjs
5
+ */
6
+ import { spawn } from "child_process";
7
+ import path from "path";
8
+ import { fileURLToPath } from "url";
9
+ import fs from "fs";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const SERVERS = ["project-memory", "checkpoint", "context-pack", "devjournal"];
13
+ const EXTRA_TESTS = fs.existsSync("tests")
14
+ ? fs.readdirSync("tests").filter((f) => f.endsWith(".test.mjs")).map((f) => `tests/${f}`)
15
+ : [];
16
+
17
+ /** Run a single test file by absolute path. */
18
+ function runFile(absPath) {
19
+ return new Promise((resolve) => {
20
+ const proc = spawn("node", [absPath], { stdio: ["ignore", "pipe", "pipe"] });
21
+ let out = "";
22
+ proc.stdout.on("data", (d) => (out += d));
23
+ proc.stderr.on("data", (d) => (out += d));
24
+ proc.on("close", (code) => resolve({ name: path.basename(path.dirname(absPath)) || absPath, code, out }));
25
+ });
26
+ }
27
+
28
+ const results = [];
29
+
30
+ for (const s of SERVERS) {
31
+ console.log(`\n=== ${s} ===`);
32
+ const r = await runFile(path.join(__dirname, s, "test.mjs"));
33
+ process.stdout.write(r.out);
34
+ results.push({ ...r, name: s });
35
+ }
36
+
37
+ for (const t of EXTRA_TESTS) {
38
+ console.log(`\n=== ${t} ===`);
39
+ const r = await runFile(path.join(__dirname, t));
40
+ process.stdout.write(r.out);
41
+ results.push({ ...r, name: t });
42
+ }
43
+
44
+ console.log("\n================ SUMMARY ================");
45
+ let anyFail = false;
46
+ for (const r of results) {
47
+ const ok = r.code === 0;
48
+ if (!ok) anyFail = true;
49
+ const m = r.out.match(/(\d+) passed, (\d+) failed/);
50
+ const stat = m ? `${m[1]} passed, ${m[2]} failed` : (ok ? "ok" : "FAILED");
51
+ console.log(` ${ok ? "✓" : "✗"} ${r.name.padEnd(28)} ${stat}`);
52
+ }
53
+ console.log("========================================");
54
+ process.exit(anyFail ? 1 : 0);