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,288 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Backfill v2 — deeper session extraction + correct (original) timestamps.
|
|
4
|
+
*
|
|
5
|
+
* Improvements over v1:
|
|
6
|
+
* - Uses the session's ORIGINAL start timestamp (from session_meta) for both
|
|
7
|
+
* the project-memory note 'created' and the devjournal entry 'ts'.
|
|
8
|
+
* - Extracts richer content: all user prompts, plan steps (update_plan),
|
|
9
|
+
* shell commands run, files touched (apply_patch / write paths / cmd heuristics),
|
|
10
|
+
* counts (turns, commands), and the final agent summary.
|
|
11
|
+
* - Writes a richer Markdown body and richer tags.
|
|
12
|
+
* - Idempotent via its own state file (separate from v1).
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node backfill-sessions-v2.mjs --dry
|
|
16
|
+
* node backfill-sessions-v2.mjs --min-prompts 2 [--wipe]
|
|
17
|
+
*
|
|
18
|
+
* --wipe : delete existing vault+journal entries that came from backfill
|
|
19
|
+
* (kind/type == 'session') before re-importing, so you don't get dupes.
|
|
20
|
+
*/
|
|
21
|
+
import fs from "fs";
|
|
22
|
+
import path from "path";
|
|
23
|
+
import os from "os";
|
|
24
|
+
import { McpClient } from "./_testkit/harness.mjs";
|
|
25
|
+
|
|
26
|
+
const HOME = os.homedir();
|
|
27
|
+
const SESS_ROOT = path.join(HOME, ".codex", "sessions");
|
|
28
|
+
const MEM_ROOT = path.join(HOME, ".codex", "memories");
|
|
29
|
+
const VAULT = path.join(MEM_ROOT, "vault");
|
|
30
|
+
const JOURNAL = path.join(MEM_ROOT, "journal");
|
|
31
|
+
const STATE_FILE = path.join(MEM_ROOT, ".backfill-v2-state.json");
|
|
32
|
+
|
|
33
|
+
const args = process.argv.slice(2);
|
|
34
|
+
const DRY = args.includes("--dry");
|
|
35
|
+
const WIPE = args.includes("--wipe");
|
|
36
|
+
const MIN_PROMPTS = (() => { const i = args.indexOf("--min-prompts"); return i !== -1 ? Number(args[i + 1] || 1) : 1; })();
|
|
37
|
+
|
|
38
|
+
function* walk(dir) {
|
|
39
|
+
let es; try { es = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
40
|
+
for (const e of es) {
|
|
41
|
+
const p = path.join(dir, e.name);
|
|
42
|
+
if (e.isDirectory()) yield* walk(p);
|
|
43
|
+
else if (e.isFile() && e.name.endsWith(".jsonl")) yield p;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function textFromContent(c) {
|
|
47
|
+
if (typeof c === "string") return c;
|
|
48
|
+
if (Array.isArray(c)) return c.map((x) => x?.text || "").join(" ").trim();
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
function clip(s, n) { s = String(s || "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n) + "…" : s; }
|
|
52
|
+
|
|
53
|
+
function firstCmdToken(cmd) {
|
|
54
|
+
// crude: pull the program + maybe subcommand
|
|
55
|
+
const c = String(cmd || "").trim();
|
|
56
|
+
const m = c.match(/^[A-Za-z0-9_./-]+(\s+[A-Za-z0-9_-]+)?/);
|
|
57
|
+
return m ? m[0] : "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function looksLikePath(raw) {
|
|
61
|
+
let p = String(raw || "").trim();
|
|
62
|
+
if (!p) return null;
|
|
63
|
+
// strip surrounding quotes
|
|
64
|
+
p = p.replace(/^['"]|['"]$/g, "");
|
|
65
|
+
// reject obvious non-paths
|
|
66
|
+
if (p.length < 3 || p.length > 200) return null;
|
|
67
|
+
if (/[\s'"`(){}<>|;]/.test(p)) return null; // whitespace / shell / code punctuation
|
|
68
|
+
if (/^-/.test(p)) return null; // flags like -100, -rf
|
|
69
|
+
if (/^\d+$/.test(p)) return null; // pure numbers
|
|
70
|
+
if (/^(https?|ftp):/.test(p)) return null; // urls
|
|
71
|
+
if (p === "/dev/null" || p === "/dev/stdin" || p === "/dev/stdout") return null;
|
|
72
|
+
if (/^[a-z]{1,4}\.(json|status|tipe|score|data|body|text|value|length)$/.test(p)) return null; // method calls: r.json res.body
|
|
73
|
+
if (/\.[A-Za-z]+-[A-Za-z]/.test(p)) return null; // code expr like b.score-a.score
|
|
74
|
+
if (!p.includes("/") && (p.match(/\./g) || []).length >= 2) return null; // chained member access: m.rows.length, e.currentTarget.form
|
|
75
|
+
// JS-ish member access: Foo.bar, a.replace, JSON.stringify (dot but no slash, lowerCamel/UpperCamel)
|
|
76
|
+
const hasSlash = p.includes("/");
|
|
77
|
+
const hasExt = /\.[A-Za-z0-9]{1,6}$/.test(p);
|
|
78
|
+
if (!hasSlash && !hasExt) return null; // must look like a path or have a file extension
|
|
79
|
+
// if it has a dot but no slash, ensure it's a real ext, not method call (e.g. s.replace)
|
|
80
|
+
if (!hasSlash && hasExt) {
|
|
81
|
+
const parts = p.split(".");
|
|
82
|
+
const base = parts[0];
|
|
83
|
+
const ext = parts[parts.length - 1];
|
|
84
|
+
// method/property access like s.replace, k.tipe, r.status, Math.round, JSON.stringify
|
|
85
|
+
if (parts.length === 2 && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(base) && /^[a-z]/.test(ext)) {
|
|
86
|
+
// looks like identifier.method — only accept if it has a known file extension
|
|
87
|
+
const knownExt = ["js","mjs","cjs","ts","tsx","jsx","py","go","rs","java","rb","php","c","cpp","h","sh","json","md","txt","yml","yaml","toml","css","html","sql","env","lock","cfg","ini","xml","csv"];
|
|
88
|
+
if (!knownExt.includes(ext.toLowerCase())) return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// collapse redirect/glob artifacts
|
|
92
|
+
if (/[*?]/.test(p)) return null;
|
|
93
|
+
return p;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseSession(file) {
|
|
97
|
+
let cwd = null, startTs = null, id = null, model = null;
|
|
98
|
+
const userMsgs = [];
|
|
99
|
+
let lastAssistant = "";
|
|
100
|
+
let plan = null;
|
|
101
|
+
const cmds = [];
|
|
102
|
+
const tools = {};
|
|
103
|
+
const files = new Set();
|
|
104
|
+
let turns = 0, reasoningCount = 0;
|
|
105
|
+
|
|
106
|
+
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
|
107
|
+
if (!line.trim()) continue;
|
|
108
|
+
let d; try { d = JSON.parse(line); } catch { continue; }
|
|
109
|
+
const p = d.payload || {};
|
|
110
|
+
if (d.type === "session_meta") { cwd = p.cwd || cwd; startTs = p.timestamp || startTs; id = p.id || id; model = p.model_provider || model; }
|
|
111
|
+
if (d.type === "turn_context") { if (!cwd) cwd = p.cwd || cwd; }
|
|
112
|
+
if (d.type === "event_msg" && p.type === "task_started") turns++;
|
|
113
|
+
if (d.type === "event_msg" && p.type === "agent_reasoning") reasoningCount++;
|
|
114
|
+
if (d.type === "event_msg" && p.type === "user_message") {
|
|
115
|
+
const m = (p.message || "").trim();
|
|
116
|
+
if (m && !m.startsWith("<environment_context") && !m.startsWith("[Context:") &&
|
|
117
|
+
!m.startsWith("<INSTRUCTIONS") && !m.startsWith("# AGENTS.md")) userMsgs.push(m);
|
|
118
|
+
}
|
|
119
|
+
if (d.type === "response_item" && p.type === "message" && p.role === "assistant") {
|
|
120
|
+
const t = textFromContent(p.content);
|
|
121
|
+
if (t) lastAssistant = t;
|
|
122
|
+
}
|
|
123
|
+
if (d.type === "response_item" && p.type === "function_call") {
|
|
124
|
+
const name = p.name || "?";
|
|
125
|
+
tools[name] = (tools[name] || 0) + 1;
|
|
126
|
+
let a = {}; try { a = JSON.parse(p.arguments || "{}"); } catch { /* ignore */ }
|
|
127
|
+
if (name === "exec_command" && a.cmd) cmds.push(a.cmd);
|
|
128
|
+
if (name === "update_plan" && Array.isArray(a.plan)) plan = a.plan.map((s) => s.step);
|
|
129
|
+
// file heuristics
|
|
130
|
+
if (a.path) { const v = looksLikePath(a.path); if (v) files.add(v); }
|
|
131
|
+
if (name === "apply_patch" && typeof a.input === "string") {
|
|
132
|
+
for (const mm of a.input.matchAll(/\*\*\* (?:Add|Update|Delete) File: (.+)/g)) {
|
|
133
|
+
const v = looksLikePath(mm[1]); if (v) files.add(v);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (name === "exec_command" && a.cmd) {
|
|
137
|
+
const fm = String(a.cmd).match(/(?:cat|vim|nano|touch|rm|cp|mv|head|tail|less|sed -n|node|python3?)\s+([^\s|;&>]+)/g) || [];
|
|
138
|
+
for (const x of fm) { const parts = x.split(/\s+/); const cand = looksLikePath(parts[parts.length - 1]); if (cand) files.add(cand); }
|
|
139
|
+
const apply = String(a.cmd).match(/(?:>|>>)\s*([A-Za-z0-9_./-]+)/g) || [];
|
|
140
|
+
for (const x of apply) { const v = looksLikePath(x.replace(/^>+\s*/, "")); if (v) files.add(v); }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!id) id = path.basename(file).replace(/\.jsonl$/, "");
|
|
145
|
+
return { file, id, cwd, startTs, model, userMsgs, lastAssistant, plan, cmds, tools, files: [...files], turns, reasoningCount };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildBody(s) {
|
|
149
|
+
const lines = [];
|
|
150
|
+
lines.push(`Session ${s.id}`);
|
|
151
|
+
lines.push(`Started: ${s.startTs}`);
|
|
152
|
+
lines.push(`Project: ${s.cwd}`);
|
|
153
|
+
lines.push(`Turns: ${s.turns} | prompts: ${s.userMsgs.length} | commands: ${s.cmds.length} | reasoning steps: ${s.reasoningCount}`);
|
|
154
|
+
const toolList = Object.entries(s.tools).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}×${v}`).join(", ");
|
|
155
|
+
if (toolList) lines.push(`Tools: ${toolList}`);
|
|
156
|
+
|
|
157
|
+
if (s.userMsgs.length) {
|
|
158
|
+
lines.push(`\n## Prompts (${s.userMsgs.length})`);
|
|
159
|
+
s.userMsgs.slice(0, 20).forEach((m, i) => lines.push(`${i + 1}. ${clip(m, 220)}`));
|
|
160
|
+
if (s.userMsgs.length > 20) lines.push(`… +${s.userMsgs.length - 20} more`);
|
|
161
|
+
}
|
|
162
|
+
if (s.plan && s.plan.length) {
|
|
163
|
+
lines.push(`\n## Plan`);
|
|
164
|
+
s.plan.slice(0, 20).forEach((p) => lines.push(`- ${clip(p, 120)}`));
|
|
165
|
+
}
|
|
166
|
+
if (s.cmds.length) {
|
|
167
|
+
lines.push(`\n## Commands (${s.cmds.length}, sample)`);
|
|
168
|
+
const sample = s.cmds.slice(0, 25);
|
|
169
|
+
sample.forEach((c) => lines.push(`$ ${clip(c, 160)}`));
|
|
170
|
+
if (s.cmds.length > 25) lines.push(`… +${s.cmds.length - 25} more`);
|
|
171
|
+
}
|
|
172
|
+
if (s.files.length) {
|
|
173
|
+
lines.push(`\n## Files touched (${s.files.length})`);
|
|
174
|
+
s.files.slice(0, 40).forEach((f) => lines.push(`- ${f}`));
|
|
175
|
+
if (s.files.length > 40) lines.push(`… +${s.files.length - 40} more`);
|
|
176
|
+
}
|
|
177
|
+
if (s.lastAssistant) {
|
|
178
|
+
lines.push(`\n## Final assistant note`);
|
|
179
|
+
lines.push(clip(s.lastAssistant, 800));
|
|
180
|
+
}
|
|
181
|
+
return lines.join("\n");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function topicTags(s) {
|
|
185
|
+
// derive a few tags from frequent command tokens
|
|
186
|
+
const freq = {};
|
|
187
|
+
for (const c of s.cmds) { const t = firstCmdToken(c).split(/\s+/)[0]; if (t) freq[t] = (freq[t] || 0) + 1; }
|
|
188
|
+
return Object.entries(freq).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k]) => k);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function loadState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")); } catch { return { done: {} }; } }
|
|
192
|
+
function saveState(st) { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(st, null, 2)); }
|
|
193
|
+
|
|
194
|
+
// ---- wipe old backfill entries (kind/type session) ----
|
|
195
|
+
function wipeSessionEntries() {
|
|
196
|
+
let removedNotes = 0, removedJ = 0;
|
|
197
|
+
// vault
|
|
198
|
+
for (const idxFile of (function* () { for (const d of safeList(VAULT)) yield path.join(VAULT, d, "index.json"); })()) {
|
|
199
|
+
if (!fs.existsSync(idxFile)) continue;
|
|
200
|
+
let idx; try { idx = JSON.parse(fs.readFileSync(idxFile, "utf8")); } catch { continue; }
|
|
201
|
+
const pdir = path.dirname(idxFile);
|
|
202
|
+
for (const [id, n] of Object.entries(idx.notes || {})) {
|
|
203
|
+
if (n.kind === "session") {
|
|
204
|
+
try { fs.unlinkSync(path.join(pdir, n.file)); } catch { /* ignore */ }
|
|
205
|
+
delete idx.notes[id]; removedNotes++;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
|
|
209
|
+
}
|
|
210
|
+
// journal: rewrite jsonl/md without session-type entries
|
|
211
|
+
for (const d of safeList(JOURNAL)) {
|
|
212
|
+
const jdir = path.join(JOURNAL, d);
|
|
213
|
+
const jsonl = path.join(jdir, "journal.jsonl");
|
|
214
|
+
if (!fs.existsSync(jsonl)) continue;
|
|
215
|
+
const kept = [];
|
|
216
|
+
for (const line of fs.readFileSync(jsonl, "utf8").split("\n")) {
|
|
217
|
+
if (!line.trim()) continue;
|
|
218
|
+
let e; try { e = JSON.parse(line); } catch { continue; }
|
|
219
|
+
if (e.type === "session") { removedJ++; continue; }
|
|
220
|
+
kept.push(line);
|
|
221
|
+
}
|
|
222
|
+
fs.writeFileSync(jsonl, kept.length ? kept.join("\n") + "\n" : "");
|
|
223
|
+
// rebuild md mirror minimally
|
|
224
|
+
const md = path.join(jdir, "journal.md");
|
|
225
|
+
const mdLines = kept.map((l) => { const e = JSON.parse(l); return `\n## ${e.ts} — ${e.type}${e.title ? `: ${e.title}` : ""}\n${e.body || ""}\n`; });
|
|
226
|
+
fs.writeFileSync(md, mdLines.join(""));
|
|
227
|
+
}
|
|
228
|
+
return { removedNotes, removedJ };
|
|
229
|
+
}
|
|
230
|
+
function safeList(dir) { try { return fs.readdirSync(dir).filter((n) => fs.statSync(path.join(dir, n)).isDirectory()); } catch { return []; } }
|
|
231
|
+
|
|
232
|
+
// ---- collect ----
|
|
233
|
+
const state = loadState();
|
|
234
|
+
const sessions = [];
|
|
235
|
+
for (const f of walk(SESS_ROOT)) {
|
|
236
|
+
let s; try { s = parseSession(f); } catch { continue; }
|
|
237
|
+
if (!s.cwd) continue;
|
|
238
|
+
if (s.userMsgs.length < MIN_PROMPTS) continue;
|
|
239
|
+
sessions.push(s);
|
|
240
|
+
}
|
|
241
|
+
sessions.sort((a, b) => String(a.startTs).localeCompare(String(b.startTs)));
|
|
242
|
+
|
|
243
|
+
if (WIPE && !DRY) {
|
|
244
|
+
const { removedNotes, removedJ } = wipeSessionEntries();
|
|
245
|
+
console.log(`Wiped ${removedNotes} session notes + ${removedJ} session journal entries.`);
|
|
246
|
+
state.done = {}; // re-import everything fresh
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const pending = sessions.filter((s) => !state.done[s.id]);
|
|
250
|
+
|
|
251
|
+
console.log(`Sessions >= ${MIN_PROMPTS} prompts: ${sessions.length}. Done: ${sessions.length - pending.length}. Pending: ${pending.length}.`);
|
|
252
|
+
|
|
253
|
+
if (DRY) {
|
|
254
|
+
const ex = pending.find((s) => s.cmds.length > 5) || pending[0];
|
|
255
|
+
if (ex) {
|
|
256
|
+
console.log(`\n--- Example deep extraction (${ex.id}) ---`);
|
|
257
|
+
console.log(`date: ${ex.startTs}`);
|
|
258
|
+
console.log(`cwd: ${ex.cwd}`);
|
|
259
|
+
console.log(`turns=${ex.turns} prompts=${ex.userMsgs.length} cmds=${ex.cmds.length} files=${ex.files.length}`);
|
|
260
|
+
console.log(`tags: session,backfill,${ex.startTs?.slice(0,10)},${topicTags(ex).join(",")}`);
|
|
261
|
+
console.log("\n" + buildBody(ex).slice(0, 1200));
|
|
262
|
+
}
|
|
263
|
+
process.exit(0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const env = { MEMORY_VAULT_DIR: VAULT, JOURNAL_DIR: JOURNAL, NINEROUTER_KEY: "", EMBED_KEY: "", RERANK_ENABLED: "0" };
|
|
267
|
+
const mem = new McpClient(path.join(import.meta.dirname, "project-memory", "server.js"), env);
|
|
268
|
+
const jrnl = new McpClient(path.join(import.meta.dirname, "devjournal", "server.js"), env);
|
|
269
|
+
await mem.start(); await jrnl.start();
|
|
270
|
+
|
|
271
|
+
let ok = 0, errs = 0;
|
|
272
|
+
for (const s of pending) {
|
|
273
|
+
const date = (s.startTs || "").slice(0, 10);
|
|
274
|
+
const title = clip(s.userMsgs[0] || "(no prompt)", 90);
|
|
275
|
+
const body = buildBody(s);
|
|
276
|
+
const tags = ["session", "backfill", date, ...topicTags(s)];
|
|
277
|
+
try {
|
|
278
|
+
await mem.callTool("memory_save", { dir: s.cwd, title: `[session ${date}] ${title}`, content: body, tags, kind: "session", created: s.startTs }, 60000);
|
|
279
|
+
await jrnl.callTool("journal_log", { dir: s.cwd, title: `Session ${date}: ${title}`, type: "session", ts: s.startTs,
|
|
280
|
+
body: `turns=${s.turns} cmds=${s.cmds.length} files=${s.files.length} | ${clip(s.userMsgs.join(" | "), 300)}` }, 60000);
|
|
281
|
+
state.done[s.id] = { cwd: s.cwd, ts: s.startTs };
|
|
282
|
+
ok++;
|
|
283
|
+
if (ok % 20 === 0) { saveState(state); console.log(` ...${ok} written`); }
|
|
284
|
+
} catch (e) { errs++; console.log(` ! ${s.id}: ${e.message}`); }
|
|
285
|
+
}
|
|
286
|
+
saveState(state);
|
|
287
|
+
await mem.stop(); await jrnl.stop();
|
|
288
|
+
console.log(`\nDone. Wrote ${ok}, errors ${errs}. State: ${STATE_FILE}`);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "checkpoint-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight file snapshot/restore checkpoints for safe experimentation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "server.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.js",
|
|
9
|
+
"test": "node test.mjs"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checkpoint MCP Server for Codex
|
|
5
|
+
*
|
|
6
|
+
* Local, git-independent file snapshots so a solo dev / vibecoder can
|
|
7
|
+
* experiment freely and roll back instantly. Snapshots are stored outside
|
|
8
|
+
* the project (default ~/.codex/memories/checkpoints/<project-slug>/).
|
|
9
|
+
*
|
|
10
|
+
* Tools: checkpoint_create, checkpoint_list, checkpoint_restore,
|
|
11
|
+
* checkpoint_diff, checkpoint_delete
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import {
|
|
17
|
+
CallToolRequestSchema,
|
|
18
|
+
ListToolsRequestSchema,
|
|
19
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
20
|
+
import fs from "fs/promises";
|
|
21
|
+
import path from "path";
|
|
22
|
+
import os from "os";
|
|
23
|
+
import crypto from "crypto";
|
|
24
|
+
|
|
25
|
+
const ROOT =
|
|
26
|
+
process.env.CHECKPOINT_DIR ||
|
|
27
|
+
path.join(os.homedir(), ".codex", "memories", "checkpoints");
|
|
28
|
+
|
|
29
|
+
const DEFAULT_IGNORE = new Set([
|
|
30
|
+
"node_modules", ".git", ".next", "dist", "build", "target", ".venv",
|
|
31
|
+
"venv", "__pycache__", ".cache", ".turbo", "coverage", ".DS_Store",
|
|
32
|
+
".codex", "vendor", ".idea", ".gradle",
|
|
33
|
+
]);
|
|
34
|
+
const MAX_FILE_BYTES = 2_000_000;
|
|
35
|
+
const MAX_FILES = 4000;
|
|
36
|
+
|
|
37
|
+
function slug(dir) {
|
|
38
|
+
const resolved = path.resolve(dir || process.cwd());
|
|
39
|
+
const base = path.basename(resolved) || "root";
|
|
40
|
+
const hash = crypto.createHash("sha1").update(resolved).digest("hex").slice(0, 8);
|
|
41
|
+
return `${base.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 40)}-${hash}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nowIso() { return new Date().toISOString(); }
|
|
45
|
+
function cpId() {
|
|
46
|
+
return new Date().toISOString().replace(/[:.]/g, "").slice(0, 15) + "-" + crypto.randomBytes(2).toString("hex");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function walk(base, rel = "", out = []) {
|
|
50
|
+
const abs = path.join(base, rel);
|
|
51
|
+
let entries;
|
|
52
|
+
try { entries = await fs.readdir(abs, { withFileTypes: true }); } catch { return out; }
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
if (DEFAULT_IGNORE.has(e.name)) continue;
|
|
55
|
+
const childRel = path.join(rel, e.name);
|
|
56
|
+
if (e.isDirectory()) {
|
|
57
|
+
await walk(base, childRel, out);
|
|
58
|
+
} else if (e.isFile()) {
|
|
59
|
+
if (out.length >= MAX_FILES) break;
|
|
60
|
+
out.push(childRel);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hashContent(buf) {
|
|
67
|
+
return crypto.createHash("sha1").update(buf).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class CheckpointServer {
|
|
71
|
+
constructor() {
|
|
72
|
+
this.server = new Server(
|
|
73
|
+
{ name: "checkpoint", version: "1.0.0" },
|
|
74
|
+
{ capabilities: { tools: {} } }
|
|
75
|
+
);
|
|
76
|
+
this.setup();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
paths(dir) {
|
|
80
|
+
const s = slug(dir);
|
|
81
|
+
const projectDir = path.join(ROOT, s);
|
|
82
|
+
return {
|
|
83
|
+
slug: s,
|
|
84
|
+
root: path.resolve(dir || process.cwd()),
|
|
85
|
+
projectDir,
|
|
86
|
+
manifest: path.join(projectDir, "manifest.json"),
|
|
87
|
+
snaps: path.join(projectDir, "snapshots"),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async loadManifest(p) {
|
|
92
|
+
try { return JSON.parse(await fs.readFile(p.manifest, "utf8")); }
|
|
93
|
+
catch { return { project: p.slug, root: p.root, checkpoints: {} }; }
|
|
94
|
+
}
|
|
95
|
+
async saveManifest(p, m) {
|
|
96
|
+
await fs.mkdir(p.projectDir, { recursive: true });
|
|
97
|
+
await fs.writeFile(p.manifest, JSON.stringify(m, null, 2));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setup() {
|
|
101
|
+
this.server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
102
|
+
tools: [
|
|
103
|
+
{
|
|
104
|
+
name: "checkpoint_create",
|
|
105
|
+
description: "Snapshot all text files in a project directory (git-independent). Use before risky/experimental changes so you can roll back.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: {
|
|
109
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
110
|
+
label: { type: "string", description: "Short label for this checkpoint" },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "checkpoint_list",
|
|
116
|
+
description: "List checkpoints for a project (newest first).",
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: "object",
|
|
119
|
+
properties: { dir: { type: "string", description: "Project directory (defaults to CWD)" } },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "checkpoint_restore",
|
|
124
|
+
description: "Restore project files to a checkpoint. By default only restores changed/deleted files; pass clean=true to also remove files added since the checkpoint.",
|
|
125
|
+
inputSchema: {
|
|
126
|
+
type: "object",
|
|
127
|
+
properties: {
|
|
128
|
+
id: { type: "string", description: "Checkpoint id" },
|
|
129
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
130
|
+
clean: { type: "boolean", description: "Delete files created after the checkpoint", default: false },
|
|
131
|
+
},
|
|
132
|
+
required: ["id"],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "checkpoint_diff",
|
|
137
|
+
description: "Summarize what changed between a checkpoint and current files (added/modified/deleted lists).",
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: "object",
|
|
140
|
+
properties: {
|
|
141
|
+
id: { type: "string", description: "Checkpoint id" },
|
|
142
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
143
|
+
},
|
|
144
|
+
required: ["id"],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "checkpoint_delete",
|
|
149
|
+
description: "Delete a checkpoint by id.",
|
|
150
|
+
inputSchema: {
|
|
151
|
+
type: "object",
|
|
152
|
+
properties: {
|
|
153
|
+
id: { type: "string", description: "Checkpoint id" },
|
|
154
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
155
|
+
},
|
|
156
|
+
required: ["id"],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
163
|
+
const { name, arguments: args } = req.params;
|
|
164
|
+
try {
|
|
165
|
+
switch (name) {
|
|
166
|
+
case "checkpoint_create": return await this.create(args || {});
|
|
167
|
+
case "checkpoint_list": return await this.list(args || {});
|
|
168
|
+
case "checkpoint_restore": return await this.restore(args || {});
|
|
169
|
+
case "checkpoint_diff": return await this.diff(args || {});
|
|
170
|
+
case "checkpoint_delete": return await this.del(args || {});
|
|
171
|
+
default: throw new Error(`Unknown tool: ${name}`);
|
|
172
|
+
}
|
|
173
|
+
} catch (e) {
|
|
174
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async create({ dir, label }) {
|
|
180
|
+
const p = this.paths(dir);
|
|
181
|
+
const files = await walk(p.root);
|
|
182
|
+
const id = cpId();
|
|
183
|
+
const snapDir = path.join(p.snaps, id);
|
|
184
|
+
await fs.mkdir(snapDir, { recursive: true });
|
|
185
|
+
|
|
186
|
+
const records = {};
|
|
187
|
+
let skipped = 0, stored = 0;
|
|
188
|
+
for (const rel of files) {
|
|
189
|
+
const abs = path.join(p.root, rel);
|
|
190
|
+
let stat;
|
|
191
|
+
try { stat = await fs.stat(abs); } catch { continue; }
|
|
192
|
+
if (stat.size > MAX_FILE_BYTES) { skipped++; continue; }
|
|
193
|
+
const buf = await fs.readFile(abs);
|
|
194
|
+
if (buf.includes(0)) { skipped++; continue; } // skip binary
|
|
195
|
+
const h = hashContent(buf);
|
|
196
|
+
const dest = path.join(snapDir, h);
|
|
197
|
+
await fs.writeFile(dest, buf).catch(() => {});
|
|
198
|
+
records[rel] = { hash: h, size: stat.size };
|
|
199
|
+
stored++;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const manifest = await this.loadManifest(p);
|
|
203
|
+
manifest.checkpoints[id] = { id, label: label || "", created: nowIso(), files: records, stored, skipped };
|
|
204
|
+
await this.saveManifest(p, manifest);
|
|
205
|
+
return { content: [{ type: "text", text: `Checkpoint ${id} created for ${p.slug}\nLabel: ${label || "(none)"}\nFiles stored: ${stored}${skipped ? `, skipped(binary/large): ${skipped}` : ""}` }] };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async list({ dir }) {
|
|
209
|
+
const p = this.paths(dir);
|
|
210
|
+
const m = await this.loadManifest(p);
|
|
211
|
+
const cps = Object.values(m.checkpoints || {}).sort((a, b) => (a.created < b.created ? 1 : -1));
|
|
212
|
+
if (!cps.length) return { content: [{ type: "text", text: `No checkpoints for ${p.slug} yet.` }] };
|
|
213
|
+
const lines = cps.map((c) => `- ${c.id} ${c.label ? `"${c.label}" ` : ""}(${c.stored} files) — ${c.created}`);
|
|
214
|
+
return { content: [{ type: "text", text: `Checkpoints in ${p.slug} (${cps.length}):\n${lines.join("\n")}` }] };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async computeDiff(p, cp) {
|
|
218
|
+
const current = await walk(p.root);
|
|
219
|
+
const currentSet = new Set(current);
|
|
220
|
+
const added = [], modified = [], deleted = [];
|
|
221
|
+
for (const rel of current) {
|
|
222
|
+
if (!cp.files[rel]) { added.push(rel); continue; }
|
|
223
|
+
const abs = path.join(p.root, rel);
|
|
224
|
+
try {
|
|
225
|
+
const buf = await fs.readFile(abs);
|
|
226
|
+
if (hashContent(buf) !== cp.files[rel].hash) modified.push(rel);
|
|
227
|
+
} catch { /* ignore */ }
|
|
228
|
+
}
|
|
229
|
+
for (const rel of Object.keys(cp.files)) {
|
|
230
|
+
if (!currentSet.has(rel)) deleted.push(rel);
|
|
231
|
+
}
|
|
232
|
+
return { added, modified, deleted };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async diff({ id, dir }) {
|
|
236
|
+
const p = this.paths(dir);
|
|
237
|
+
const m = await this.loadManifest(p);
|
|
238
|
+
const cp = m.checkpoints?.[id];
|
|
239
|
+
if (!cp) throw new Error(`Checkpoint ${id} not found in ${p.slug}`);
|
|
240
|
+
const d = await this.computeDiff(p, cp);
|
|
241
|
+
const fmt = (arr) => arr.length ? arr.slice(0, 100).map((x) => ` ${x}`).join("\n") : " (none)";
|
|
242
|
+
return { content: [{ type: "text", text:
|
|
243
|
+
`Diff vs ${id} (${cp.created}) in ${p.slug}:\n` +
|
|
244
|
+
`Added (${d.added.length}):\n${fmt(d.added)}\n` +
|
|
245
|
+
`Modified (${d.modified.length}):\n${fmt(d.modified)}\n` +
|
|
246
|
+
`Deleted (${d.deleted.length}):\n${fmt(d.deleted)}` }] };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async restore({ id, dir, clean = false }) {
|
|
250
|
+
const p = this.paths(dir);
|
|
251
|
+
const m = await this.loadManifest(p);
|
|
252
|
+
const cp = m.checkpoints?.[id];
|
|
253
|
+
if (!cp) throw new Error(`Checkpoint ${id} not found in ${p.slug}`);
|
|
254
|
+
const snapDir = path.join(p.snaps, id);
|
|
255
|
+
let restored = 0, removed = 0;
|
|
256
|
+
for (const [rel, rec] of Object.entries(cp.files)) {
|
|
257
|
+
const src = path.join(snapDir, rec.hash);
|
|
258
|
+
const dest = path.join(p.root, rel);
|
|
259
|
+
try {
|
|
260
|
+
const buf = await fs.readFile(src);
|
|
261
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
262
|
+
await fs.writeFile(dest, buf);
|
|
263
|
+
restored++;
|
|
264
|
+
} catch { /* ignore */ }
|
|
265
|
+
}
|
|
266
|
+
if (clean) {
|
|
267
|
+
const d = await this.computeDiff(p, cp);
|
|
268
|
+
for (const rel of d.added) {
|
|
269
|
+
await fs.unlink(path.join(p.root, rel)).catch(() => {});
|
|
270
|
+
removed++;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { content: [{ type: "text", text: `Restored ${restored} files from ${id}${clean ? `, removed ${removed} newer files` : ""} in ${p.slug}` }] };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async del({ id, dir }) {
|
|
277
|
+
const p = this.paths(dir);
|
|
278
|
+
const m = await this.loadManifest(p);
|
|
279
|
+
if (!m.checkpoints?.[id]) throw new Error(`Checkpoint ${id} not found in ${p.slug}`);
|
|
280
|
+
await fs.rm(path.join(p.snaps, id), { recursive: true, force: true }).catch(() => {});
|
|
281
|
+
delete m.checkpoints[id];
|
|
282
|
+
await this.saveManifest(p, m);
|
|
283
|
+
return { content: [{ type: "text", text: `Deleted checkpoint ${id} from ${p.slug}` }] };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async run() {
|
|
287
|
+
const t = new StdioServerTransport();
|
|
288
|
+
await this.server.connect(t);
|
|
289
|
+
console.error(`Checkpoint MCP server running on stdio (store: ${ROOT})`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
new CheckpointServer().run().catch(console.error);
|