codex-dev-mcp-suite 1.2.0 → 1.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 - 2026-06-17
4
+
5
+ ### Added
6
+ - New `stats` CLI: summarize local memory storage across vault / journal / checkpoints. Shows totals, top projects by notes, most recent activity, and temp-slug cleanup candidates.
7
+ - Usage: `npx -y -p codex-dev-mcp-suite stats` (or globally `stats`)
8
+ - Flags: `--root <path>`, `--json`, `--top N`, `--help`, `--version`
9
+ - Pure-function library at `lib/stats.js` (no MCP / no stdio); tested offline.
10
+ - New top-level test runner hook: `tests/*.test.mjs` are auto-picked-up by `node run-tests.mjs`.
11
+
12
+
13
+
14
+
15
+ ### Added
16
+ - New `prune` CLI: remove temp project slugs (prefix `tmp.`) from vault, journal, and checkpoints. Default is DRY-RUN — nothing is deleted without `--yes`.
17
+ - Usage: `npx -y -p codex-dev-mcp-suite prune` (or globally `prune`)
18
+ - Flags: `--root <path>`, `--yes`, `--json`, `--help`, `--version`
19
+ - Pure-function library at `lib/prune.js`; tested offline.
20
+ - Safety: refuses to delete any slug that does not start with `tmp.`.
21
+ - New MCP tool `memory_stats` on the project-memory server: returns the same summary as the `stats` CLI. Useful for in-session introspection from an MCP client.
22
+ - Args: `root?`, `top?` (default 10), `json?` (default false).
23
+
3
24
  ## 1.2.0 - 2026-06-15
4
25
 
5
26
  ### Added
package/README.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  [![CI](https://github.com/verrysimatupang99/codex-dev-mcp-suite/actions/workflows/ci.yml/badge.svg)](https://github.com/verrysimatupang99/codex-dev-mcp-suite/actions/workflows/ci.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/codex-dev-mcp-suite.svg)](https://www.npmjs.com/package/codex-dev-mcp-suite)
5
+ [![npm downloads](https://img.shields.io/npm/dm/codex-dev-mcp-suite.svg)](https://www.npmjs.com/package/codex-dev-mcp-suite)
6
+ [![license](https://img.shields.io/npm/l/codex-dev-mcp-suite.svg)](./LICENSE)
7
+ [![node](https://img.shields.io/node/v/codex-dev-mcp-suite.svg)](https://nodejs.org)
5
8
 
6
9
  Published as `codex-dev-mcp-suite` for backward compatibility.
7
10
 
@@ -14,6 +17,51 @@ per-project** by the working directory. Works with any MCP-capable client
14
17
  (Codex CLI, Claude Code, Cursor, Cline, Gemini-compatible launchers, Hermes,
15
18
  or any stdio MCP host).
16
19
 
20
+ ## Quickstart
21
+
22
+ Try it in 10 seconds (no clone, no config):
23
+
24
+ ```bash
25
+ npx -y -p codex-dev-mcp-suite project-memory-mcp --help
26
+ ```
27
+
28
+ Local-first by default: no hosted backend, no telemetry. Works offline with
29
+ keyword recall; model/provider config is optional. For strict no-network mode,
30
+ set `MCP_DETERMINISTIC_FALLBACK=true`.
31
+
32
+ Register all four servers with an MCP client (Codex CLI shown):
33
+
34
+ ```toml
35
+ # ~/.codex/config.toml
36
+ [mcp_servers.project-memory]
37
+ command = "npx"
38
+ args = ["-y", "-p", "codex-dev-mcp-suite", "project-memory-mcp"]
39
+
40
+ [mcp_servers.devjournal]
41
+ command = "npx"
42
+ args = ["-y", "-p", "codex-dev-mcp-suite", "devjournal-mcp"]
43
+
44
+ [mcp_servers.checkpoint]
45
+ command = "npx"
46
+ args = ["-y", "-p", "codex-dev-mcp-suite", "checkpoint-mcp"]
47
+
48
+ [mcp_servers.context-pack]
49
+ command = "npx"
50
+ args = ["-y", "-p", "codex-dev-mcp-suite", "context-pack-mcp"]
51
+ ```
52
+
53
+ Inspect any server without starting it:
54
+
55
+ ```bash
56
+ project-memory-mcp --version
57
+ project-memory-mcp --doctor # config diagnostics; API keys redacted
58
+ ```
59
+
60
+ Other clients (Claude Code, Cursor, Cline, ...): see
61
+ [`docs/clients/`](docs/clients/). Full env reference:
62
+ [`docs/configuration.md`](docs/configuration.md). Data flow:
63
+ [`docs/privacy.md`](docs/privacy.md).
64
+
17
65
  ## The four servers
18
66
 
19
67
  | Server | What it does | Key tools |
@@ -138,6 +186,69 @@ node backfill-sessions-v2.mjs --dry --min-prompts 2 # preview
138
186
  node backfill-sessions-v2.mjs --min-prompts 2 # import
139
187
  ```
140
188
 
189
+ ## Stats CLI
190
+
191
+ A read-only summary of your local memory storage — totals, top projects,
192
+ recent activity, and temp-slug cleanup candidates.
193
+
194
+ ```bash
195
+ npx -y -p codex-dev-mcp-suite stats # human-readable
196
+ npx -y -p codex-dev-mcp-suite stats --json # machine-readable
197
+ npx -y -p codex-dev-mcp-suite stats --root /tmp/mem # different root
198
+ npx -y -p codex-dev-mcp-suite stats --top 5 # trim top lists
199
+ ```
200
+
201
+ Example output:
202
+
203
+ ```
204
+ Dev MCP Suite — stats
205
+ ======================
206
+ Storage root: /home/you/.codex/memories
207
+
208
+ Totals
209
+ ------
210
+ Notes: 89
211
+ Journal projects:19
212
+ Checkpoints: 1
213
+ Distinct projects: 25
214
+
215
+ Top projects by notes (top 10)
216
+ ------------------------
217
+ 24 mrtrickster99-fd1ff0fa
218
+ 14 Coding-17e063ef
219
+ ...
220
+
221
+ Temp/cleanup candidates (2)
222
+ ------------------------
223
+ tmp.itbtDn9eB3-b62798fd
224
+ tmp.iHqM2Uh5K2-8d3660a2
225
+ ```
226
+
227
+ The CLI is a thin wrapper around `lib/stats.js`, which is also importable
228
+ directly from your own scripts. The default storage root is
229
+ `~/.codex/memories`, but `--root` (or the existing `MEMORY_VAULT_DIR` /
230
+ `JOURNAL_DIR` / `CHECKPOINT_DIR` env vars) override it.
231
+
232
+
233
+ ## Prune CLI
234
+
235
+ Remove temp project slugs (prefix `tmp.`) from vault / journal / checkpoints.
236
+ Default is DRY-RUN — nothing is deleted without `--yes`.
237
+
238
+ ```bash
239
+ npx -y -p codex-dev-mcp-suite prune # dry-run report
240
+ npx -y -p codex-dev-mcp-suite prune --yes # actually delete
241
+ npx -y -p codex-dev-mcp-suite prune --json # machine-readable
242
+ npx -y -p codex-dev-mcp-suite prune --root /tmp/mem # different root
243
+ ```
244
+
245
+ Safety: refuses to delete any slug that does not start with `tmp.`. Useful
246
+ right after heavy refactors or after imports that left tmp scratch dirs.
247
+
248
+ For in-session use from an MCP client, the project-memory server exposes a
249
+ `memory_stats` tool that returns the same summary text (or JSON) as the `stats`
250
+ CLI above.
251
+
141
252
  ## Tests
142
253
 
143
254
  ```bash
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
+ }
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
+ }
package/lib/stats.js ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Dev MCP Suite — stats aggregation library.
3
+ *
4
+ * Pure functions for summarizing local memory storage across the three stores
5
+ * (vault / journal / checkpoints). No MCP, no stdio — used by bin/stats.mjs
6
+ * and safe to import from tests.
7
+ *
8
+ * Storage layout assumed (see each MCP server's server.js for source of truth):
9
+ * <root>/vault/<slug>/notes/*.md (one .md per memory_save note)
10
+ * <root>/journal/<slug>/journal.jsonl (append-only entries)
11
+ * <root>/journal/<slug>/handoff.json (optional)
12
+ * <root>/checkpoints/<slug>/snapshots/<id>/ (one dir per snapshot)
13
+ * <root>/checkpoints/<slug>/manifest.json (checkpoint index)
14
+ */
15
+
16
+ import fs from "fs";
17
+ import path from "path";
18
+
19
+ const VAULT_DIRNAME = "vault";
20
+ const JOURNAL_DIRNAME = "journal";
21
+ const CHECKPOINT_DIRNAME = "checkpoints";
22
+
23
+ function safeListDirs(parent) {
24
+ try {
25
+ return fs
26
+ .readdirSync(parent, { withFileTypes: true })
27
+ .filter((d) => d.isDirectory())
28
+ .map((d) => d.name);
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function safeListFiles(parent) {
35
+ try {
36
+ return fs.readdirSync(parent);
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ function newestMtimeMs(dir) {
43
+ try {
44
+ const stat = fs.statSync(dir);
45
+ return stat.mtimeMs;
46
+ } catch {
47
+ return 0;
48
+ }
49
+ }
50
+
51
+ function isoFromMs(ms) {
52
+ if (!ms) return null;
53
+ return new Date(ms).toISOString();
54
+ }
55
+
56
+ function isTempSlug(slug) {
57
+ return /^tmp\./.test(slug);
58
+ }
59
+
60
+ function summarizeProject(slug, { root }) {
61
+ const vaultDir = path.join(root, VAULT_DIRNAME, slug);
62
+ const journalDir = path.join(root, JOURNAL_DIRNAME, slug);
63
+ const cpDir = path.join(root, CHECKPOINT_DIRNAME, slug);
64
+
65
+ const notesDir = path.join(vaultDir, "notes");
66
+ const notes = safeListFiles(notesDir).filter((f) => f.endsWith(".md")).length;
67
+
68
+ let journalEntries = 0;
69
+ let lastEntryTs = null;
70
+ const jl = path.join(journalDir, "journal.jsonl");
71
+ if (fs.existsSync(jl)) {
72
+ try {
73
+ const lines = fs.readFileSync(jl, "utf8").split("\n").filter(Boolean);
74
+ journalEntries = lines.length;
75
+ // journal entries are append-only and roughly time-ordered; parse first + last
76
+ for (const line of lines) {
77
+ try {
78
+ const obj = JSON.parse(line);
79
+ if (obj && obj.ts) {
80
+ if (!lastEntryTs || obj.ts > lastEntryTs) lastEntryTs = obj.ts;
81
+ }
82
+ } catch { /* skip malformed */ }
83
+ }
84
+ } catch { /* ignore read errors */ }
85
+ }
86
+
87
+ // checkpoints: count snapshot dirs
88
+ let checkpoints = 0;
89
+ const snapsDir = path.join(cpDir, "snapshots");
90
+ if (fs.existsSync(snapsDir)) {
91
+ checkpoints = safeListDirs(snapsDir).length;
92
+ }
93
+
94
+ // last activity across all artifacts (mtime fallback)
95
+ let lastActivityMs = 0;
96
+ for (const d of [vaultDir, journalDir, cpDir]) {
97
+ const m = newestMtimeMs(d);
98
+ if (m > lastActivityMs) lastActivityMs = m;
99
+ }
100
+ // prefer parsed entry ts if newer than filesystem mtime
101
+ if (lastEntryTs) {
102
+ const t = Date.parse(lastEntryTs);
103
+ if (!Number.isNaN(t) && t > lastActivityMs) lastActivityMs = t;
104
+ }
105
+ const lastActivityTs = isoFromMs(lastActivityMs);
106
+
107
+ return {
108
+ slug,
109
+ notes,
110
+ journalEntries,
111
+ checkpoints,
112
+ lastActivityTs,
113
+ temp: isTempSlug(slug),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Compute aggregate stats for a memory root.
119
+ * @param {object} opts
120
+ * @param {string} opts.root - absolute path to memories root (containing vault/, journal/, checkpoints/)
121
+ * @param {number} [opts.topLimit=10] - max entries in topByNotes / recentActivity
122
+ * @returns {{
123
+ * root: string,
124
+ * totals: { notes: number, journalProjects: number, checkpoints: number },
125
+ * projectCount: number,
126
+ * topByNotes: Array<{ slug: string, notes: number }>,
127
+ * recentActivity: Array<{ slug: string, ts: string }>,
128
+ * tempSlugs: string[],
129
+ * perProject: Array<{ slug: string, notes: number, journalEntries: number, checkpoints: number, lastActivityTs: string|null, temp: boolean }>,
130
+ * }}
131
+ */
132
+ export function computeStats({ root, topLimit = 10 } = {}) {
133
+ if (!root) throw new Error("computeStats: `root` is required");
134
+
135
+ // union of slugs across all three stores
136
+ const slugSet = new Set();
137
+ for (const store of [VAULT_DIRNAME, JOURNAL_DIRNAME, CHECKPOINT_DIRNAME]) {
138
+ for (const slug of safeListDirs(path.join(root, store))) slugSet.add(slug);
139
+ }
140
+
141
+ const perProject = [...slugSet]
142
+ .map((slug) => summarizeProject(slug, { root }))
143
+ .sort((a, b) => {
144
+ if (a.temp !== b.temp) return a.temp ? 1 : -1; // real first
145
+ return a.slug.localeCompare(b.slug);
146
+ });
147
+
148
+ const totals = perProject.reduce(
149
+ (acc, p) => ({
150
+ notes: acc.notes + p.notes,
151
+ journalProjects: acc.journalProjects + (p.journalEntries > 0 ? 1 : 0),
152
+ checkpoints: acc.checkpoints + p.checkpoints,
153
+ }),
154
+ { notes: 0, journalProjects: 0, checkpoints: 0 },
155
+ );
156
+
157
+ const topByNotes = [...perProject]
158
+ .filter((p) => p.notes > 0)
159
+ .sort((a, b) => b.notes - a.notes || a.slug.localeCompare(b.slug))
160
+ .slice(0, topLimit)
161
+ .map((p) => ({ slug: p.slug, notes: p.notes }));
162
+
163
+ const recentActivity = [...perProject]
164
+ .filter((p) => p.lastActivityTs && !p.temp)
165
+ .sort((a, b) => (a.lastActivityTs < b.lastActivityTs ? 1 : -1))
166
+ .slice(0, topLimit)
167
+ .map((p) => ({ slug: p.slug, ts: p.lastActivityTs }));
168
+
169
+ const tempSlugs = perProject.filter((p) => p.temp).map((p) => p.slug).sort();
170
+
171
+ return {
172
+ root,
173
+ totals,
174
+ projectCount: perProject.length,
175
+ topByNotes,
176
+ recentActivity,
177
+ tempSlugs,
178
+ perProject,
179
+ };
180
+ }
181
+
182
+ /** Right-pad a slug to a fixed display width. */
183
+ function pad(s, w) {
184
+ s = String(s);
185
+ if (s.length >= w) return s + " ";
186
+ return s + " ".repeat(w - s.length);
187
+ }
188
+ function rpadNum(n, w) {
189
+ const s = String(n);
190
+ return s.length >= w ? s : " ".repeat(w - s.length) + s;
191
+ }
192
+
193
+ function formatLocal(ts) {
194
+ if (!ts) return "—";
195
+ try {
196
+ return new Date(ts).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
197
+ } catch {
198
+ return ts;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Human-readable text summary.
204
+ * @param {ReturnType<typeof computeStats>} stats
205
+ * @param {object} [opts]
206
+ * @param {string} [opts.timezone="UTC"] - display-only label, not used to convert
207
+ */
208
+ export function formatText(stats, opts = {}) {
209
+ const lines = [];
210
+ lines.push("Dev MCP Suite — stats");
211
+ lines.push("======================");
212
+ lines.push(`Storage root: ${stats.root}`);
213
+ lines.push("");
214
+ lines.push("Totals");
215
+ lines.push("------");
216
+ lines.push(` Notes: ${stats.totals.notes}`);
217
+ lines.push(` Journal projects:${rpadNum(stats.totals.journalProjects, 2)}`);
218
+ lines.push(` Checkpoints: ${stats.totals.checkpoints}`);
219
+ lines.push(` Distinct projects: ${stats.projectCount}`);
220
+ lines.push("");
221
+
222
+ if (stats.topByNotes.length) {
223
+ lines.push(`Top projects by notes (top ${stats.topByNotes.length})`);
224
+ lines.push("------------------------");
225
+ const slugW = Math.max(8, ...stats.topByNotes.map((p) => p.slug.length));
226
+ for (const p of stats.topByNotes) {
227
+ lines.push(` ${rpadNum(p.notes, 4)} ${pad(p.slug, slugW)}`);
228
+ }
229
+ lines.push("");
230
+ }
231
+
232
+ if (stats.recentActivity.length) {
233
+ lines.push("Most recent activity");
234
+ lines.push("--------------------");
235
+ const slugW = Math.max(8, ...stats.recentActivity.map((p) => p.slug.length));
236
+ for (const p of stats.recentActivity) {
237
+ lines.push(` ${pad(p.slug, slugW)} ${formatLocal(p.ts)}`);
238
+ }
239
+ lines.push("");
240
+ }
241
+
242
+ if (stats.tempSlugs.length) {
243
+ lines.push(`Temp/cleanup candidates (${stats.tempSlugs.length})`);
244
+ lines.push("------------------------");
245
+ for (const slug of stats.tempSlugs) lines.push(` ${slug}`);
246
+ lines.push("");
247
+ } else {
248
+ lines.push("Temp/cleanup candidates: none");
249
+ lines.push("");
250
+ }
251
+
252
+ lines.push(`Generated at ${new Date().toISOString()}`);
253
+ return lines.join("\n");
254
+ }
255
+
256
+ /** Stable JSON serialization (sorted keys for top-level + totals). */
257
+ export function formatJson(stats) {
258
+ const out = {
259
+ root: stats.root,
260
+ totals: stats.totals,
261
+ projectCount: stats.projectCount,
262
+ topByNotes: stats.topByNotes,
263
+ recentActivity: stats.recentActivity,
264
+ tempSlugs: stats.tempSlugs,
265
+ perProject: stats.perProject,
266
+ generatedAt: new Date().toISOString(),
267
+ };
268
+ return JSON.stringify(out, null, 2);
269
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-dev-mcp-suite",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,10 +32,13 @@
32
32
  "project-memory-mcp": "bin/project-memory.mjs",
33
33
  "devjournal-mcp": "bin/devjournal.mjs",
34
34
  "checkpoint-mcp": "bin/checkpoint.mjs",
35
- "context-pack-mcp": "bin/context-pack.mjs"
35
+ "context-pack-mcp": "bin/context-pack.mjs",
36
+ "stats": "bin/stats.mjs",
37
+ "prune": "bin/prune.mjs"
36
38
  },
37
39
  "scripts": {
38
- "test": "node run-tests.mjs"
40
+ "test": "node run-tests.mjs",
41
+ "test:stats": "node tests/stats.test.mjs"
39
42
  },
40
43
  "dependencies": {
41
44
  "@modelcontextprotocol/sdk": "^1.0.0"
@@ -70,6 +73,7 @@
70
73
  "CHANGELOG.md",
71
74
  "README.md",
72
75
  "LICENSE",
73
- ".env.example"
76
+ ".env.example",
77
+ "lib/*.js"
74
78
  ]
75
79
  }
@@ -29,6 +29,7 @@ import crypto from "crypto";
29
29
  import { embed, embedOne, cosine, embeddingConfig } from "./embedding.js";
30
30
  import { rerank, rerankConfig } from "./rerank.js";
31
31
  import { deterministicEnabled } from "./env.js";
32
+ import { computeStats, formatText, formatJson } from "../lib/stats.js";
32
33
 
33
34
  const VAULT_ROOT =
34
35
  process.env.MEMORY_VAULT_DIR ||
@@ -220,6 +221,18 @@ class ProjectMemoryServer {
220
221
  },
221
222
  },
222
223
  },
224
+ {
225
+ name: "memory_stats",
226
+ description: "Summarize local memory storage across vault / journal / checkpoints: totals, top projects, recent activity, and temp-slug cleanup candidates. Returns the same text as the `stats` CLI.",
227
+ inputSchema: {
228
+ type: "object",
229
+ properties: {
230
+ root: { type: "string", description: "Storage root (default: ~/.codex/memories or first env var of MEMORY_VAULT_DIR / JOURNAL_DIR / CHECKPOINT_DIR)" },
231
+ top: { type: "number", description: "How many entries in top-project / recent-activity lists (default 10)", default: 10 },
232
+ json: { type: "boolean", description: "Return machine-readable JSON instead of human text", default: false },
233
+ },
234
+ },
235
+ },
223
236
  ],
224
237
  }));
225
238
 
@@ -233,6 +246,7 @@ class ProjectMemoryServer {
233
246
  case "memory_get": return await this.get(args || {});
234
247
  case "memory_delete": return await this.del(args || {});
235
248
  case "memory_reindex": return await this.reindex(args || {});
249
+ case "memory_stats": return await this.stats(args || {});
236
250
  default: throw new Error(`Unknown tool: ${name}`);
237
251
  }
238
252
  } catch (error) {
@@ -439,6 +453,21 @@ class ProjectMemoryServer {
439
453
  return { content: [{ type: "text", text: `Reindex ${p.slug}: embedded ${done}, failed ${failed} (embeddings ${embeddingConfig().enabled ? "configured" : "not configured"}). ${failed ? "Failures usually mean the embedding model is unavailable right now." : ""}` }] };
440
454
  }
441
455
 
456
+ async stats({ root: explicitRoot, top = 10, json = false } = {}) {
457
+ const root = explicitRoot
458
+ ? path.resolve(explicitRoot)
459
+ : (process.env.MEMORY_VAULT_DIR
460
+ ? path.dirname(process.env.MEMORY_VAULT_DIR)
461
+ : process.env.JOURNAL_DIR
462
+ ? path.dirname(process.env.JOURNAL_DIR)
463
+ : process.env.CHECKPOINT_DIR
464
+ ? path.dirname(process.env.CHECKPOINT_DIR)
465
+ : path.join(os.homedir(), ".codex", "memories"));
466
+ const stats = computeStats({ root, topLimit: top });
467
+ const text = json ? formatJson(stats) : formatText(stats);
468
+ return { content: [{ type: "text", text }] };
469
+ }
470
+
442
471
  async run() {
443
472
  const transport = new StdioServerTransport();
444
473
  await this.server.connect(transport);