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,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dev Journal MCP Server for Codex
|
|
5
|
+
*
|
|
6
|
+
* Per-project session timeline + handoff so you never lose your place when a
|
|
7
|
+
* session hits "input too long" / gets compacted / you start fresh.
|
|
8
|
+
*
|
|
9
|
+
* Core idea:
|
|
10
|
+
* - journal_handoff: at end of a work block, store "what I did + open
|
|
11
|
+
* questions + next steps + active files". One canonical resume point.
|
|
12
|
+
* - journal_resume: at start of a new session, get the latest handoff +
|
|
13
|
+
* recent entries, so you (or the agent) can continue immediately.
|
|
14
|
+
*
|
|
15
|
+
* Storage: append-only JSONL per project (default
|
|
16
|
+
* ~/.codex/memories/journal/<project-slug>/journal.jsonl) + a
|
|
17
|
+
* handoff.json for the latest canonical resume state. Human-readable
|
|
18
|
+
* journal.md mirror is also maintained.
|
|
19
|
+
*
|
|
20
|
+
* Tools: journal_log, journal_handoff, journal_resume,
|
|
21
|
+
* journal_timeline, journal_clear_handoff
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
25
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
26
|
+
import {
|
|
27
|
+
CallToolRequestSchema,
|
|
28
|
+
ListToolsRequestSchema,
|
|
29
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
30
|
+
import fs from "fs/promises";
|
|
31
|
+
import path from "path";
|
|
32
|
+
import os from "os";
|
|
33
|
+
import crypto from "crypto";
|
|
34
|
+
import { rerank, rerankConfig } from "./rerank.js";
|
|
35
|
+
|
|
36
|
+
const ROOT =
|
|
37
|
+
process.env.JOURNAL_DIR ||
|
|
38
|
+
path.join(os.homedir(), ".codex", "memories", "journal");
|
|
39
|
+
|
|
40
|
+
const MAX_TEXT = 20_000;
|
|
41
|
+
|
|
42
|
+
function slug(dir) {
|
|
43
|
+
const resolved = path.resolve(dir || process.cwd());
|
|
44
|
+
const base = path.basename(resolved) || "root";
|
|
45
|
+
const hash = crypto.createHash("sha1").update(resolved).digest("hex").slice(0, 8);
|
|
46
|
+
return `${base.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 40)}-${hash}`;
|
|
47
|
+
}
|
|
48
|
+
function nowIso() { return new Date().toISOString(); }
|
|
49
|
+
function clip(v, name, max, req = true) {
|
|
50
|
+
const t = String(v ?? "");
|
|
51
|
+
if (req && !t.trim()) throw new Error(`${name} is required`);
|
|
52
|
+
if (t.length > max) throw new Error(`${name} exceeds ${max} chars`);
|
|
53
|
+
return t;
|
|
54
|
+
}
|
|
55
|
+
function asList(v) {
|
|
56
|
+
if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean);
|
|
57
|
+
if (typeof v === "string" && v.trim()) return v.split("\n").map((s) => s.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class DevJournalServer {
|
|
62
|
+
constructor() {
|
|
63
|
+
this.server = new Server({ name: "devjournal", version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
64
|
+
this.setup();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
paths(dir) {
|
|
68
|
+
const s = slug(dir);
|
|
69
|
+
const d = path.join(ROOT, s);
|
|
70
|
+
return {
|
|
71
|
+
slug: s,
|
|
72
|
+
root: path.resolve(dir || process.cwd()),
|
|
73
|
+
dir: d,
|
|
74
|
+
jsonl: path.join(d, "journal.jsonl"),
|
|
75
|
+
md: path.join(d, "journal.md"),
|
|
76
|
+
handoff: path.join(d, "handoff.json"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async append(p, entry) {
|
|
81
|
+
await fs.mkdir(p.dir, { recursive: true });
|
|
82
|
+
await fs.appendFile(p.jsonl, JSON.stringify(entry) + "\n");
|
|
83
|
+
const md = `\n## ${entry.ts} — ${entry.type}${entry.title ? `: ${entry.title}` : ""}\n${entry.body || ""}\n`;
|
|
84
|
+
await fs.appendFile(p.md, md);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async readEntries(p, limit = 50) {
|
|
88
|
+
try {
|
|
89
|
+
const raw = await fs.readFile(p.jsonl, "utf8");
|
|
90
|
+
const rows = raw.split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
91
|
+
return rows.slice(-limit);
|
|
92
|
+
} catch { return []; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setup() {
|
|
96
|
+
this.server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
97
|
+
tools: [
|
|
98
|
+
{
|
|
99
|
+
name: "journal_log",
|
|
100
|
+
description: "Append a quick timestamped entry (note/decision/blocker/done) to the project journal.",
|
|
101
|
+
inputSchema: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
title: { type: "string", description: "Short summary" },
|
|
105
|
+
body: { type: "string", description: "Details (Markdown ok)" },
|
|
106
|
+
type: { type: "string", description: "note | decision | blocker | done | idea", default: "note" },
|
|
107
|
+
ts: { type: "string", description: "Optional ISO timestamp to backdate the entry" },
|
|
108
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
109
|
+
},
|
|
110
|
+
required: ["title"],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "journal_handoff",
|
|
115
|
+
description: "Save the canonical resume point for this project: what was done, open questions, next steps, and active files. Call before ending a session or when context is about to be compacted.",
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: "object",
|
|
118
|
+
properties: {
|
|
119
|
+
summary: { type: "string", description: "What was accomplished this session" },
|
|
120
|
+
next_steps: { type: "array", items: { type: "string" }, description: "Ordered next actions" },
|
|
121
|
+
open_questions: { type: "array", items: { type: "string" }, description: "Unresolved questions" },
|
|
122
|
+
active_files: { type: "array", items: { type: "string" }, description: "Files currently in focus" },
|
|
123
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
124
|
+
},
|
|
125
|
+
required: ["summary"],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "journal_resume",
|
|
130
|
+
description: "Get the latest handoff plus recent journal entries to resume work in a fresh session. Call this FIRST in a new session instead of re-pasting context.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: {
|
|
134
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
135
|
+
recent: { type: "number", description: "How many recent entries to include", default: 8 },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "journal_timeline",
|
|
141
|
+
description: "Show recent journal entries (newest last) for the project.",
|
|
142
|
+
inputSchema: {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
146
|
+
limit: { type: "number", description: "Max entries", default: 20 },
|
|
147
|
+
type: { type: "string", description: "Filter by type (optional)" },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "journal_search",
|
|
153
|
+
description: "Find the most relevant journal entries for a query. Uses keyword prefilter + LLM rerank (9router Kiro) when available, else keyword scoring. Use to recall what you did about a topic across past sessions.",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: {
|
|
157
|
+
query: { type: "string", description: "What you're looking for" },
|
|
158
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
159
|
+
limit: { type: "number", description: "Max entries", default: 5 },
|
|
160
|
+
},
|
|
161
|
+
required: ["query"],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "journal_clear_handoff",
|
|
166
|
+
description: "Clear the current canonical handoff (e.g., after fully resuming).",
|
|
167
|
+
inputSchema: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: { dir: { type: "string", description: "Project directory (defaults to CWD)" } },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
176
|
+
const { name, arguments: args } = req.params;
|
|
177
|
+
try {
|
|
178
|
+
switch (name) {
|
|
179
|
+
case "journal_log": return await this.log(args || {});
|
|
180
|
+
case "journal_handoff": return await this.doHandoff(args || {});
|
|
181
|
+
case "journal_resume": return await this.resume(args || {});
|
|
182
|
+
case "journal_timeline": return await this.timeline(args || {});
|
|
183
|
+
case "journal_search": return await this.search(args || {});
|
|
184
|
+
case "journal_clear_handoff": return await this.clearHandoff(args || {});
|
|
185
|
+
default: throw new Error(`Unknown tool: ${name}`);
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async log({ title, body, type = "note", dir, ts: tsArg }) {
|
|
194
|
+
title = clip(title, "title", 500);
|
|
195
|
+
body = clip(body, "body", MAX_TEXT, false);
|
|
196
|
+
const p = this.paths(dir);
|
|
197
|
+
const ts = (tsArg && /^\d{4}-\d{2}-\d{2}/.test(String(tsArg))) ? String(tsArg) : nowIso();
|
|
198
|
+
const entry = { ts, type, title, body };
|
|
199
|
+
await this.append(p, entry);
|
|
200
|
+
return { content: [{ type: "text", text: `Logged [${type}] "${title}" to ${p.slug}` }] };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async doHandoff({ summary, next_steps, open_questions, active_files, dir }) {
|
|
204
|
+
summary = clip(summary, "summary", MAX_TEXT);
|
|
205
|
+
const p = this.paths(dir);
|
|
206
|
+
const handoff = {
|
|
207
|
+
ts: nowIso(),
|
|
208
|
+
summary,
|
|
209
|
+
next_steps: asList(next_steps),
|
|
210
|
+
open_questions: asList(open_questions),
|
|
211
|
+
active_files: asList(active_files),
|
|
212
|
+
};
|
|
213
|
+
await fs.mkdir(p.dir, { recursive: true });
|
|
214
|
+
await fs.writeFile(p.handoff, JSON.stringify(handoff, null, 2));
|
|
215
|
+
await this.append(p, { ts: handoff.ts, type: "handoff", title: "Session handoff", body: summary });
|
|
216
|
+
return { content: [{ type: "text", text: `Handoff saved for ${p.slug}\nNext steps: ${handoff.next_steps.length}, open questions: ${handoff.open_questions.length}, active files: ${handoff.active_files.length}` }] };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async resume({ dir, recent = 8 }) {
|
|
220
|
+
const p = this.paths(dir);
|
|
221
|
+
let handoff = null;
|
|
222
|
+
try { handoff = JSON.parse(await fs.readFile(p.handoff, "utf8")); } catch { /* none */ }
|
|
223
|
+
const entries = await this.readEntries(p, Math.max(1, Math.min(50, recent)));
|
|
224
|
+
|
|
225
|
+
if (!handoff && entries.length === 0) {
|
|
226
|
+
return { content: [{ type: "text", text: `No journal for ${p.slug} yet. Use journal_handoff / journal_log to start.` }] };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const out = [`# Resume: ${p.slug}`, `Project path: ${p.root}`];
|
|
230
|
+
if (handoff) {
|
|
231
|
+
out.push(`\n## Latest handoff (${handoff.ts})`);
|
|
232
|
+
out.push(handoff.summary);
|
|
233
|
+
if (handoff.next_steps?.length) out.push(`\n### Next steps\n` + handoff.next_steps.map((s, i) => `${i + 1}. ${s}`).join("\n"));
|
|
234
|
+
if (handoff.open_questions?.length) out.push(`\n### Open questions\n` + handoff.open_questions.map((s) => `- ${s}`).join("\n"));
|
|
235
|
+
if (handoff.active_files?.length) out.push(`\n### Active files\n` + handoff.active_files.map((s) => `- ${s}`).join("\n"));
|
|
236
|
+
} else {
|
|
237
|
+
out.push(`\n(No canonical handoff saved — showing recent entries only.)`);
|
|
238
|
+
}
|
|
239
|
+
if (entries.length) {
|
|
240
|
+
out.push(`\n## Recent entries`);
|
|
241
|
+
out.push(entries.map((e) => `- ${e.ts} [${e.type}] ${e.title}`).join("\n"));
|
|
242
|
+
}
|
|
243
|
+
return { content: [{ type: "text", text: out.join("\n") }] };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async timeline({ dir, limit = 20, type }) {
|
|
247
|
+
const p = this.paths(dir);
|
|
248
|
+
let entries = await this.readEntries(p, 200);
|
|
249
|
+
if (type) entries = entries.filter((e) => e.type === type);
|
|
250
|
+
entries = entries.slice(-Math.max(1, Math.min(200, limit)));
|
|
251
|
+
if (!entries.length) return { content: [{ type: "text", text: `No journal entries for ${p.slug}${type ? ` of type ${type}` : ""}.` }] };
|
|
252
|
+
const lines = entries.map((e) => `- ${e.ts} [${e.type}] ${e.title}${e.body ? `\n ${e.body.split("\n")[0].slice(0, 120)}` : ""}`);
|
|
253
|
+
return { content: [{ type: "text", text: `Timeline for ${p.slug} (${entries.length}):\n${lines.join("\n")}` }] };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async search({ query, dir, limit = 5 }) {
|
|
257
|
+
query = clip(query, "query", 2000);
|
|
258
|
+
const p = this.paths(dir);
|
|
259
|
+
const entries = await this.readEntries(p, 200);
|
|
260
|
+
if (!entries.length) return { content: [{ type: "text", text: `No journal entries for ${p.slug}.` }] };
|
|
261
|
+
|
|
262
|
+
const want = Math.max(1, Math.min(20, limit));
|
|
263
|
+
const q = new Set((query.toLowerCase().match(/[a-z0-9_./-]{2,}/g) || []));
|
|
264
|
+
const score = (e) => {
|
|
265
|
+
let sc = 0;
|
|
266
|
+
const hay = `${e.title || ""} ${e.body || ""} ${e.type || ""}`.toLowerCase();
|
|
267
|
+
const toks = new Set(hay.match(/[a-z0-9_./-]{2,}/g) || []);
|
|
268
|
+
for (const t of q) if (toks.has(t)) sc += 1;
|
|
269
|
+
return sc;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
let ranked = entries.map((e, i) => ({ e, i, sc: score(e) }))
|
|
273
|
+
.sort((a, b) => b.sc - a.sc || b.i - a.i);
|
|
274
|
+
let pool = ranked.filter((x) => x.sc > 0).slice(0, 20);
|
|
275
|
+
let mode = "keyword";
|
|
276
|
+
if (pool.length === 0 && rerankConfig().enabled) {
|
|
277
|
+
pool = ranked.slice(0, 20); // fall back to recent for semantic-style queries
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let result = pool.slice(0, want).map((x) => x.e);
|
|
281
|
+
if (rerankConfig().enabled && pool.length > 1) {
|
|
282
|
+
const cands = pool.map((x) => ({ id: String(x.i), title: x.e.title || "(no title)", snippet: x.e.body || x.e.type || "" }));
|
|
283
|
+
const order = await rerank(query, cands, want);
|
|
284
|
+
if (order) {
|
|
285
|
+
mode = "rerank";
|
|
286
|
+
const byId = new Map(pool.map((x) => [String(x.i), x.e]));
|
|
287
|
+
const picked = order.map((id) => byId.get(id)).filter(Boolean).slice(0, want);
|
|
288
|
+
if (picked.length) result = picked;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!result.length) return { content: [{ type: "text", text: `No relevant journal entries for "${query}" in ${p.slug}.` }] };
|
|
293
|
+
const lines = result.map((e) => `- ${e.ts} [${e.type}] ${e.title}${e.body ? `\n ${e.body.split("\n")[0].slice(0, 160)}` : ""}`);
|
|
294
|
+
return { content: [{ type: "text", text: `Journal search for "${query}" in ${p.slug} [${mode}]:\n${lines.join("\n")}` }] };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async clearHandoff({ dir }) {
|
|
298
|
+
const p = this.paths(dir);
|
|
299
|
+
await fs.unlink(p.handoff).catch(() => {});
|
|
300
|
+
return { content: [{ type: "text", text: `Cleared handoff for ${p.slug}` }] };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async run() {
|
|
304
|
+
const t = new StdioServerTransport();
|
|
305
|
+
await this.server.connect(t);
|
|
306
|
+
console.error(`Dev Journal MCP server running on stdio (store: ${ROOT})`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
new DevJournalServer().run().catch(console.error);
|
|
@@ -0,0 +1,68 @@
|
|
|
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 STORE = tmpDir("jrnl-");
|
|
8
|
+
const PROJ = "/tmp/jrnl-demo-project";
|
|
9
|
+
|
|
10
|
+
const client = new McpClient(SERVER, { JOURNAL_DIR: STORE });
|
|
11
|
+
|
|
12
|
+
describe("devjournal", () => {
|
|
13
|
+
it("lists expected tools", async () => {
|
|
14
|
+
const tools = await client.listTools();
|
|
15
|
+
for (const t of ["journal_log", "journal_handoff", "journal_resume", "journal_timeline", "journal_search", "journal_clear_handoff"])
|
|
16
|
+
assert(tools.includes(t), `missing ${t}`);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("logs entries", async () => {
|
|
20
|
+
const r1 = await client.callTool("journal_log", { dir: PROJ, title: "Set up auth", type: "done", body: "login + AuthService" });
|
|
21
|
+
assertIncludes(r1.text, "Logged [done]");
|
|
22
|
+
const r2 = await client.callTool("journal_log", { dir: PROJ, title: "Refresh undecided", type: "blocker" });
|
|
23
|
+
assertIncludes(r2.text, "Logged [blocker]");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("saves a handoff", async () => {
|
|
27
|
+
const r = await client.callTool("journal_handoff", {
|
|
28
|
+
dir: PROJ, summary: "Auth scaffolding done; wiring refresh next.",
|
|
29
|
+
next_steps: ["Implement refresh", "Add tests"],
|
|
30
|
+
open_questions: ["Cookie or header?"], active_files: ["src/auth.ts"],
|
|
31
|
+
});
|
|
32
|
+
assertIncludes(r.text, "Handoff saved");
|
|
33
|
+
assertIncludes(r.text, "Next steps: 2");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("resume returns handoff + recent entries", async () => {
|
|
37
|
+
const r = await client.callTool("journal_resume", { dir: PROJ });
|
|
38
|
+
assertIncludes(r.text, "Latest handoff");
|
|
39
|
+
assertIncludes(r.text, "Implement refresh");
|
|
40
|
+
assertIncludes(r.text, "Cookie or header?");
|
|
41
|
+
assertIncludes(r.text, "Recent entries");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("timeline filters by type", async () => {
|
|
45
|
+
const r = await client.callTool("journal_timeline", { dir: PROJ, type: "blocker" });
|
|
46
|
+
assertIncludes(r.text, "Refresh undecided");
|
|
47
|
+
assert(!r.text.includes("Set up auth"), "should not include done entries when filtering blocker");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("search finds entries by keyword (offline mode)", async () => {
|
|
51
|
+
const r = await client.callTool("journal_search", { dir: PROJ, query: "auth login refresh" });
|
|
52
|
+
assertIncludes(r.text, "[keyword]");
|
|
53
|
+
assertIncludes(r.text, "Set up auth");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("clears handoff", async () => {
|
|
57
|
+
const r = await client.callTool("journal_clear_handoff", { dir: PROJ });
|
|
58
|
+
assertIncludes(r.text, "Cleared handoff");
|
|
59
|
+
const r2 = await client.callTool("journal_resume", { dir: PROJ });
|
|
60
|
+
assert(!r2.text.includes("Latest handoff"), "handoff should be gone");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await client.start();
|
|
65
|
+
const { fail } = await run();
|
|
66
|
+
await client.stop();
|
|
67
|
+
rmrf(STORE);
|
|
68
|
+
process.exit(fail > 0 ? 1 : 0);
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codex-dev-mcp-suite",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Four local, file-based MCP servers for solo devs/vibecoders: persistent project memory, session handoff/resume, git-independent file checkpoints, and token-efficient project briefings. Works with any MCP client.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "maximalabs",
|
|
8
|
+
"homepage": "https://github.com/verrysimatupang99/codex-dev-mcp-suite#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/verrysimatupang99/codex-dev-mcp-suite.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/verrysimatupang99/codex-dev-mcp-suite/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"codex",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"cursor",
|
|
22
|
+
"ai-memory",
|
|
23
|
+
"developer-tools",
|
|
24
|
+
"llm",
|
|
25
|
+
"context",
|
|
26
|
+
"vibecoding"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"bin": {
|
|
32
|
+
"project-memory-mcp": "bin/project-memory.mjs",
|
|
33
|
+
"devjournal-mcp": "bin/devjournal.mjs",
|
|
34
|
+
"checkpoint-mcp": "bin/checkpoint.mjs",
|
|
35
|
+
"context-pack-mcp": "bin/context-pack.mjs"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node run-tests.mjs"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"bin/*.mjs",
|
|
45
|
+
"project-memory/*.js",
|
|
46
|
+
"project-memory/*.mjs",
|
|
47
|
+
"project-memory/package.json",
|
|
48
|
+
"devjournal/*.js",
|
|
49
|
+
"devjournal/*.mjs",
|
|
50
|
+
"devjournal/package.json",
|
|
51
|
+
"checkpoint/*.js",
|
|
52
|
+
"checkpoint/*.mjs",
|
|
53
|
+
"checkpoint/package.json",
|
|
54
|
+
"context-pack/*.js",
|
|
55
|
+
"context-pack/*.mjs",
|
|
56
|
+
"context-pack/package.json",
|
|
57
|
+
"_testkit/*.mjs",
|
|
58
|
+
"run-tests.mjs",
|
|
59
|
+
"backfill-sessions-v2.mjs",
|
|
60
|
+
"CODEX_DEV_MCP_GUIDE.md",
|
|
61
|
+
"README.md",
|
|
62
|
+
"LICENSE",
|
|
63
|
+
".env.example"
|
|
64
|
+
]
|
|
65
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding helper for 9router (OpenAI-compatible /v1/embeddings).
|
|
3
|
+
* Degrades gracefully: returns null on any failure so callers fall back
|
|
4
|
+
* to keyword search. Never throws.
|
|
5
|
+
*/
|
|
6
|
+
import http from "http";
|
|
7
|
+
import https from "https";
|
|
8
|
+
import { URL } from "url";
|
|
9
|
+
|
|
10
|
+
const BASE = process.env.LLM_BASE_URL || process.env.EMBED_URL || process.env.NINEROUTER_URL || "http://localhost:20128";
|
|
11
|
+
const KEY = process.env.LLM_API_KEY || process.env.EMBED_KEY || process.env.NINEROUTER_KEY || "";
|
|
12
|
+
const MODEL = process.env.EMBED_MODEL || "bm/baai/bge-m3";
|
|
13
|
+
const TIMEOUT_MS = Number(process.env.EMBED_TIMEOUT_MS || 15000);
|
|
14
|
+
|
|
15
|
+
export function embeddingConfig() {
|
|
16
|
+
return { base: BASE, model: MODEL, enabled: Boolean(KEY) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function postJson(urlStr, payload) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
let u;
|
|
22
|
+
try { u = new URL("/v1/embeddings", urlStr); } catch { return resolve(null); }
|
|
23
|
+
const data = Buffer.from(JSON.stringify(payload));
|
|
24
|
+
const lib = u.protocol === "https:" ? https : http;
|
|
25
|
+
const req = lib.request(
|
|
26
|
+
{
|
|
27
|
+
hostname: u.hostname,
|
|
28
|
+
port: u.port || (u.protocol === "https:" ? 443 : 80),
|
|
29
|
+
path: u.pathname,
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
"Content-Length": data.length,
|
|
34
|
+
Authorization: `Bearer ${KEY}`,
|
|
35
|
+
},
|
|
36
|
+
timeout: TIMEOUT_MS,
|
|
37
|
+
},
|
|
38
|
+
(res) => {
|
|
39
|
+
let body = "";
|
|
40
|
+
res.on("data", (c) => (body += c));
|
|
41
|
+
res.on("end", () => {
|
|
42
|
+
if (res.statusCode !== 200) return resolve(null);
|
|
43
|
+
try { resolve(JSON.parse(body)); } catch { resolve(null); }
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
req.on("error", () => resolve(null));
|
|
48
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
49
|
+
req.write(data);
|
|
50
|
+
req.end();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Returns array of vectors aligned to inputs, or null if unavailable. */
|
|
55
|
+
export async function embed(inputs) {
|
|
56
|
+
if (!KEY) return null;
|
|
57
|
+
const arr = Array.isArray(inputs) ? inputs : [inputs];
|
|
58
|
+
if (arr.length === 0) return [];
|
|
59
|
+
const json = await postJson(BASE, { model: MODEL, input: arr });
|
|
60
|
+
if (!json || !Array.isArray(json.data)) return null;
|
|
61
|
+
try {
|
|
62
|
+
const sorted = json.data.slice().sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
|
63
|
+
const vecs = sorted.map((d) => d.embedding);
|
|
64
|
+
if (vecs.some((v) => !Array.isArray(v) || v.length === 0)) return null;
|
|
65
|
+
return vecs;
|
|
66
|
+
} catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function embedOne(text) {
|
|
70
|
+
const v = await embed([text]);
|
|
71
|
+
return v ? v[0] : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function cosine(a, b) {
|
|
75
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
76
|
+
let dot = 0, na = 0, nb = 0;
|
|
77
|
+
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
78
|
+
if (na === 0 || nb === 0) return 0;
|
|
79
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
80
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "project-memory-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Per-directory memory + indexing MCP server (Obsidian-style vault + Context7-style recall)",
|
|
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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM reranker via 9router chat models (e.g. Kiro kr/claude-haiku-4.5).
|
|
3
|
+
* Used when embeddings are unavailable: keyword prefilter -> LLM picks the
|
|
4
|
+
* most relevant candidates. Degrades gracefully: returns null on any failure
|
|
5
|
+
* so callers fall back to keyword ordering. Never throws.
|
|
6
|
+
*/
|
|
7
|
+
import http from "http";
|
|
8
|
+
import https from "https";
|
|
9
|
+
import { URL } from "url";
|
|
10
|
+
|
|
11
|
+
const BASE = process.env.LLM_BASE_URL || process.env.RERANK_URL || process.env.NINEROUTER_URL || "http://localhost:20128";
|
|
12
|
+
const KEY = process.env.LLM_API_KEY || process.env.RERANK_KEY || process.env.NINEROUTER_KEY || "";
|
|
13
|
+
const MODEL = process.env.RERANK_MODEL || "kr/claude-haiku-4.5";
|
|
14
|
+
const TIMEOUT_MS = Number(process.env.RERANK_TIMEOUT_MS || 30000);
|
|
15
|
+
const ENABLED = process.env.RERANK_ENABLED !== "0";
|
|
16
|
+
|
|
17
|
+
export function rerankConfig() {
|
|
18
|
+
return { base: BASE, model: MODEL, enabled: Boolean(KEY) && ENABLED };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function chat(messages) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
if (!KEY || !ENABLED) return resolve(null);
|
|
24
|
+
let u;
|
|
25
|
+
try { u = new URL("/v1/chat/completions", BASE); } catch { return resolve(null); }
|
|
26
|
+
const payload = Buffer.from(JSON.stringify({ model: MODEL, stream: false, temperature: 0, max_tokens: 200, messages }));
|
|
27
|
+
const lib = u.protocol === "https:" ? https : http;
|
|
28
|
+
const req = lib.request(
|
|
29
|
+
{
|
|
30
|
+
hostname: u.hostname,
|
|
31
|
+
port: u.port || (u.protocol === "https:" ? 443 : 80),
|
|
32
|
+
path: u.pathname,
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json", "Content-Length": payload.length, Authorization: `Bearer ${KEY}` },
|
|
35
|
+
timeout: TIMEOUT_MS,
|
|
36
|
+
},
|
|
37
|
+
(res) => {
|
|
38
|
+
let body = "";
|
|
39
|
+
res.on("data", (c) => (body += c));
|
|
40
|
+
res.on("end", () => {
|
|
41
|
+
if (res.statusCode !== 200) return resolve(null);
|
|
42
|
+
// handle plain JSON or SSE stream just in case
|
|
43
|
+
try {
|
|
44
|
+
const j = JSON.parse(body);
|
|
45
|
+
return resolve(j?.choices?.[0]?.message?.content ?? null);
|
|
46
|
+
} catch { /* maybe SSE */ }
|
|
47
|
+
try {
|
|
48
|
+
let acc = "";
|
|
49
|
+
for (const line of body.split("\n")) {
|
|
50
|
+
const t = line.trim();
|
|
51
|
+
if (!t.startsWith("data:")) continue;
|
|
52
|
+
const d = t.slice(5).trim();
|
|
53
|
+
if (d === "[DONE]") break;
|
|
54
|
+
const j = JSON.parse(d);
|
|
55
|
+
acc += j?.choices?.[0]?.delta?.content || j?.choices?.[0]?.message?.content || "";
|
|
56
|
+
}
|
|
57
|
+
return resolve(acc || null);
|
|
58
|
+
} catch { return resolve(null); }
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
req.on("error", () => resolve(null));
|
|
63
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
64
|
+
req.write(payload);
|
|
65
|
+
req.end();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Given a query and candidates [{id, title, snippet}], ask the model to return
|
|
71
|
+
* the ids ordered by relevance. Returns an array of ids, or null on failure.
|
|
72
|
+
*/
|
|
73
|
+
export async function rerank(query, candidates, topK = 5) {
|
|
74
|
+
if (!KEY || !ENABLED || !candidates || candidates.length === 0) return null;
|
|
75
|
+
const list = candidates.map((c, i) => `[${i + 1}] (id:${c.id}) ${c.title}\n ${String(c.snippet || "").replace(/\s+/g, " ").slice(0, 200)}`).join("\n");
|
|
76
|
+
const sys = "You are a search reranker. Given a user query and a numbered list of notes, return the most relevant notes ordered best-first. Respond with ONLY a comma-separated list of the bracket numbers, e.g. 3,1,5. No prose.";
|
|
77
|
+
const user = `Query: ${query}\n\nNotes:\n${list}\n\nReturn the top ${topK} bracket numbers, best first, comma-separated:`;
|
|
78
|
+
const out = await chat([{ role: "system", content: sys }, { role: "user", content: user }]);
|
|
79
|
+
if (!out) return null;
|
|
80
|
+
const nums = (out.match(/\d+/g) || []).map(Number).filter((n) => n >= 1 && n <= candidates.length);
|
|
81
|
+
if (nums.length === 0) return null;
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
const ordered = [];
|
|
84
|
+
for (const n of nums) {
|
|
85
|
+
const id = candidates[n - 1]?.id;
|
|
86
|
+
if (id && !seen.has(id)) { seen.add(id); ordered.push(id); }
|
|
87
|
+
}
|
|
88
|
+
return ordered.length ? ordered : null;
|
|
89
|
+
}
|