codex-dev-mcp-suite 1.2.0 → 1.4.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/bin/prune.mjs ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bin/prune.mjs — Dev MCP Suite prune CLI.
4
+ *
5
+ * Removes temp project slugs (prefix `tmp.`) from all stores. Default is
6
+ * DRY-RUN — nothing is deleted unless you pass `--yes`.
7
+ *
8
+ * Usage:
9
+ * prune [--root <path>] [--yes] [--json]
10
+ * prune --help | --version
11
+ */
12
+ import fs from "fs";
13
+ import os from "os";
14
+ import path from "path";
15
+ import { fileURLToPath } from "url";
16
+ import { findTempSlugs, planPrune, applyPrune } from "../lib/prune.js";
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+
20
+ function pkgVersion() {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).version || "unknown";
23
+ } catch { return "unknown"; }
24
+ }
25
+
26
+ function resolveRoot() {
27
+ const fromFlag = process.env.PRUNE_ROOT;
28
+ if (fromFlag) return path.resolve(fromFlag);
29
+ for (const k of ["MEMORY_VAULT_DIR", "JOURNAL_DIR", "CHECKPOINT_DIR"]) {
30
+ const v = process.env[k];
31
+ if (v) {
32
+ const sub = k === "MEMORY_VAULT_DIR" ? "vault" : k === "JOURNAL_DIR" ? "journal" : "checkpoints";
33
+ const parent = path.dirname(v);
34
+ if (path.basename(parent) === sub) return parent;
35
+ return parent;
36
+ }
37
+ }
38
+ return path.join(os.homedir(), ".codex", "memories");
39
+ }
40
+
41
+ function parseArgs(argv) {
42
+ const opts = { root: null, yes: false, json: false, help: false, version: false };
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const a = argv[i];
45
+ if (a === "-h" || a === "--help") opts.help = true;
46
+ else if (a === "-v" || a === "--version") opts.version = true;
47
+ else if (a === "--yes" || a === "-y") opts.yes = true;
48
+ else if (a === "--json") opts.json = true;
49
+ else if (a === "--dry-run") opts.yes = false;
50
+ else if (a === "--root") opts.root = path.resolve(argv[++i]);
51
+ else if (a.startsWith("--root=")) opts.root = path.resolve(a.slice("--root=".length));
52
+ else if (!a.startsWith("-")) opts.root = path.resolve(a);
53
+ else {
54
+ process.stderr.write(`prune: unknown flag: ${a}\n`);
55
+ process.exit(2);
56
+ }
57
+ }
58
+ return opts;
59
+ }
60
+
61
+ function fmtBytes(n) {
62
+ if (n < 1024) return `${n} B`;
63
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
64
+ return `${(n / 1024 / 1024).toFixed(2)} MB`;
65
+ }
66
+
67
+ function printHelp() {
68
+ const out = [
69
+ `prune (Dev MCP Suite) v${pkgVersion()}`,
70
+ "",
71
+ "Remove temp project slugs (prefix tmp.) from vault, journal, and",
72
+ "checkpoints. Default is DRY-RUN — nothing is deleted without --yes.",
73
+ "",
74
+ "Usage:",
75
+ " prune [root] [--root <path>] [--yes] [--json]",
76
+ " prune --help | --version",
77
+ "",
78
+ "Options:",
79
+ " --yes, -y Actually delete. Without this, only a dry-run report is printed.",
80
+ " --dry-run Force dry-run mode (default).",
81
+ " --json Machine-readable JSON output.",
82
+ " --root <p> Storage root (default: ~/.codex/memories or first env var of",
83
+ " MEMORY_VAULT_DIR / JOURNAL_DIR / CHECKPOINT_DIR)",
84
+ " -h, --help",
85
+ " -v, --version",
86
+ "",
87
+ "Safety: refuses to delete any slug that does not start with `tmp.`.",
88
+ "",
89
+ "Examples:",
90
+ " prune # dry-run report",
91
+ " prune --yes # delete after seeing the plan",
92
+ " prune --yes --json # machine-readable delete report",
93
+ ];
94
+ process.stdout.write(out.join("\n") + "\n");
95
+ }
96
+
97
+ const opts = parseArgs(process.argv.slice(2));
98
+ if (opts.version) { process.stdout.write(`${pkgVersion()}\n`); process.exit(0); }
99
+ if (opts.help) { printHelp(); process.exit(0); }
100
+
101
+ const root = opts.root || resolveRoot();
102
+ const plan = planPrune({ root });
103
+
104
+ if (plan.length === 0) {
105
+ if (opts.json) {
106
+ process.stdout.write(JSON.stringify({ root, plan: [], removed: [], skipped: [], errors: [] }, null, 2) + "\n");
107
+ } else {
108
+ process.stdout.write(`No temp slugs found under ${root}.\n`);
109
+ }
110
+ process.exit(0);
111
+ }
112
+
113
+ if (!opts.yes) {
114
+ // dry-run report
115
+ if (opts.json) {
116
+ process.stdout.write(JSON.stringify({ root, dryRun: true, plan, removed: [], skipped: [], errors: [] }, null, 2) + "\n");
117
+ } else {
118
+ const lines = [];
119
+ lines.push(`Dev MCP Suite — prune (DRY RUN)`);
120
+ lines.push("=================================");
121
+ lines.push(`Storage root: ${root}`);
122
+ lines.push(`Found ${plan.length} temp slug${plan.length === 1 ? "" : "s"}:`);
123
+ let totalBytes = 0;
124
+ for (const e of plan) {
125
+ lines.push(` ${e.slug.padEnd(36)} ${fmtBytes(e.bytes).padStart(10)} stores: ${e.stores.join(",")}`);
126
+ totalBytes += e.bytes;
127
+ }
128
+ lines.push(` ${"TOTAL".padEnd(36)} ${fmtBytes(totalBytes).padStart(10)}`);
129
+ lines.push("");
130
+ lines.push("Re-run with `--yes` to actually delete.");
131
+ process.stdout.write(lines.join("\n") + "\n");
132
+ }
133
+ process.exit(0);
134
+ }
135
+
136
+ // actually delete
137
+ const slugs = plan.map((p) => p.slug);
138
+ const result = applyPrune({ root, slugs, dryRun: false });
139
+ const totalFreed = plan.reduce((a, p) => a + p.bytes, 0);
140
+
141
+ if (opts.json) {
142
+ process.stdout.write(JSON.stringify({ root, dryRun: false, plan, ...result, bytesFreed: totalFreed }, null, 2) + "\n");
143
+ } else {
144
+ const lines = [];
145
+ lines.push(`Dev MCP Suite — prune`);
146
+ lines.push("=====================");
147
+ lines.push(`Storage root: ${root}`);
148
+ lines.push(`Removed ${result.removed.length} slug${result.removed.length === 1 ? "" : "s"} (freed ~${fmtBytes(totalFreed)})`);
149
+ for (const slug of result.removed) lines.push(` ✓ ${slug}`);
150
+ if (result.errors.length) {
151
+ lines.push("");
152
+ lines.push(`Errors (${result.errors.length}):`);
153
+ for (const e of result.errors) lines.push(` ✗ ${e.slug} [${e.store}]: ${e.error}`);
154
+ }
155
+ process.stdout.write(lines.join("\n") + "\n");
156
+ }
157
+
158
+ process.exit(result.errors.length > 0 ? 1 : 0);
package/bin/stats.mjs ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bin/stats.mjs — Dev MCP Suite stats CLI.
4
+ *
5
+ * Usage:
6
+ * stats [--root <path>] [--json] [--top N] [--help] [--version]
7
+ *
8
+ * Default storage root: ~/.codex/memories (override via --root or
9
+ * MEMORY_VAULT_DIR / JOURNAL_DIR / CHECKPOINT_DIR siblings).
10
+ */
11
+ import fs from "fs";
12
+ import os from "os";
13
+ import path from "path";
14
+ import { fileURLToPath } from "url";
15
+ import { computeStats, formatText, formatJson } from "../lib/stats.js";
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+
19
+ function pkgVersion() {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).version || "unknown";
22
+ } catch { return "unknown"; }
23
+ }
24
+
25
+ function resolveRoot() {
26
+ // Precedence: explicit --root > vault env > journal env > checkpoint env > ~/.codex/memories
27
+ const fromFlag = process.env.STATS_ROOT;
28
+ if (fromFlag) return path.resolve(fromFlag);
29
+ for (const k of ["MEMORY_VAULT_DIR", "JOURNAL_DIR", "CHECKPOINT_DIR"]) {
30
+ const v = process.env[k];
31
+ if (v) {
32
+ // each server stores under <root>/<subdir>, so the root is the parent
33
+ const sub = k === "MEMORY_VAULT_DIR" ? "vault" : k === "JOURNAL_DIR" ? "journal" : "checkpoints";
34
+ const parent = path.dirname(v);
35
+ if (path.basename(parent) === sub) return parent;
36
+ return parent;
37
+ }
38
+ }
39
+ return path.join(os.homedir(), ".codex", "memories");
40
+ }
41
+
42
+ function parseArgs(argv) {
43
+ const opts = { root: null, json: false, top: 10, help: false, version: false };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a === "-h" || a === "--help") opts.help = true;
47
+ else if (a === "-v" || a === "--version") opts.version = true;
48
+ else if (a === "--json") opts.json = true;
49
+ else if (a === "--root") opts.root = path.resolve(argv[++i]);
50
+ else if (a.startsWith("--root=")) opts.root = path.resolve(a.slice("--root=".length));
51
+ else if (a === "--top") opts.top = parseInt(argv[++i], 10) || 10;
52
+ else if (a.startsWith("--top=")) opts.top = parseInt(a.slice("--top=".length), 10) || 10;
53
+ else if (a === "--") { /* ignore rest */ break; }
54
+ else if (!a.startsWith("-")) opts.root = path.resolve(a);
55
+ else {
56
+ process.stderr.write(`stats: unknown flag: ${a}\n`);
57
+ process.exit(2);
58
+ }
59
+ }
60
+ return opts;
61
+ }
62
+
63
+ function printHelp() {
64
+ const out = [
65
+ `stats (Dev MCP Suite) v${pkgVersion()}`,
66
+ "",
67
+ "Summarize local memory storage (vault / journal / checkpoints).",
68
+ "",
69
+ "Usage:",
70
+ " stats [root] [--root <path>] [--json] [--top N]",
71
+ " stats --help | --version",
72
+ "",
73
+ "Options:",
74
+ " --root <path> Storage root (default: ~/.codex/memories or first env var of",
75
+ " MEMORY_VAULT_DIR / JOURNAL_DIR / CHECKPOINT_DIR)",
76
+ " --json Emit machine-readable JSON instead of human text",
77
+ " --top N How many entries in top-project / recent-activity lists",
78
+ " (default: 10)",
79
+ " -h, --help Show this help",
80
+ " -v, --version Print version",
81
+ "",
82
+ "Examples:",
83
+ " stats # summary of default storage",
84
+ " stats --json | jq .totals # totals only",
85
+ " stats --root /tmp/mem # use a different root",
86
+ ];
87
+ process.stdout.write(out.join("\n") + "\n");
88
+ }
89
+
90
+ const opts = parseArgs(process.argv.slice(2));
91
+ if (opts.version) { process.stdout.write(`${pkgVersion()}\n`); process.exit(0); }
92
+ if (opts.help) { printHelp(); process.exit(0); }
93
+
94
+ const root = opts.root || resolveRoot();
95
+ const stats = computeStats({ root, topLimit: opts.top });
96
+
97
+ if (opts.json) {
98
+ process.stdout.write(formatJson(stats) + "\n");
99
+ } else {
100
+ process.stdout.write(formatText(stats) + "\n");
101
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Dev MCP Suite — provider smoke library.
3
+ *
4
+ * Builds a "matrix" of providers from environment variables (matching the
5
+ * convention used by project-memory / devjournal servers: `MCP_*` neutral,
6
+ * `NINEROUTER_*` / `LLM_*` / `EMBED_*` legacy aliases), then exposes
7
+ * helpers to shape probe results and format them as text/JSON/markdown.
8
+ *
9
+ * The actual HTTP probes are NOT done here — the CLI wires real `fetch`.
10
+ * Keeping the lib free of network I/O makes it trivially testable offline.
11
+ */
12
+
13
+ import os from "os";
14
+
15
+ /**
16
+ * Static metadata for well-known providers. `supportsEmbed: false` means
17
+ * the provider does not expose an OpenAI-compatible /v1/embeddings endpoint
18
+ * (e.g. Groq, Cerebras — they focus on inference speed).
19
+ */
20
+ export function isCloudflareURL(urlStr) {
21
+ return typeof urlStr === "string" && urlStr.includes("api.cloudflare.com/client/v4/accounts");
22
+ }
23
+
24
+ export const KNOWN_PROVIDERS = {
25
+ // Inference-only (no embeddings endpoint)
26
+ groq: { supportsChat: true, supportsEmbed: false, defaultBase: "https://api.groq.com/openai/v1", defaultModel: "llama-3.3-70b-versatile", notes: "OpenAI-compatible; inference-only" },
27
+ cerebras: { supportsChat: true, supportsEmbed: false, defaultBase: "https://api.cerebras.ai/v1", defaultModel: "gpt-oss-120b", notes: "OpenAI-compatible; inference-only" },
28
+ anthropic: { supportsChat: true, supportsEmbed: false, defaultBase: "https://api.anthropic.com/v1", defaultModel: null, notes: "No embeddings API" },
29
+
30
+ // Embeddings + chat
31
+ openai: { supportsChat: true, supportsEmbed: true, defaultBase: "https://api.openai.com/v1", defaultModel: "text-embedding-3-small", notes: "Reference impl; paid (~$0.02/1M tok)" },
32
+ mistral: { supportsChat: true, supportsEmbed: true, defaultBase: "https://api.mistral.ai/v1", defaultModel: "mistral-embed", notes: "OpenAI-compatible; paid w/ free tier" },
33
+ openrouter: { supportsChat: true, supportsEmbed: true, defaultBase: "https://openrouter.ai/api/v1", defaultModel: "openai/text-embedding-3-small", notes: "Aggregator; many free models available" },
34
+ gemini: { supportsChat: true, supportsEmbed: true, defaultBase: "https://generativelanguage.googleapis.com/v1beta/openai", defaultModel: "text-embedding-004", notes: "Google; OpenAI-compatible; generous free tier" },
35
+ cohere: { supportsChat: true, supportsEmbed: true, defaultBase: "https://api.cohere.ai/v1", defaultModel: "embed-multilingual-v3.0", notes: "Top multilingual; trial + paid" },
36
+ voyage: { supportsChat: false, supportsEmbed: true, defaultBase: "https://api.voyageai.com/v1", defaultModel: "voyage-3", notes: "Embeddings-only; 200M free on signup" },
37
+
38
+ // Local (free, unlimited, uses laptop resources)
39
+ ollama: { supportsChat: true, supportsEmbed: true, defaultBase: "http://localhost:11434/v1", defaultModel: "nomic-embed-text", notes: "Local; runs on laptop CPU/GPU/RAM" },
40
+
41
+ // Non-OpenAI-compatible (requires custom code path in embedding.js)
42
+ cloudflare: { supportsChat: true, supportsEmbed: true, defaultBase: null, defaultModel: "@cf/baai/bge-base-en-v1.5", endpoint: "cloudflare", notes: "REST-only API; NOT OpenAI-compatible; 10K neurons/day free; model goes in URL path" },
43
+
44
+ // Local proxy / aggregator (not OpenAI by default; configured via env)
45
+ "9router": { supportsChat: true, supportsEmbed: true, defaultBase: null, defaultModel: null, notes: "Self-hosted OpenAI-compatible aggregator" },
46
+ };
47
+
48
+ function pickEnv(env, names) {
49
+ for (const n of names) if (env[n]) return env[n];
50
+ return undefined;
51
+ }
52
+
53
+ /** Read a provider slot (PRIMARY, CHAIN2..N) into a normalized record. */
54
+ function readProviderSlot(env, slot /* PRIMARY | CHAIN2 | CHAIN3 | ... */) {
55
+ const upper = slot.toUpperCase();
56
+ const name = pickEnv(env, [
57
+ `MCP_PROVIDER_${upper}`,
58
+ `MCP_PROVIDER_${upper}_NAME`,
59
+ ]);
60
+ if (!name) return null;
61
+ const lower = name.toLowerCase();
62
+ const baseUrl = pickEnv(env, [`MCP_PROVIDER_${upper}_BASE_URL`]);
63
+ const apiKey = pickEnv(env, [`MCP_PROVIDER_${upper}_API_KEY`]);
64
+ const chatModel = pickEnv(env, [`MCP_PROVIDER_${upper}_MODEL`, `MCP_PROVIDER_${upper}_CHAT_MODEL`]);
65
+ const embedModel = pickEnv(env, [`MCP_PROVIDER_${upper}_EMBED_MODEL`]);
66
+ const meta = KNOWN_PROVIDERS[lower] || {};
67
+ return {
68
+ id: lower,
69
+ name,
70
+ baseUrl: baseUrl || meta.defaultBase || null,
71
+ apiKey: apiKey || null,
72
+ chatModel: chatModel || meta.defaultModel || null,
73
+ embedModel: embedModel || null,
74
+ supportsChat: meta.supportsChat !== false,
75
+ supportsEmbed: meta.supportsEmbed === true && Boolean(embedModel || meta.defaultModel),
76
+ source: `MCP_PROVIDER_${upper}`,
77
+ };
78
+ }
79
+
80
+ /** Read a "named" provider from explicit env (e.g. MISTRAL_*, OPENAI_*, GROQ_*). */
81
+ function readNamedProvider(env, id) {
82
+ const upper = id.toUpperCase();
83
+ const apiKey = pickEnv(env, [`${upper}_API_KEY`, `${upper}_KEY`, `MCP_${upper}_API_KEY`]);
84
+ const baseUrl = pickEnv(env, [`${upper}_BASE_URL`, `${upper}_URL`, `MCP_${upper}_BASE_URL`]);
85
+ const chatModel = pickEnv(env, [`${upper}_MODEL`, `${upper}_CHAT_MODEL`, `MCP_${upper}_MODEL`]);
86
+ const embedModel = pickEnv(env, [`${upper}_EMBED_MODEL`, `MCP_EMBED_MODEL`, `${upper}_MODEL`]);
87
+ if (!apiKey && !baseUrl) return null;
88
+ const meta = KNOWN_PROVIDERS[id] || {};
89
+ // For single-model providers (e.g. Cloudflare), the configured model is used for both chat and embed.
90
+ const effectiveChatModel = chatModel || meta.defaultModel || embedModel || null;
91
+ const effectiveEmbedModel = embedModel || meta.defaultModel || chatModel || null;
92
+ const effectiveBaseUrl = baseUrl || meta.defaultBase || null;
93
+ return {
94
+ id,
95
+ name: id,
96
+ baseUrl: effectiveBaseUrl,
97
+ apiKey: apiKey || null,
98
+ chatModel: effectiveChatModel,
99
+ embedModel: effectiveEmbedModel,
100
+ supportsChat: meta.supportsChat !== false,
101
+ supportsEmbed: meta.supportsEmbed === true && Boolean(effectiveEmbedModel),
102
+ endpoint: meta.endpoint || (effectiveBaseUrl && isCloudflareURL(effectiveBaseUrl) ? "cloudflare" : "openai-compatible"),
103
+ source: `${upper}_*`,
104
+ };
105
+ }
106
+
107
+ /** Read the existing 9router/agentrouter config used by project-memory / devjournal. */
108
+ function read9routerLike(env) {
109
+ const baseUrl = pickEnv(env, [
110
+ "MCP_LLM_BASE_URL", "MCP_RERANK_BASE_URL", "MCP_EMBED_BASE_URL",
111
+ "LLM_BASE_URL", "EMBED_URL", "NINEROUTER_URL", "RERANK_URL",
112
+ ]);
113
+ const apiKey = pickEnv(env, [
114
+ "MCP_LLM_API_KEY", "MCP_RERANK_API_KEY", "MCP_EMBED_API_KEY",
115
+ "LLM_API_KEY", "EMBED_KEY", "NINEROUTER_KEY", "RERANK_KEY",
116
+ ]);
117
+ if (!baseUrl && !apiKey) return null;
118
+ // Heuristic: label the catch-all based on host for clarity in output.
119
+ const host = baseUrl ? new URL(baseUrl).hostname : "";
120
+ let id = "openai-compatible";
121
+ if (/9router|agentrouter/i.test(host)) id = "9router";
122
+ return {
123
+ id,
124
+ name: id,
125
+ baseUrl: baseUrl || null,
126
+ apiKey: apiKey || null,
127
+ chatModel: pickEnv(env, [
128
+ "MCP_RERANK_MODEL", "MCP_LLM_MODEL", "RERANK_MODEL", "LLM_MODEL",
129
+ "NINEROUTER_CHAT_MODEL", "NINEROUTER_RERANK_MODEL", "NINEROUTER_MODEL",
130
+ ]) || null,
131
+ embedModel: pickEnv(env, [
132
+ "MCP_EMBED_MODEL", "EMBED_MODEL", "NINEROUTER_EMBED_MODEL",
133
+ ]) || null,
134
+ supportsChat: true,
135
+ supportsEmbed: true,
136
+ source: "MCP_LLM_BASE_URL",
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Build the provider matrix from environment variables.
142
+ * Returns a deduplicated list of provider records.
143
+ * @param {Record<string,string|undefined>} env
144
+ * @returns {Array<{id, name, baseUrl, apiKey, chatModel, embedModel, supportsChat, supportsEmbed, source}>}
145
+ */
146
+ export function buildProviderMatrix(env) {
147
+ const out = [];
148
+ const seen = new Set();
149
+
150
+ const push = (p) => {
151
+ if (!p || !p.baseUrl && !p.apiKey) return;
152
+ if (seen.has(p.id)) return;
153
+ seen.add(p.id);
154
+ out.push(p);
155
+ };
156
+
157
+ // 1. Primary + numbered slots
158
+ push(readProviderSlot(env, "PRIMARY"));
159
+ for (let i = 2; i <= 10; i++) {
160
+ push(readProviderSlot(env, `CHAIN${i}`));
161
+ }
162
+
163
+ // 2. Named providers (Groq, Cerebras, Mistral, OpenRouter, OpenAI, Ollama)
164
+ for (const id of ["groq", "cerebras", "mistral", "openrouter", "openai", "ollama", "anthropic", "gemini", "cohere", "voyage", "cloudflare"]) {
165
+ push(readNamedProvider(env, id));
166
+ }
167
+
168
+ // 3. Catch-all: existing 9router-like config from MCP_LLM_BASE_URL etc.
169
+ push(read9routerLike(env));
170
+
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * Shape a probe result into the canonical record used by formatting helpers.
176
+ */
177
+ export function shapeProbe({ name, kind, ok, latencyMs = null, status = null, sample = null, error = null, dim = null }) {
178
+ return { name, kind, ok, latencyMs, status, sample, error, dim };
179
+ }
180
+
181
+ // ---- formatting ----
182
+
183
+ function fmtLatency(ms) {
184
+ if (ms == null) return "—";
185
+ if (ms < 1000) return `${ms}ms`;
186
+ return `${(ms / 1000).toFixed(2)}s`;
187
+ }
188
+
189
+ export function formatText(results) {
190
+ const lines = [];
191
+ lines.push("Dev MCP Suite — provider smoke");
192
+ lines.push("==============================");
193
+ // group by provider
194
+ const byName = new Map();
195
+ for (const r of results) {
196
+ if (!byName.has(r.name)) byName.set(r.name, []);
197
+ byName.get(r.name).push(r);
198
+ }
199
+ for (const [name, rs] of byName) {
200
+ lines.push(`\n[${name}]`);
201
+ for (const r of rs) {
202
+ const mark = r.ok ? "✓" : "✗";
203
+ const lat = r.ok ? fmtLatency(r.latencyMs) : "";
204
+ const detail = r.ok
205
+ ? `${r.status || ""} ${lat}${r.sample ? ` "${String(r.sample).slice(0, 40)}"` : ""}${r.dim ? ` dim=${r.dim}` : ""}`.trim()
206
+ : `${r.status || "—"} ${r.error || "unknown error"}`;
207
+ lines.push(` ${mark} ${r.kind.padEnd(10)} ${detail}`);
208
+ }
209
+ }
210
+ lines.push("");
211
+ return lines.join("\n");
212
+ }
213
+
214
+ export function formatJson(results) {
215
+ return JSON.stringify(results, null, 2);
216
+ }
217
+
218
+ export function formatMarkdown(results) {
219
+ const lines = [];
220
+ lines.push("# Provider smoke matrix");
221
+ lines.push("");
222
+ lines.push(`_Generated ${new Date().toISOString()}_`);
223
+ lines.push("");
224
+ lines.push("| Provider | Kind | Status | Latency | HTTP | Sample / dim | Error |");
225
+ lines.push("|---|---|---|---|---|---|---|");
226
+ for (const r of results) {
227
+ const okMark = r.ok ? "✓" : "✗";
228
+ const sample = r.ok ? (r.sample ? `\`${String(r.sample).slice(0, 30)}\`` : (r.dim ? `dim=${r.dim}` : "—")) : "—";
229
+ const err = r.ok ? "" : (r.error || "").replace(/\|/g, "\\|");
230
+ lines.push(`| ${r.name} | ${r.kind} | ${okMark} | ${fmtLatency(r.latencyMs)} | ${r.status || "—"} | ${sample} | ${err} |`);
231
+ }
232
+ lines.push("");
233
+ return lines.join("\n");
234
+ }
package/lib/prune.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Dev MCP Suite — prune library.
3
+ *
4
+ * Identifies and removes "temp" project slugs (prefix `tmp.`) from all three
5
+ * stores. Used by `bin/prune.mjs`. No MCP, no stdio — safe to import from tests.
6
+ *
7
+ * Storage layout (matches the rest of the suite):
8
+ * <root>/vault/<slug>/
9
+ * <root>/journal/<slug>/
10
+ * <root>/checkpoints/<slug>/
11
+ */
12
+
13
+ import fs from "fs";
14
+ import path from "path";
15
+
16
+ export const STORES = ["vault", "journal", "checkpoints"];
17
+ const TEMP_PREFIX = "tmp.";
18
+
19
+ /** Returns the list of slug names matching temp pattern across all stores. */
20
+ export function findTempSlugs({ root }) {
21
+ const set = new Set();
22
+ for (const store of STORES) {
23
+ let entries = [];
24
+ try {
25
+ entries = fs.readdirSync(path.join(root, store), { withFileTypes: true });
26
+ } catch { continue; }
27
+ for (const e of entries) {
28
+ if (e.isDirectory() && e.name.startsWith(TEMP_PREFIX)) set.add(e.name);
29
+ }
30
+ }
31
+ return [...set].sort();
32
+ }
33
+
34
+ /** Recursively sum bytes under each slug's directories across stores. */
35
+ export function estimateBytes({ root, slugs }) {
36
+ let total = 0;
37
+ for (const slug of slugs) {
38
+ for (const store of STORES) {
39
+ total += dirSize(path.join(root, store, slug));
40
+ }
41
+ }
42
+ return total;
43
+ }
44
+
45
+ function dirSize(p) {
46
+ let total = 0;
47
+ let stack = [p];
48
+ while (stack.length) {
49
+ const cur = stack.pop();
50
+ let stat;
51
+ try { stat = fs.lstatSync(cur); } catch { continue; }
52
+ if (stat.isDirectory()) {
53
+ let children = [];
54
+ try { children = fs.readdirSync(cur); } catch { continue; }
55
+ for (const c of children) stack.push(path.join(cur, c));
56
+ } else {
57
+ total += stat.size;
58
+ }
59
+ }
60
+ return total;
61
+ }
62
+
63
+ /** Validate slugs only contain temp prefix — refuse to delete otherwise. */
64
+ function assertTempSlugs(slugs) {
65
+ for (const s of slugs) {
66
+ if (!s.startsWith(TEMP_PREFIX)) {
67
+ throw new Error(`refusing to remove non-temp slug: "${s}" (must start with "${TEMP_PREFIX}")`);
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Compute a plan: per-slug list of stores where the dir exists + total bytes. */
73
+ export function planPrune({ root }) {
74
+ const slugs = findTempSlugs({ root });
75
+ return slugs.map((slug) => {
76
+ const stores = [];
77
+ for (const store of STORES) {
78
+ const p = path.join(root, store, slug);
79
+ try {
80
+ if (fs.existsSync(p)) stores.push(store);
81
+ } catch { /* ignore */ }
82
+ }
83
+ const bytes = estimateBytes({ root, slugs: [slug] });
84
+ return { slug, stores, bytes };
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Remove temp slugs from all stores. Default is dry-run (no deletion).
90
+ * @param {{ root: string, slugs: string[], dryRun?: boolean }} opts
91
+ * @returns {{ removed: string[], skipped: string[], errors: Array<{slug:string,store:string,error:string}> }}
92
+ */
93
+ export function applyPrune({ root, slugs, dryRun = true }) {
94
+ assertTempSlugs(slugs);
95
+ const removed = [];
96
+ const skipped = [];
97
+ const errors = [];
98
+ for (const slug of slugs) {
99
+ let anyRemoved = false;
100
+ for (const store of STORES) {
101
+ const p = path.join(root, store, slug);
102
+ if (!fs.existsSync(p)) continue;
103
+ if (dryRun) { skipped.push(slug); continue; }
104
+ try {
105
+ fs.rmSync(p, { recursive: true, force: true });
106
+ anyRemoved = true;
107
+ } catch (e) {
108
+ errors.push({ slug, store, error: String(e && e.message || e) });
109
+ }
110
+ }
111
+ if (anyRemoved) removed.push(slug);
112
+ }
113
+ return { removed, skipped, errors };
114
+ }