codex-dev-mcp-suite 1.3.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.
@@ -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(
@@ -163,7 +177,7 @@ class ProjectMemoryServer {
163
177
  {
164
178
  name: "memory_recall",
165
179
  description:
166
- "Retrieve the most relevant saved notes for a query using the keyword index. Call this at the start of a new session instead of re-pasting context.",
180
+ "Retrieve the most relevant saved notes for a query. Falls back gracefully: semantic -> keyword -> LLM rerank. Set mode to control behavior: 'auto' (default), 'semantic' (require embedding), 'keyword' (skip embedding).",
167
181
  inputSchema: {
168
182
  type: "object",
169
183
  properties: {
@@ -171,6 +185,7 @@ class ProjectMemoryServer {
171
185
  dir: { type: "string", description: "Project directory (defaults to CWD)" },
172
186
  limit: { type: "number", description: "Max notes to return", default: 5 },
173
187
  full: { type: "boolean", description: "Return full bodies instead of excerpts", default: false },
188
+ mode: { type: "string", enum: ["auto", "semantic", "keyword"], default: "auto", description: "Retrieval mode: 'auto' (default, smart fallback), 'semantic' (require embedding), 'keyword' (skip embedding)" },
174
189
  },
175
190
  required: ["query"],
176
191
  },
@@ -221,6 +236,45 @@ class ProjectMemoryServer {
221
236
  },
222
237
  },
223
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
+ },
224
278
  {
225
279
  name: "memory_stats",
226
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.",
@@ -246,6 +300,9 @@ class ProjectMemoryServer {
246
300
  case "memory_get": return await this.get(args || {});
247
301
  case "memory_delete": return await this.del(args || {});
248
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 || {});
249
306
  case "memory_stats": return await this.stats(args || {});
250
307
  default: throw new Error(`Unknown tool: ${name}`);
251
308
  }
@@ -316,11 +373,19 @@ class ProjectMemoryServer {
316
373
  return { content: [{ type: "text", text: `Saved note ${id} → ${p.slug}\nFile: ${file}\nKeywords indexed: ${keywords.length}${embedded ? " (semantic embedding stored)" : " (keyword-only; embeddings unavailable)"}` }] };
317
374
  }
318
375
 
319
- async recall({ query, dir, limit: lim = 5, full = false }) {
376
+ async recall({ query, dir, limit: lim = 5, full = false, mode: requestedMode = "auto" }) {
320
377
  query = limit(query, "query", 2000);
321
378
  const p = this.paths(dir);
322
379
  const index = await this.loadIndex(p);
323
- 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 || {});
324
389
  if (notes.length === 0) {
325
390
  return { content: [{ type: "text", text: `No memories for ${p.slug} yet. Use memory_save first.` }] };
326
391
  }
@@ -333,10 +398,17 @@ class ProjectMemoryServer {
333
398
  return score;
334
399
  };
335
400
 
401
+ // Tier 2.1: respect requested mode. "auto" = smart fallback.
336
402
  let mode = deterministicEnabled() ? "deterministic" : "keyword";
337
- const qVec = deterministicEnabled() ? null : await embedOne(query);
403
+ const useEmbed = requestedMode !== "keyword" && !deterministicEnabled();
404
+ const qVec = useEmbed ? await embedOne(query) : null;
338
405
  const haveEmb = qVec && notes.some((n) => Array.isArray(n.embedding));
339
406
 
407
+ // Tier 2.1: "semantic" requested but unavailable -> error
408
+ if (requestedMode === "semantic" && !haveEmb) {
409
+ return { content: [{ type: "text", text: `memory_recall(mode=semantic) requested but no embeddings available. Set MCP_EMBED_API_KEY + MCP_EMBED_MODEL, or omit mode for keyword fallback.` }], isError: true };
410
+ }
411
+
340
412
  let scored;
341
413
  if (haveEmb) {
342
414
  mode = "semantic";
@@ -344,14 +416,14 @@ class ProjectMemoryServer {
344
416
  scored = notes.map((n) => {
345
417
  const sim = Array.isArray(n.embedding) ? cosine(qVec, n.embedding) : 0;
346
418
  const kwNorm = kw(n) / maxKw;
347
- const score = 0.8 * sim + 0.2 * kwNorm;
419
+ const score = 0.8 * sim + 0.2 * kwNorm + graphBoost(n, [...qTokens]);
348
420
  return { n, score, sim };
349
421
  }).filter((x) => x.score > 0.05)
350
422
  .sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1))
351
423
  .slice(0, Math.max(1, Math.min(20, lim)));
352
424
  } else {
353
425
  const want = Math.max(1, Math.min(20, lim));
354
- const kwRanked = notes.map((n) => ({ n, score: kw(n) }))
426
+ const kwRanked = notes.map((n) => ({ n, score: kw(n) + graphBoost(n, [...qTokens]) }))
355
427
  .filter((x) => x.score > 0)
356
428
  .sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1));
357
429
 
@@ -395,7 +467,76 @@ class ProjectMemoryServer {
395
467
  const tag = mode === "semantic" ? `sim:${(sim ?? 0).toFixed(3)}` : (mode === "rerank" ? "llm-ranked" : `score:${score}`);
396
468
  blocks.push(`### ${n.title} (id:${n.id}, ${tag}, ${n.created})\n${text}`);
397
469
  }
398
- return { content: [{ type: "text", text: `Recall for "${query}" in ${p.slug} [${mode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
470
+ // Tier 2.2: annotate mode with rerank indicator
471
+ const isReranked = rerankConfig().enabled && blocks.length > 1;
472
+ const displayMode = mode === "semantic" && isReranked ? "semantic+rerank"
473
+ : mode === "keyword" && isReranked ? "keyword+rerank"
474
+ : mode;
475
+ return { content: [{ type: "text", text: `Recall for "${query}" in ${p.slug} [${displayMode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
476
+ }
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")}` }] };
399
540
  }
400
541
 
401
542
  async list({ dir, limit: lim = 20 }) {
@@ -431,6 +572,47 @@ class ProjectMemoryServer {
431
572
  return { content: [{ type: "text", text: `Deleted note ${id} from ${p.slug}` }] };
432
573
  }
433
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
+
434
616
  async reindex({ dir, force = false }) {
435
617
  const p = this.paths(dir);
436
618
  if (deterministicEnabled()) return { content: [{ type: "text", text: `Reindex ${p.slug}: skipped (deterministic no-network mode; embeddings disabled).` }] };
@@ -475,5 +657,9 @@ class ProjectMemoryServer {
475
657
  }
476
658
  }
477
659
 
478
- const server = new ProjectMemoryServer();
479
- 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 };