codex-dev-mcp-suite 1.0.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 +26 -0
- package/CODEX_DEV_MCP_GUIDE.md +297 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/_testkit/harness.mjs +126 -0
- package/backfill-sessions-v2.mjs +288 -0
- package/bin/checkpoint.mjs +2 -0
- package/bin/context-pack.mjs +2 -0
- package/bin/devjournal.mjs +2 -0
- package/bin/project-memory.mjs +2 -0
- package/checkpoint/package.json +14 -0
- package/checkpoint/server.js +293 -0
- package/checkpoint/test.mjs +73 -0
- package/context-pack/package.json +14 -0
- package/context-pack/server.js +256 -0
- package/context-pack/test.mjs +66 -0
- package/devjournal/package.json +14 -0
- package/devjournal/rerank.js +89 -0
- package/devjournal/server.js +310 -0
- package/devjournal/test.mjs +68 -0
- package/package.json +65 -0
- package/project-memory/embedding.js +80 -0
- package/project-memory/package.json +14 -0
- package/project-memory/rerank.js +89 -0
- package/project-memory/server.js +448 -0
- package/project-memory/test.mjs +72 -0
- package/project-memory/test.rerank.mjs +53 -0
- package/run-tests.mjs +42 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project Memory MCP Server for Codex
|
|
5
|
+
*
|
|
6
|
+
* Idea: Obsidian-style local Markdown vault + Context7-style on-demand recall.
|
|
7
|
+
* Persists per-directory notes/activity to .md files (with YAML frontmatter)
|
|
8
|
+
* and maintains a keyword index so a fresh session can pull back only the
|
|
9
|
+
* relevant context instead of re-pasting everything.
|
|
10
|
+
*
|
|
11
|
+
* Vault layout (default ~/.codex/memories/vault):
|
|
12
|
+
* <project-slug>/
|
|
13
|
+
* notes/<id>.md # one note per file, plain Markdown + frontmatter
|
|
14
|
+
* index.json # keyword + metadata index for fast recall
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
|
+
import {
|
|
20
|
+
CallToolRequestSchema,
|
|
21
|
+
ListToolsRequestSchema,
|
|
22
|
+
ListResourcesRequestSchema,
|
|
23
|
+
ReadResourceRequestSchema,
|
|
24
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
25
|
+
import fs from "fs/promises";
|
|
26
|
+
import path from "path";
|
|
27
|
+
import os from "os";
|
|
28
|
+
import crypto from "crypto";
|
|
29
|
+
import { embed, embedOne, cosine, embeddingConfig } from "./embedding.js";
|
|
30
|
+
import { rerank, rerankConfig } from "./rerank.js";
|
|
31
|
+
|
|
32
|
+
const VAULT_ROOT =
|
|
33
|
+
process.env.MEMORY_VAULT_DIR ||
|
|
34
|
+
path.join(os.homedir(), ".codex", "memories", "vault");
|
|
35
|
+
|
|
36
|
+
const MAX_CONTENT = 200_000;
|
|
37
|
+
const MAX_TITLE = 200;
|
|
38
|
+
const STOPWORDS = new Set([
|
|
39
|
+
"the", "a", "an", "and", "or", "but", "if", "then", "else", "for", "to",
|
|
40
|
+
"of", "in", "on", "at", "by", "is", "are", "was", "were", "be", "this",
|
|
41
|
+
"that", "it", "as", "with", "from", "we", "i", "you", "he", "she", "they",
|
|
42
|
+
"not", "no", "do", "did", "does", "can", "will", "so", "my", "our", "your",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function projectSlug(dir) {
|
|
46
|
+
const resolved = path.resolve(dir || process.cwd());
|
|
47
|
+
const base = path.basename(resolved) || "root";
|
|
48
|
+
const hash = crypto.createHash("sha1").update(resolved).digest("hex").slice(0, 8);
|
|
49
|
+
const safeBase = base.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 40);
|
|
50
|
+
return `${safeBase}-${hash}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function tokenize(text) {
|
|
54
|
+
return (String(text || "").toLowerCase().match(/[a-z0-9_./-]{2,}/g) || [])
|
|
55
|
+
.filter((w) => !STOPWORDS.has(w));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nowIso() {
|
|
59
|
+
return new Date().toISOString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function genId() {
|
|
63
|
+
return (
|
|
64
|
+
new Date().toISOString().replace(/[:.]/g, "").slice(0, 15) +
|
|
65
|
+
"-" +
|
|
66
|
+
crypto.randomBytes(3).toString("hex")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function limit(value, name, max, required = true) {
|
|
71
|
+
const text = String(value ?? "");
|
|
72
|
+
if (required && !text.trim()) throw new Error(`${name} is required`);
|
|
73
|
+
if (text.length > max) throw new Error(`${name} exceeds ${max} characters`);
|
|
74
|
+
return text;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseFrontmatter(raw) {
|
|
78
|
+
const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
79
|
+
if (!m) return { meta: {}, body: raw };
|
|
80
|
+
const meta = {};
|
|
81
|
+
for (const line of m[1].split("\n")) {
|
|
82
|
+
const idx = line.indexOf(":");
|
|
83
|
+
if (idx === -1) continue;
|
|
84
|
+
const key = line.slice(0, idx).trim();
|
|
85
|
+
let val = line.slice(idx + 1).trim();
|
|
86
|
+
if (val.startsWith("[") && val.endsWith("]")) {
|
|
87
|
+
val = val.slice(1, -1).split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
88
|
+
} else {
|
|
89
|
+
val = val.replace(/^["']|["']$/g, "");
|
|
90
|
+
}
|
|
91
|
+
meta[key] = val;
|
|
92
|
+
}
|
|
93
|
+
return { meta, body: m[2] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildFrontmatter(meta) {
|
|
97
|
+
const lines = ["---"];
|
|
98
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
99
|
+
if (Array.isArray(v)) lines.push(`${k}: [${v.map((x) => JSON.stringify(x)).join(", ")}]`);
|
|
100
|
+
else lines.push(`${k}: ${JSON.stringify(v)}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push("---", "");
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
class ProjectMemoryServer {
|
|
107
|
+
constructor() {
|
|
108
|
+
this.server = new Server(
|
|
109
|
+
{ name: "project-memory", version: "1.0.0" },
|
|
110
|
+
{ capabilities: { tools: {}, resources: {} } }
|
|
111
|
+
);
|
|
112
|
+
this.setupHandlers();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
paths(dir) {
|
|
116
|
+
const slug = projectSlug(dir);
|
|
117
|
+
const projectDir = path.join(VAULT_ROOT, slug);
|
|
118
|
+
return {
|
|
119
|
+
slug,
|
|
120
|
+
projectDir,
|
|
121
|
+
notesDir: path.join(projectDir, "notes"),
|
|
122
|
+
indexFile: path.join(projectDir, "index.json"),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async loadIndex(p) {
|
|
127
|
+
try {
|
|
128
|
+
const raw = await fs.readFile(p.indexFile, "utf8");
|
|
129
|
+
return JSON.parse(raw);
|
|
130
|
+
} catch {
|
|
131
|
+
return { project: p.slug, updated: nowIso(), notes: {} };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async saveIndex(p, index) {
|
|
136
|
+
index.updated = nowIso();
|
|
137
|
+
await fs.mkdir(p.projectDir, { recursive: true });
|
|
138
|
+
await fs.writeFile(p.indexFile, JSON.stringify(index, null, 2));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
setupHandlers() {
|
|
142
|
+
this.server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
143
|
+
tools: [
|
|
144
|
+
{
|
|
145
|
+
name: "memory_save",
|
|
146
|
+
description:
|
|
147
|
+
"Save a note/decision/activity to the project's Markdown vault and index it for later recall. Use after meaningful work so a fresh session can recover context.",
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: {
|
|
151
|
+
title: { type: "string", description: "Short title/summary" },
|
|
152
|
+
content: { type: "string", description: "Markdown body: details, decisions, code refs" },
|
|
153
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
154
|
+
tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
|
|
155
|
+
kind: { type: "string", description: "note | decision | task | log | snippet", default: "note" },
|
|
156
|
+
created: { type: "string", description: "Optional ISO timestamp to backdate the note (e.g. original session time)" },
|
|
157
|
+
},
|
|
158
|
+
required: ["title", "content"],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "memory_recall",
|
|
163
|
+
description:
|
|
164
|
+
"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.",
|
|
165
|
+
inputSchema: {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: {
|
|
168
|
+
query: { type: "string", description: "What you need context about" },
|
|
169
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
170
|
+
limit: { type: "number", description: "Max notes to return", default: 5 },
|
|
171
|
+
full: { type: "boolean", description: "Return full bodies instead of excerpts", default: false },
|
|
172
|
+
},
|
|
173
|
+
required: ["query"],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: "memory_list",
|
|
178
|
+
description: "List saved notes for a project (newest first) with id, title, tags, timestamp.",
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: {
|
|
182
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
183
|
+
limit: { type: "number", description: "Max notes", default: 20 },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "memory_get",
|
|
189
|
+
description: "Fetch the full Markdown content of a single note by id.",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
id: { type: "string", description: "Note id" },
|
|
194
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
195
|
+
},
|
|
196
|
+
required: ["id"],
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "memory_delete",
|
|
201
|
+
description: "Delete a note by id from the vault and index.",
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: "object",
|
|
204
|
+
properties: {
|
|
205
|
+
id: { type: "string", description: "Note id" },
|
|
206
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
207
|
+
},
|
|
208
|
+
required: ["id"],
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: "memory_reindex",
|
|
213
|
+
description: "Backfill semantic embeddings for notes that don't have one yet (run after the embedding model becomes available). Safe to call repeatedly.",
|
|
214
|
+
inputSchema: {
|
|
215
|
+
type: "object",
|
|
216
|
+
properties: {
|
|
217
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
218
|
+
force: { type: "boolean", description: "Re-embed all notes, not just missing ones", default: false },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
}));
|
|
224
|
+
|
|
225
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
226
|
+
const { name, arguments: args } = request.params;
|
|
227
|
+
try {
|
|
228
|
+
switch (name) {
|
|
229
|
+
case "memory_save": return await this.save(args || {});
|
|
230
|
+
case "memory_recall": return await this.recall(args || {});
|
|
231
|
+
case "memory_list": return await this.list(args || {});
|
|
232
|
+
case "memory_get": return await this.get(args || {});
|
|
233
|
+
case "memory_delete": return await this.del(args || {});
|
|
234
|
+
case "memory_reindex": return await this.reindex(args || {});
|
|
235
|
+
default: throw new Error(`Unknown tool: ${name}`);
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Resources: expose every saved note as a read-only resource across all
|
|
243
|
+
// projects in the vault. URI form: memory://<project-slug>/<noteId>
|
|
244
|
+
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
245
|
+
const resources = [];
|
|
246
|
+
let slugs = [];
|
|
247
|
+
try { slugs = await fs.readdir(VAULT_ROOT); } catch { slugs = []; }
|
|
248
|
+
for (const slug of slugs) {
|
|
249
|
+
const indexFile = path.join(VAULT_ROOT, slug, "index.json");
|
|
250
|
+
let index; try { index = JSON.parse(await fs.readFile(indexFile, "utf8")); } catch { continue; }
|
|
251
|
+
for (const n of Object.values(index.notes || {})) {
|
|
252
|
+
resources.push({
|
|
253
|
+
uri: `memory://${slug}/${n.id}`,
|
|
254
|
+
name: n.title || n.id,
|
|
255
|
+
description: `[${n.kind || "note"}] ${(n.tags || []).join(", ")} — ${n.created || ""}`.trim(),
|
|
256
|
+
mimeType: "text/markdown",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { resources };
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
264
|
+
const uri = request.params.uri || "";
|
|
265
|
+
const m = uri.match(/^memory:\/\/([^/]+)\/(.+)$/);
|
|
266
|
+
if (!m) throw new Error(`Unknown resource URI: ${uri}`);
|
|
267
|
+
const [, slug, noteId] = m;
|
|
268
|
+
const indexFile = path.join(VAULT_ROOT, slug, "index.json");
|
|
269
|
+
let index; try { index = JSON.parse(await fs.readFile(indexFile, "utf8")); } catch { throw new Error(`Project not found: ${slug}`); }
|
|
270
|
+
const note = index.notes?.[noteId];
|
|
271
|
+
if (!note) throw new Error(`Note not found: ${noteId}`);
|
|
272
|
+
const raw = await fs.readFile(path.join(VAULT_ROOT, slug, note.file), "utf8");
|
|
273
|
+
return { contents: [{ uri, mimeType: "text/markdown", text: raw }] };
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async save({ title, content, dir, tags, kind = "note", created: createdArg }) {
|
|
278
|
+
title = limit(title, "title", MAX_TITLE);
|
|
279
|
+
content = limit(content, "content", MAX_CONTENT);
|
|
280
|
+
const tagList = Array.isArray(tags) ? tags.map((t) => String(t).trim()).filter(Boolean) : [];
|
|
281
|
+
const p = this.paths(dir);
|
|
282
|
+
await fs.mkdir(p.notesDir, { recursive: true });
|
|
283
|
+
|
|
284
|
+
const id = genId();
|
|
285
|
+
const created = (createdArg && /^\d{4}-\d{2}-\d{2}/.test(String(createdArg))) ? String(createdArg) : nowIso();
|
|
286
|
+
const meta = { id, title, kind, tags: tagList, created, dir: path.resolve(dir || process.cwd()) };
|
|
287
|
+
const file = path.join(p.notesDir, `${id}.md`);
|
|
288
|
+
await fs.writeFile(file, buildFrontmatter(meta) + `# ${title}\n\n${content}\n`);
|
|
289
|
+
|
|
290
|
+
const index = await this.loadIndex(p);
|
|
291
|
+
const keywords = [...new Set([...tokenize(title), ...tokenize(content), ...tagList.map((t) => t.toLowerCase())])];
|
|
292
|
+
const note = { id, title, kind, tags: tagList, created, keywords, file: path.relative(p.projectDir, file) };
|
|
293
|
+
|
|
294
|
+
const vec = await embedOne(`${title}\n\n${content}`);
|
|
295
|
+
let embedded = false;
|
|
296
|
+
if (vec) { note.embedding = vec; note.embModel = embeddingConfig().model; embedded = true; }
|
|
297
|
+
|
|
298
|
+
index.notes[id] = note;
|
|
299
|
+
await this.saveIndex(p, index);
|
|
300
|
+
|
|
301
|
+
return { content: [{ type: "text", text: `Saved note ${id} → ${p.slug}\nFile: ${file}\nKeywords indexed: ${keywords.length}${embedded ? " (semantic embedding stored)" : " (keyword-only; embeddings unavailable)"}` }] };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async recall({ query, dir, limit: lim = 5, full = false }) {
|
|
305
|
+
query = limit(query, "query", 2000);
|
|
306
|
+
const p = this.paths(dir);
|
|
307
|
+
const index = await this.loadIndex(p);
|
|
308
|
+
const notes = Object.values(index.notes || {});
|
|
309
|
+
if (notes.length === 0) {
|
|
310
|
+
return { content: [{ type: "text", text: `No memories for ${p.slug} yet. Use memory_save first.` }] };
|
|
311
|
+
}
|
|
312
|
+
const qTokens = new Set(tokenize(query));
|
|
313
|
+
const kw = (n) => {
|
|
314
|
+
let score = 0;
|
|
315
|
+
for (const k of n.keywords || []) if (qTokens.has(k)) score += 1;
|
|
316
|
+
for (const t of n.tags || []) if (qTokens.has(t.toLowerCase())) score += 2;
|
|
317
|
+
for (const w of tokenize(n.title)) if (qTokens.has(w)) score += 2;
|
|
318
|
+
return score;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
let mode = "keyword";
|
|
322
|
+
const qVec = await embedOne(query);
|
|
323
|
+
const haveEmb = qVec && notes.some((n) => Array.isArray(n.embedding));
|
|
324
|
+
|
|
325
|
+
let scored;
|
|
326
|
+
if (haveEmb) {
|
|
327
|
+
mode = "semantic";
|
|
328
|
+
const maxKw = Math.max(1, ...notes.map(kw));
|
|
329
|
+
scored = notes.map((n) => {
|
|
330
|
+
const sim = Array.isArray(n.embedding) ? cosine(qVec, n.embedding) : 0;
|
|
331
|
+
const kwNorm = kw(n) / maxKw;
|
|
332
|
+
const score = 0.8 * sim + 0.2 * kwNorm;
|
|
333
|
+
return { n, score, sim };
|
|
334
|
+
}).filter((x) => x.score > 0.05)
|
|
335
|
+
.sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1))
|
|
336
|
+
.slice(0, Math.max(1, Math.min(20, lim)));
|
|
337
|
+
} else {
|
|
338
|
+
const want = Math.max(1, Math.min(20, lim));
|
|
339
|
+
const kwRanked = notes.map((n) => ({ n, score: kw(n) }))
|
|
340
|
+
.filter((x) => x.score > 0)
|
|
341
|
+
.sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1));
|
|
342
|
+
|
|
343
|
+
// Build a candidate pool for the LLM reranker. If keyword found nothing,
|
|
344
|
+
// fall back to most-recent notes so semantic-style queries still work.
|
|
345
|
+
let pool = kwRanked.slice(0, 20);
|
|
346
|
+
if (pool.length === 0 && rerankConfig().enabled) {
|
|
347
|
+
pool = notes.slice().sort((a, b) => (a.created < b.created ? 1 : -1)).slice(0, 20).map((n) => ({ n, score: 0 }));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let reranked = null;
|
|
351
|
+
if (rerankConfig().enabled && pool.length > 1) {
|
|
352
|
+
const cands = [];
|
|
353
|
+
for (const { n } of pool) {
|
|
354
|
+
const raw = await fs.readFile(path.join(p.projectDir, n.file), "utf8").catch(() => "");
|
|
355
|
+
const { body } = parseFrontmatter(raw);
|
|
356
|
+
cands.push({ id: n.id, title: n.title, snippet: body });
|
|
357
|
+
}
|
|
358
|
+
const order = await rerank(query, cands, want);
|
|
359
|
+
if (order) {
|
|
360
|
+
mode = "rerank";
|
|
361
|
+
const byId = new Map(pool.map((x) => [x.n.id, x.n]));
|
|
362
|
+
reranked = order.map((id) => byId.get(id)).filter(Boolean).slice(0, want).map((n) => ({ n, score: 0 }));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
scored = (reranked && reranked.length)
|
|
367
|
+
? reranked
|
|
368
|
+
: kwRanked.slice(0, want);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (scored.length === 0) {
|
|
372
|
+
return { content: [{ type: "text", text: `No relevant memories for "${query}" in ${p.slug}. Try memory_list.` }] };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const blocks = [];
|
|
376
|
+
for (const { n, score, sim } of scored) {
|
|
377
|
+
const raw = await fs.readFile(path.join(p.projectDir, n.file), "utf8").catch(() => "");
|
|
378
|
+
const { body } = parseFrontmatter(raw);
|
|
379
|
+
const text = full ? body.trim() : body.trim().split("\n").slice(0, 12).join("\n");
|
|
380
|
+
const tag = mode === "semantic" ? `sim:${(sim ?? 0).toFixed(3)}` : (mode === "rerank" ? "llm-ranked" : `score:${score}`);
|
|
381
|
+
blocks.push(`### ${n.title} (id:${n.id}, ${tag}, ${n.created})\n${text}`);
|
|
382
|
+
}
|
|
383
|
+
return { content: [{ type: "text", text: `Recall for "${query}" in ${p.slug} [${mode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async list({ dir, limit: lim = 20 }) {
|
|
387
|
+
const p = this.paths(dir);
|
|
388
|
+
const index = await this.loadIndex(p);
|
|
389
|
+
const notes = Object.values(index.notes || {})
|
|
390
|
+
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
|
391
|
+
.slice(0, Math.max(1, Math.min(200, lim)));
|
|
392
|
+
if (notes.length === 0) return { content: [{ type: "text", text: `No memories for ${p.slug} yet.` }] };
|
|
393
|
+
const lines = notes.map((n) => `- ${n.id} [${n.kind}] ${n.title}${n.tags?.length ? ` (#${n.tags.join(" #")})` : ""} — ${n.created}`);
|
|
394
|
+
return { content: [{ type: "text", text: `Memories in ${p.slug} (${notes.length}):\n${lines.join("\n")}` }] };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async get({ id, dir }) {
|
|
398
|
+
id = limit(id, "id", 100);
|
|
399
|
+
const p = this.paths(dir);
|
|
400
|
+
const index = await this.loadIndex(p);
|
|
401
|
+
const meta = index.notes?.[id];
|
|
402
|
+
if (!meta) throw new Error(`Note ${id} not found in ${p.slug}`);
|
|
403
|
+
const raw = await fs.readFile(path.join(p.projectDir, meta.file), "utf8");
|
|
404
|
+
return { content: [{ type: "text", text: raw }] };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async del({ id, dir }) {
|
|
408
|
+
id = limit(id, "id", 100);
|
|
409
|
+
const p = this.paths(dir);
|
|
410
|
+
const index = await this.loadIndex(p);
|
|
411
|
+
const meta = index.notes?.[id];
|
|
412
|
+
if (!meta) throw new Error(`Note ${id} not found in ${p.slug}`);
|
|
413
|
+
await fs.unlink(path.join(p.projectDir, meta.file)).catch(() => {});
|
|
414
|
+
delete index.notes[id];
|
|
415
|
+
await this.saveIndex(p, index);
|
|
416
|
+
return { content: [{ type: "text", text: `Deleted note ${id} from ${p.slug}` }] };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async reindex({ dir, force = false }) {
|
|
420
|
+
const p = this.paths(dir);
|
|
421
|
+
const index = await this.loadIndex(p);
|
|
422
|
+
const notes = Object.values(index.notes || {});
|
|
423
|
+
if (notes.length === 0) return { content: [{ type: "text", text: `No memories for ${p.slug} yet.` }] };
|
|
424
|
+
|
|
425
|
+
const targets = notes.filter((n) => force || !Array.isArray(n.embedding));
|
|
426
|
+
if (targets.length === 0) return { content: [{ type: "text", text: `All ${notes.length} notes already embedded in ${p.slug}.` }] };
|
|
427
|
+
|
|
428
|
+
let done = 0, failed = 0;
|
|
429
|
+
for (const n of targets) {
|
|
430
|
+
const raw = await fs.readFile(path.join(p.projectDir, n.file), "utf8").catch(() => "");
|
|
431
|
+
const { body } = parseFrontmatter(raw);
|
|
432
|
+
const vec = await embedOne(`${n.title}\n\n${body}`);
|
|
433
|
+
if (vec) { n.embedding = vec; n.embModel = embeddingConfig().model; done++; }
|
|
434
|
+
else { failed++; }
|
|
435
|
+
}
|
|
436
|
+
await this.saveIndex(p, index);
|
|
437
|
+
return { content: [{ type: "text", text: `Reindex ${p.slug}: embedded ${done}, failed ${failed} (embeddings ${embeddingConfig().enabled ? "configured" : "not configured"}). ${failed ? "Failures usually mean the embedding model is unavailable right now." : ""}` }] };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async run() {
|
|
441
|
+
const transport = new StdioServerTransport();
|
|
442
|
+
await this.server.connect(transport);
|
|
443
|
+
console.error(`Project Memory MCP server running on stdio (vault: ${VAULT_ROOT})`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const server = new ProjectMemoryServer();
|
|
448
|
+
server.run().catch(console.error);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { McpClient, describe, it, assert, assertIncludes, run, tmpDir, rmrf } from "../_testkit/harness.mjs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const SERVER = path.join(__dirname, "server.js");
|
|
7
|
+
const VAULT = tmpDir("pm-");
|
|
8
|
+
const DEMO = "/tmp/pm-demo-project";
|
|
9
|
+
|
|
10
|
+
// Force keyword mode (no embedding key) so the suite is deterministic/offline.
|
|
11
|
+
const client = new McpClient(SERVER, { MEMORY_VAULT_DIR: VAULT, NINEROUTER_KEY: "", EMBED_KEY: "" });
|
|
12
|
+
|
|
13
|
+
let savedId = "";
|
|
14
|
+
|
|
15
|
+
describe("project-memory", () => {
|
|
16
|
+
it("lists expected tools", async () => {
|
|
17
|
+
const tools = await client.listTools();
|
|
18
|
+
for (const t of ["memory_save", "memory_recall", "memory_list", "memory_get", "memory_delete", "memory_reindex"])
|
|
19
|
+
assert(tools.includes(t), `missing ${t}`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("saves a note (keyword mode without embeddings)", async () => {
|
|
23
|
+
const r = await client.callTool("memory_save", {
|
|
24
|
+
dir: DEMO, title: "JWT login flow",
|
|
25
|
+
content: "login() issues JWT; refresh token in httpOnly cookie; clock skew bug on verify.",
|
|
26
|
+
tags: ["auth", "jwt"], kind: "decision",
|
|
27
|
+
});
|
|
28
|
+
assertIncludes(r.text, "Saved note");
|
|
29
|
+
assertIncludes(r.text, "keyword-only");
|
|
30
|
+
savedId = (r.text.match(/Saved note (\S+)/) || [])[1] || "";
|
|
31
|
+
assert(savedId, "could not parse note id");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("recalls by keyword and tags mode label", async () => {
|
|
35
|
+
const r = await client.callTool("memory_recall", { dir: DEMO, query: "jwt auth cookie" });
|
|
36
|
+
assertIncludes(r.text, "[keyword]");
|
|
37
|
+
assertIncludes(r.text, "JWT login flow");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns no-match gracefully", async () => {
|
|
41
|
+
const r = await client.callTool("memory_recall", { dir: DEMO, query: "kubernetes helm chart xyzzy" });
|
|
42
|
+
assertIncludes(r.text, "No relevant memories");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("lists saved notes", async () => {
|
|
46
|
+
const r = await client.callTool("memory_list", { dir: DEMO });
|
|
47
|
+
assertIncludes(r.text, "JWT login flow");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("gets full note by id", async () => {
|
|
51
|
+
const r = await client.callTool("memory_get", { dir: DEMO, id: savedId });
|
|
52
|
+
assertIncludes(r.text, "httpOnly cookie");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reindex reports gracefully without embeddings", async () => {
|
|
56
|
+
const r = await client.callTool("memory_reindex", { dir: DEMO });
|
|
57
|
+
assertIncludes(r.text, "failed");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("deletes a note", async () => {
|
|
61
|
+
const r = await client.callTool("memory_delete", { dir: DEMO, id: savedId });
|
|
62
|
+
assertIncludes(r.text, "Deleted note");
|
|
63
|
+
const r2 = await client.callTool("memory_list", { dir: DEMO });
|
|
64
|
+
assertIncludes(r2.text, "No memories");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await client.start();
|
|
69
|
+
const { fail } = await run();
|
|
70
|
+
await client.stop();
|
|
71
|
+
rmrf(VAULT);
|
|
72
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Optional online test: LLM rerank via 9router Kiro. Skips cleanly if offline.
|
|
2
|
+
import { McpClient, describe, it, assert, assertIncludes, run, tmpDir, rmrf } from "../_testkit/harness.mjs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import http from "http";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const SERVER = path.join(__dirname, "server.js");
|
|
9
|
+
const KEY = process.env.LLM_API_KEY || process.env.RERANK_KEY || process.env.NINEROUTER_KEY || "";
|
|
10
|
+
const URL_BASE = "http://localhost:20128";
|
|
11
|
+
|
|
12
|
+
function ping() {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const req = http.request(URL_BASE + "/v1/models", { method: "GET", timeout: 4000, headers: { Authorization: `Bearer ${KEY}` } }, (r) => { r.resume(); resolve(r.statusCode === 200); });
|
|
15
|
+
req.on("error", () => resolve(false));
|
|
16
|
+
req.on("timeout", () => { req.destroy(); resolve(false); });
|
|
17
|
+
req.end();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!KEY) { console.log(" ⚠ rerank test skipped (no LLM_API_KEY in env)"); process.exit(0); }
|
|
22
|
+
const online = await ping();
|
|
23
|
+
if (!online) {
|
|
24
|
+
console.log(" ⚠ rerank test skipped (9router not reachable)");
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const VAULT = tmpDir("pm-rr-");
|
|
29
|
+
const DEMO = "/tmp/pm-rr-demo";
|
|
30
|
+
const env = { MEMORY_VAULT_DIR: VAULT, LLM_BASE_URL: URL_BASE, LLM_API_KEY: KEY, EMBED_KEY: "", RERANK_MODEL: "kr/claude-haiku-4.5", RERANK_TIMEOUT_MS: "40000" };
|
|
31
|
+
const client = new McpClient(SERVER, env);
|
|
32
|
+
|
|
33
|
+
describe("project-memory (rerank/online)", () => {
|
|
34
|
+
it("seeds distinct notes", async () => {
|
|
35
|
+
await client.callTool("memory_save", { dir: DEMO, title: "JWT auth flow", content: "login issues JWT; refresh token stored in httpOnly cookie", tags: ["auth"] });
|
|
36
|
+
await client.callTool("memory_save", { dir: DEMO, title: "Dark mode toggle", content: "CSS variables and localStorage for theme switching", tags: ["ui"] });
|
|
37
|
+
await client.callTool("memory_save", { dir: DEMO, title: "Nginx docker setup", content: "Dockerfile and compose for nginx reverse proxy", tags: ["infra"] });
|
|
38
|
+
const r = await client.callTool("memory_list", { dir: DEMO });
|
|
39
|
+
assertIncludes(r.text, "(3)");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("reranks a semantic query to the auth note (no keyword overlap)", async () => {
|
|
43
|
+
const r = await client.callTool("memory_recall", { dir: DEMO, query: "how do users sign in securely with tokens", limit: 1 }, 60000);
|
|
44
|
+
assertIncludes(r.text, "[rerank]");
|
|
45
|
+
assertIncludes(r.text, "JWT auth flow");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await client.start();
|
|
50
|
+
const { fail } = await run();
|
|
51
|
+
await client.stop();
|
|
52
|
+
rmrf(VAULT);
|
|
53
|
+
process.exit(fail > 0 ? 1 : 0);
|
package/run-tests.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runs every server's test.mjs and prints a combined summary.
|
|
4
|
+
* Usage: node run-tests.mjs (from ~/.codex/mcp-servers)
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const SERVERS = ["project-memory", "checkpoint", "context-pack", "devjournal"];
|
|
12
|
+
|
|
13
|
+
function runOne(name) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const test = path.join(__dirname, name, "test.mjs");
|
|
16
|
+
const proc = spawn("node", [test], { stdio: ["ignore", "pipe", "pipe"] });
|
|
17
|
+
let out = "";
|
|
18
|
+
proc.stdout.on("data", (d) => (out += d));
|
|
19
|
+
proc.stderr.on("data", (d) => (out += d));
|
|
20
|
+
proc.on("close", (code) => resolve({ name, code, out }));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const results = [];
|
|
25
|
+
for (const s of SERVERS) {
|
|
26
|
+
console.log(`\n=== ${s} ===`);
|
|
27
|
+
const r = await runOne(s);
|
|
28
|
+
process.stdout.write(r.out);
|
|
29
|
+
results.push(r);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log("\n================ SUMMARY ================");
|
|
33
|
+
let anyFail = false;
|
|
34
|
+
for (const r of results) {
|
|
35
|
+
const ok = r.code === 0;
|
|
36
|
+
if (!ok) anyFail = true;
|
|
37
|
+
const m = r.out.match(/(\d+) passed, (\d+) failed/);
|
|
38
|
+
const stat = m ? `${m[1]} passed, ${m[2]} failed` : (ok ? "ok" : "FAILED");
|
|
39
|
+
console.log(` ${ok ? "✓" : "✗"} ${r.name.padEnd(16)} ${stat}`);
|
|
40
|
+
}
|
|
41
|
+
console.log("========================================");
|
|
42
|
+
process.exit(anyFail ? 1 : 0);
|