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.
- package/.env.example +19 -0
- package/CHANGELOG.md +52 -0
- package/README.md +79 -1
- package/bin/provider-smoke.mjs +278 -0
- package/lib/provider-smoke.js +234 -0
- package/package.json +3 -2
- package/project-memory/dedup.js +49 -0
- package/project-memory/embedding.js +108 -30
- package/project-memory/global-index.js +46 -0
- package/project-memory/graph.js +86 -0
- package/project-memory/server.js +197 -11
package/project-memory/server.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
server
|
|
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 };
|