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.
@@ -0,0 +1,73 @@
1
+ import { McpClient, describe, it, assert, assertIncludes, run, tmpDir, rmrf } from "../_testkit/harness.mjs";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const SERVER = path.join(__dirname, "server.js");
8
+ const STORE = tmpDir("cp-store-");
9
+ const PROJ = tmpDir("cp-proj-");
10
+
11
+ // seed project files
12
+ fs.mkdirSync(path.join(PROJ, "src"), { recursive: true });
13
+ fs.writeFileSync(path.join(PROJ, "src", "a.txt"), "original A\n");
14
+ fs.writeFileSync(path.join(PROJ, "README.md"), "# Title\n");
15
+
16
+ const client = new McpClient(SERVER, { CHECKPOINT_DIR: STORE });
17
+ let cpId = "";
18
+
19
+ describe("checkpoint", () => {
20
+ it("lists expected tools", async () => {
21
+ const tools = await client.listTools();
22
+ for (const t of ["checkpoint_create", "checkpoint_list", "checkpoint_restore", "checkpoint_diff", "checkpoint_delete"])
23
+ assert(tools.includes(t), `missing ${t}`);
24
+ });
25
+
26
+ it("creates a checkpoint", async () => {
27
+ const r = await client.callTool("checkpoint_create", { dir: PROJ, label: "baseline" });
28
+ assertIncludes(r.text, "Checkpoint");
29
+ assertIncludes(r.text, "Files stored: 2");
30
+ cpId = (r.text.match(/Checkpoint (\S+) created/) || [])[1] || "";
31
+ assert(cpId, "could not parse checkpoint id");
32
+ });
33
+
34
+ it("diff detects add/modify/delete", async () => {
35
+ fs.writeFileSync(path.join(PROJ, "src", "a.txt"), "CHANGED A\n");
36
+ fs.writeFileSync(path.join(PROJ, "src", "new.txt"), "brand new\n");
37
+ fs.rmSync(path.join(PROJ, "README.md"));
38
+ const r = await client.callTool("checkpoint_diff", { dir: PROJ, id: cpId });
39
+ assertIncludes(r.text, "Added (1)");
40
+ assertIncludes(r.text, "src/new.txt");
41
+ assertIncludes(r.text, "Modified (1)");
42
+ assertIncludes(r.text, "src/a.txt");
43
+ assertIncludes(r.text, "Deleted (1)");
44
+ assertIncludes(r.text, "README.md");
45
+ });
46
+
47
+ it("restores with clean (revert + remove new)", async () => {
48
+ const r = await client.callTool("checkpoint_restore", { dir: PROJ, id: cpId, clean: true });
49
+ assertIncludes(r.text, "Restored 2 files");
50
+ assert(fs.readFileSync(path.join(PROJ, "src", "a.txt"), "utf8").includes("original A"), "a.txt not reverted");
51
+ assert(fs.existsSync(path.join(PROJ, "README.md")), "README not restored");
52
+ assert(!fs.existsSync(path.join(PROJ, "src", "new.txt")), "new.txt not removed");
53
+ });
54
+
55
+ it("errors on unknown id", async () => {
56
+ const r = await client.callTool("checkpoint_diff", { dir: PROJ, id: "nope-123" });
57
+ assert(r.isError, "expected error flag");
58
+ assertIncludes(r.text, "not found");
59
+ });
60
+
61
+ it("deletes the checkpoint", async () => {
62
+ const r = await client.callTool("checkpoint_delete", { dir: PROJ, id: cpId });
63
+ assertIncludes(r.text, "Deleted checkpoint");
64
+ const r2 = await client.callTool("checkpoint_list", { dir: PROJ });
65
+ assertIncludes(r2.text, "No checkpoints");
66
+ });
67
+ });
68
+
69
+ await client.start();
70
+ const { fail } = await run();
71
+ await client.stop();
72
+ rmrf(STORE); rmrf(PROJ);
73
+ process.exit(fail > 0 ? 1 : 0);
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "context-pack-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Token-efficient project digest builder (tree, stack, key files, symbols)",
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,256 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Context Pack MCP Server for Codex
5
+ *
6
+ * Builds a compact, token-budgeted "briefing" of a project so a fresh
7
+ * session can get oriented without re-pasting files or blowing the context
8
+ * window. Detects stack, shows a pruned tree, surfaces key files, and
9
+ * extracts top-level symbols (functions/classes/exports) heuristically.
10
+ *
11
+ * Tools: pack_overview, pack_tree, pack_outline, pack_search
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
+
23
+ const IGNORE = new Set([
24
+ "node_modules", ".git", ".next", "dist", "build", "target", ".venv",
25
+ "venv", "__pycache__", ".cache", ".turbo", "coverage", ".DS_Store",
26
+ "vendor", ".idea", ".gradle", "out", ".parcel-cache", ".pytest_cache",
27
+ ]);
28
+ const KEY_FILES = [
29
+ "package.json", "pyproject.toml", "requirements.txt", "Cargo.toml",
30
+ "go.mod", "pom.xml", "build.gradle", "composer.json", "Gemfile",
31
+ "README.md", "readme.md", "Makefile", "Dockerfile", "docker-compose.yml",
32
+ "tsconfig.json", "next.config.js", "vite.config.ts", "vite.config.js",
33
+ ".env.example", "AGENTS.md", "CLAUDE.md", "RTK.md",
34
+ ];
35
+ const CODE_EXT = new Set([".js", ".mjs", ".ts", ".tsx", ".jsx", ".py", ".go", ".rs", ".java", ".rb", ".php", ".c", ".cpp", ".h", ".sh"]);
36
+ const MAX_FILE_BYTES = 1_500_000;
37
+
38
+ function resolveDir(dir) { return path.resolve(dir || process.cwd()); }
39
+
40
+ async function walk(base, rel = "", out = [], depth = 0, maxDepth = 8) {
41
+ if (depth > maxDepth) return out;
42
+ let entries;
43
+ try { entries = await fs.readdir(path.join(base, rel), { withFileTypes: true }); } catch { return out; }
44
+ entries.sort((a, b) => a.name.localeCompare(b.name));
45
+ for (const e of entries) {
46
+ if (IGNORE.has(e.name) || e.name.startsWith(".") && e.isDirectory() && e.name !== ".github") continue;
47
+ const childRel = path.join(rel, e.name);
48
+ if (e.isDirectory()) { out.push({ rel: childRel, dir: true, depth }); await walk(base, childRel, out, depth + 1, maxDepth); }
49
+ else if (e.isFile()) out.push({ rel: childRel, dir: false, depth });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ async function detectStack(root, files) {
55
+ const names = new Set(files.filter((f) => !f.dir).map((f) => path.basename(f.rel)));
56
+ const stack = [];
57
+ if (names.has("package.json")) {
58
+ stack.push("Node/JS");
59
+ try {
60
+ const pkg = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
61
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
62
+ for (const [d, label] of [["next", "Next.js"], ["react", "React"], ["vue", "Vue"], ["svelte", "Svelte"], ["express", "Express"], ["fastify", "Fastify"], ["typescript", "TypeScript"], ["tailwindcss", "Tailwind"], ["prisma", "Prisma"], ["vite", "Vite"]]) {
63
+ if (deps[d]) stack.push(label);
64
+ }
65
+ } catch { /* ignore */ }
66
+ }
67
+ if (names.has("pyproject.toml") || names.has("requirements.txt")) stack.push("Python");
68
+ if (names.has("Cargo.toml")) stack.push("Rust");
69
+ if (names.has("go.mod")) stack.push("Go");
70
+ if (names.has("pom.xml") || names.has("build.gradle")) stack.push("Java/JVM");
71
+ if (names.has("composer.json")) stack.push("PHP");
72
+ if (names.has("Gemfile")) stack.push("Ruby");
73
+ if (names.has("Dockerfile") || names.has("docker-compose.yml")) stack.push("Docker");
74
+ return [...new Set(stack)];
75
+ }
76
+
77
+ function extractSymbols(content, ext) {
78
+ const syms = [];
79
+ const lines = content.split("\n");
80
+ const patterns = [
81
+ /^\s*export\s+(?:default\s+)?(?:async\s+)?function\s+([A-Za-z0-9_]+)/,
82
+ /^\s*export\s+(?:const|let|var)\s+([A-Za-z0-9_]+)\s*=/,
83
+ /^\s*export\s+(?:abstract\s+)?class\s+([A-Za-z0-9_]+)/,
84
+ /^\s*(?:async\s+)?function\s+([A-Za-z0-9_]+)/,
85
+ /^\s*class\s+([A-Za-z0-9_]+)/,
86
+ /^\s*def\s+([A-Za-z0-9_]+)/,
87
+ /^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z0-9_]+)/,
88
+ /^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z0-9_]+)/,
89
+ /^\s*(?:public|private|protected)\s+(?:static\s+)?[A-Za-z0-9_<>\[\]]+\s+([A-Za-z0-9_]+)\s*\(/,
90
+ ];
91
+ for (let i = 0; i < lines.length; i++) {
92
+ for (const re of patterns) {
93
+ const m = lines[i].match(re);
94
+ if (m) { syms.push({ name: m[1], line: i + 1 }); break; }
95
+ }
96
+ if (syms.length >= 80) break;
97
+ }
98
+ return syms;
99
+ }
100
+
101
+ class ContextPackServer {
102
+ constructor() {
103
+ this.server = new Server({ name: "context-pack", version: "1.0.0" }, { capabilities: { tools: {} } });
104
+ this.setup();
105
+ }
106
+
107
+ setup() {
108
+ this.server.setRequestHandler(ListToolsRequestSchema, () => ({
109
+ tools: [
110
+ {
111
+ name: "pack_overview",
112
+ description: "Build a compact project briefing: detected stack, key config files (with short excerpts), top-level dirs, and file counts. Call at session start to get oriented cheaply.",
113
+ inputSchema: {
114
+ type: "object",
115
+ properties: {
116
+ dir: { type: "string", description: "Project directory (defaults to CWD)" },
117
+ include_readme: { type: "boolean", description: "Include README excerpt", default: true },
118
+ },
119
+ },
120
+ },
121
+ {
122
+ name: "pack_tree",
123
+ description: "Print a pruned directory tree (ignores node_modules/.git/build dirs).",
124
+ inputSchema: {
125
+ type: "object",
126
+ properties: {
127
+ dir: { type: "string", description: "Project directory (defaults to CWD)" },
128
+ max_depth: { type: "number", description: "Max depth", default: 3 },
129
+ max_entries: { type: "number", description: "Max lines", default: 200 },
130
+ },
131
+ },
132
+ },
133
+ {
134
+ name: "pack_outline",
135
+ description: "Extract top-level symbols (functions/classes/exports) from a source file without dumping the whole file.",
136
+ inputSchema: {
137
+ type: "object",
138
+ properties: {
139
+ file: { type: "string", description: "Path to source file (absolute or relative to dir)" },
140
+ dir: { type: "string", description: "Base directory (defaults to CWD)" },
141
+ },
142
+ required: ["file"],
143
+ },
144
+ },
145
+ {
146
+ name: "pack_search",
147
+ description: "Find files whose name or path matches a substring (fast, ignores build dirs). Returns relative paths.",
148
+ inputSchema: {
149
+ type: "object",
150
+ properties: {
151
+ query: { type: "string", description: "Substring to match in path" },
152
+ dir: { type: "string", description: "Project directory (defaults to CWD)" },
153
+ limit: { type: "number", description: "Max results", default: 50 },
154
+ },
155
+ required: ["query"],
156
+ },
157
+ },
158
+ ],
159
+ }));
160
+
161
+ this.server.setRequestHandler(CallToolRequestSchema, async (req) => {
162
+ const { name, arguments: args } = req.params;
163
+ try {
164
+ switch (name) {
165
+ case "pack_overview": return await this.overview(args || {});
166
+ case "pack_tree": return await this.tree(args || {});
167
+ case "pack_outline": return await this.outline(args || {});
168
+ case "pack_search": return await this.search(args || {});
169
+ default: throw new Error(`Unknown tool: ${name}`);
170
+ }
171
+ } catch (e) {
172
+ return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
173
+ }
174
+ });
175
+ }
176
+
177
+ async overview({ dir, include_readme = true }) {
178
+ const root = resolveDir(dir);
179
+ const all = await walk(root, "", [], 0, 6);
180
+ const fileCount = all.filter((f) => !f.dir).length;
181
+ const dirCount = all.filter((f) => f.dir).length;
182
+ const stack = await detectStack(root, all);
183
+
184
+ const topDirs = all.filter((f) => f.dir && f.depth === 0).map((f) => f.rel);
185
+ const present = KEY_FILES.filter((k) => all.some((f) => !f.dir && f.rel === k || path.basename(f.rel) === k && f.depth === 0));
186
+
187
+ const sections = [];
188
+ sections.push(`# Project briefing: ${path.basename(root)}`);
189
+ sections.push(`Path: ${root}`);
190
+ sections.push(`Stack: ${stack.length ? stack.join(", ") : "unknown"}`);
191
+ sections.push(`Files: ${fileCount} | Dirs: ${dirCount}`);
192
+ sections.push(`Top-level dirs: ${topDirs.length ? topDirs.join(", ") : "(none)"}`);
193
+ sections.push(`Key files: ${present.length ? present.join(", ") : "(none)"}`);
194
+
195
+ // package.json scripts
196
+ if (present.includes("package.json")) {
197
+ try {
198
+ const pkg = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
199
+ if (pkg.scripts) sections.push(`\n## Scripts\n` + Object.entries(pkg.scripts).map(([k, v]) => `- ${k}: ${v}`).join("\n"));
200
+ } catch { /* ignore */ }
201
+ }
202
+
203
+ if (include_readme) {
204
+ const readme = present.find((k) => k.toLowerCase() === "readme.md");
205
+ if (readme) {
206
+ try {
207
+ const txt = await fs.readFile(path.join(root, readme), "utf8");
208
+ sections.push(`\n## README (excerpt)\n` + txt.trim().split("\n").slice(0, 25).join("\n"));
209
+ } catch { /* ignore */ }
210
+ }
211
+ }
212
+
213
+ return { content: [{ type: "text", text: sections.join("\n") }] };
214
+ }
215
+
216
+ async tree({ dir, max_depth = 3, max_entries = 200 }) {
217
+ const root = resolveDir(dir);
218
+ const all = await walk(root, "", [], 0, Math.max(1, Math.min(10, max_depth)));
219
+ const filtered = all.filter((f) => f.depth < max_depth).slice(0, Math.max(10, Math.min(1000, max_entries)));
220
+ const lines = filtered.map((f) => `${" ".repeat(f.depth)}${f.dir ? "📁 " : ""}${path.basename(f.rel)}${f.dir ? "/" : ""}`);
221
+ return { content: [{ type: "text", text: `Tree of ${root} (depth ${max_depth}):\n${lines.join("\n")}${all.length > filtered.length ? `\n... (${all.length - filtered.length} more entries truncated)` : ""}` }] };
222
+ }
223
+
224
+ async outline({ file, dir }) {
225
+ const root = resolveDir(dir);
226
+ const abs = path.isAbsolute(file) ? file : path.join(root, file);
227
+ const stat = await fs.stat(abs).catch(() => null);
228
+ if (!stat || !stat.isFile()) throw new Error(`File not found: ${abs}`);
229
+ if (stat.size > MAX_FILE_BYTES) throw new Error(`File too large (${stat.size} bytes)`);
230
+ const ext = path.extname(abs);
231
+ const content = await fs.readFile(abs, "utf8");
232
+ const totalLines = content.split("\n").length;
233
+ if (!CODE_EXT.has(ext)) {
234
+ return { content: [{ type: "text", text: `${abs}\n${totalLines} lines, ext ${ext || "(none)"} — no symbol extractor for this type. First 20 lines:\n` + content.split("\n").slice(0, 20).join("\n") }] };
235
+ }
236
+ const syms = extractSymbols(content, ext);
237
+ const list = syms.length ? syms.map((s) => ` L${s.line}: ${s.name}`).join("\n") : " (no top-level symbols detected)";
238
+ return { content: [{ type: "text", text: `Outline of ${abs}\n${totalLines} lines, ${syms.length} symbols:\n${list}` }] };
239
+ }
240
+
241
+ async search({ query, dir, limit = 50 }) {
242
+ const root = resolveDir(dir);
243
+ const q = String(query).toLowerCase();
244
+ const all = await walk(root, "", [], 0, 10);
245
+ const hits = all.filter((f) => !f.dir && f.rel.toLowerCase().includes(q)).map((f) => f.rel).slice(0, Math.max(1, Math.min(500, limit)));
246
+ return { content: [{ type: "text", text: hits.length ? `Matches for "${query}" (${hits.length}):\n` + hits.map((h) => ` ${h}`).join("\n") : `No path matches for "${query}" in ${root}` }] };
247
+ }
248
+
249
+ async run() {
250
+ const t = new StdioServerTransport();
251
+ await this.server.connect(t);
252
+ console.error("Context Pack MCP server running on stdio");
253
+ }
254
+ }
255
+
256
+ new ContextPackServer().run().catch(console.error);
@@ -0,0 +1,66 @@
1
+ import { McpClient, describe, it, assert, assertIncludes, run, tmpDir, rmrf } from "../_testkit/harness.mjs";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const SERVER = path.join(__dirname, "server.js");
8
+ const PROJ = tmpDir("ctx-proj-");
9
+
10
+ fs.mkdirSync(path.join(PROJ, "src"), { recursive: true });
11
+ fs.writeFileSync(path.join(PROJ, "package.json"), JSON.stringify({
12
+ name: "sample", version: "1.0.0",
13
+ scripts: { dev: "vite", test: "vitest" },
14
+ dependencies: { react: "^18.0.0", next: "^14.0.0" },
15
+ }, null, 2));
16
+ fs.writeFileSync(path.join(PROJ, "README.md"), "# Sample Project\nDemo app.\n## Setup\nnpm install\n");
17
+ fs.writeFileSync(path.join(PROJ, "src", "auth.ts"),
18
+ "export function login(user: string) { return true; }\nexport class AuthService { verify() {} }\nfunction helperInternal() {}\n");
19
+
20
+ const client = new McpClient(SERVER, {});
21
+
22
+ describe("context-pack", () => {
23
+ it("lists expected tools", async () => {
24
+ const tools = await client.listTools();
25
+ for (const t of ["pack_overview", "pack_tree", "pack_outline", "pack_search"])
26
+ assert(tools.includes(t), `missing ${t}`);
27
+ });
28
+
29
+ it("overview detects stack + scripts + readme", async () => {
30
+ const r = await client.callTool("pack_overview", { dir: PROJ });
31
+ assertIncludes(r.text, "Next.js");
32
+ assertIncludes(r.text, "React");
33
+ assertIncludes(r.text, "dev: vite");
34
+ assertIncludes(r.text, "Sample Project");
35
+ });
36
+
37
+ it("tree shows dirs and files", async () => {
38
+ const r = await client.callTool("pack_tree", { dir: PROJ, max_depth: 3 });
39
+ assertIncludes(r.text, "src/");
40
+ assertIncludes(r.text, "auth.ts");
41
+ });
42
+
43
+ it("outline extracts symbols", async () => {
44
+ const r = await client.callTool("pack_outline", { dir: PROJ, file: "src/auth.ts" });
45
+ assertIncludes(r.text, "login");
46
+ assertIncludes(r.text, "AuthService");
47
+ assertIncludes(r.text, "helperInternal");
48
+ });
49
+
50
+ it("outline errors on missing file", async () => {
51
+ const r = await client.callTool("pack_outline", { dir: PROJ, file: "does/not/exist.ts" });
52
+ assert(r.isError, "expected error");
53
+ assertIncludes(r.text, "File not found");
54
+ });
55
+
56
+ it("search matches by path substring", async () => {
57
+ const r = await client.callTool("pack_search", { dir: PROJ, query: "auth" });
58
+ assertIncludes(r.text, "src/auth.ts");
59
+ });
60
+ });
61
+
62
+ await client.start();
63
+ const { fail } = await run();
64
+ await client.stop();
65
+ rmrf(PROJ);
66
+ process.exit(fail > 0 ? 1 : 0);
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "devjournal-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Per-project dev journal: session logs, decisions, next-steps timeline",
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
+ }