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/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.4.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,14 @@
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",
38
+ "provider-smoke": "bin/provider-smoke.mjs"
36
39
  },
37
40
  "scripts": {
38
- "test": "node run-tests.mjs"
41
+ "test": "node run-tests.mjs",
42
+ "test:stats": "node tests/stats.test.mjs"
39
43
  },
40
44
  "dependencies": {
41
45
  "@modelcontextprotocol/sdk": "^1.0.0"
@@ -70,6 +74,7 @@
70
74
  "CHANGELOG.md",
71
75
  "README.md",
72
76
  "LICENSE",
73
- ".env.example"
77
+ ".env.example",
78
+ "lib/*.js"
74
79
  ]
75
80
  }
@@ -1,47 +1,96 @@
1
1
  /**
2
- * Embedding helper for any OpenAI-compatible /v1/embeddings endpoint.
3
- * Degrades gracefully: returns null on any failure so callers fall back
2
+ * Embedding helper for any OpenAI-compatible /v1/embeddings endpoint,
3
+ * with first-class support for Cloudflare Workers AI (REST-only, non-OpenAI).
4
+ *
5
+ * Degrades gracefully: returns null/[] on any failure so callers fall back
4
6
  * to keyword search. Never throws.
7
+ *
8
+ * Endpoint formats:
9
+ * - OpenAI-compatible: POST {base}/v1/embeddings body: {model, input:[...]}
10
+ * response: {data:[{embedding:[...], index}]}
11
+ * - Cloudflare Workers: POST {base}/{model} body: {text:[...]}
12
+ * response: {result:{data:[[...]], shape:[n,dim]}}
13
+ *
14
+ * Cloudflare is auto-detected by URL pattern (contains "api.cloudflare.com/client/v4/accounts").
15
+ * To use it: set MCP_EMBED_BASE_URL=https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/run/
16
+ * and MCP_EMBED_MODEL=@cf/baai/bge-base-en-v1.5
5
17
  */
6
18
  import http from "http";
7
19
  import https from "https";
8
20
  import { URL } from "url";
9
21
  import { deterministicEnabled } from "./env.js";
10
22
 
11
- const BASE = process.env.MCP_EMBED_BASE_URL || process.env.MCP_LLM_BASE_URL || process.env.LLM_BASE_URL || process.env.EMBED_URL || process.env.NINEROUTER_URL || "http://localhost:20128";
12
- const KEY = process.env.MCP_EMBED_API_KEY || process.env.MCP_LLM_API_KEY || process.env.LLM_API_KEY || process.env.EMBED_KEY || process.env.NINEROUTER_KEY || "";
13
- const MODEL = process.env.MCP_EMBED_MODEL || process.env.EMBED_MODEL || "bm/baai/bge-m3";
14
- const TIMEOUT_MS = Number(process.env.EMBED_TIMEOUT_MS || 15000);
23
+ /** Read env at runtime (not module-load time) so tests can override and config is dynamic). */
24
+ function readEnv() {
25
+ return {
26
+ base: process.env.MCP_EMBED_BASE_URL || process.env.MCP_LLM_BASE_URL || process.env.LLM_BASE_URL || process.env.EMBED_URL || process.env.NINEROUTER_URL || "http://localhost:20128",
27
+ key: process.env.MCP_EMBED_API_KEY || process.env.MCP_LLM_API_KEY || process.env.LLM_API_KEY || process.env.EMBED_KEY || process.env.NINEROUTER_KEY || "",
28
+ model: process.env.MCP_EMBED_MODEL || process.env.EMBED_MODEL || "bm/baai/bge-m3",
29
+ timeoutMs: Number(process.env.EMBED_TIMEOUT_MS || 15000),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Resolve what retrieval mode memory_recall will use, given current env.
35
+ * Returns one of: "deterministic" | "semantic" | "keyword".
36
+ * Independent of MCP_RERANK_ENABLED (rerank layers on top of either).
37
+ */
38
+ export function recallMode() {
39
+ if (deterministicEnabled()) return "deterministic";
40
+ const { key } = readEnv();
41
+ return key ? "semantic" : "keyword";
42
+ }
15
43
 
16
44
  export function embeddingConfig() {
17
- return { base: BASE, model: MODEL, enabled: Boolean(KEY) && !deterministicEnabled(), deterministic: deterministicEnabled() };
45
+ const { base, key, model } = readEnv();
46
+ return {
47
+ base,
48
+ model,
49
+ enabled: Boolean(key) && !deterministicEnabled(),
50
+ deterministic: deterministicEnabled(),
51
+ endpoint: isCloudflareURL(base) ? "cloudflare" : "openai-compatible",
52
+ mode: recallMode(),
53
+ };
18
54
  }
19
55
 
20
- function postJson(urlStr, payload) {
56
+ /** Detect Cloudflare Workers AI by URL pattern. */
57
+ export function isCloudflareURL(urlStr) {
58
+ return typeof urlStr === "string" && urlStr.includes("api.cloudflare.com/client/v4/accounts");
59
+ }
60
+
61
+ /**
62
+ * POST a JSON body to an absolute URL with Bearer auth. Returns:
63
+ * { status: 200, body: <parsed JSON> } on success
64
+ * { status: <code>, body: <raw text> } on non-200
65
+ * null on network/timeout/parse error
66
+ * Caller is responsible for building the full URL (we don't append /v1/embeddings etc).
67
+ */
68
+ function httpPostJson(urlStr, payload) {
21
69
  return new Promise((resolve) => {
22
70
  let u;
23
- try { u = new URL("/v1/embeddings", urlStr); } catch { return resolve(null); }
71
+ try { u = new URL(urlStr); } catch { return resolve(null); }
24
72
  const data = Buffer.from(JSON.stringify(payload));
73
+ const { key, timeoutMs } = readEnv();
25
74
  const lib = u.protocol === "https:" ? https : http;
26
75
  const req = lib.request(
27
76
  {
28
77
  hostname: u.hostname,
29
78
  port: u.port || (u.protocol === "https:" ? 443 : 80),
30
- path: u.pathname,
79
+ path: u.pathname + (u.search || ""),
31
80
  method: "POST",
32
81
  headers: {
33
82
  "Content-Type": "application/json",
34
83
  "Content-Length": data.length,
35
- Authorization: `Bearer ${KEY}`,
84
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
36
85
  },
37
- timeout: TIMEOUT_MS,
86
+ timeout: timeoutMs,
38
87
  },
39
88
  (res) => {
40
89
  let body = "";
41
90
  res.on("data", (c) => (body += c));
42
91
  res.on("end", () => {
43
- if (res.statusCode !== 200) return resolve(null);
44
- try { resolve(JSON.parse(body)); } catch { resolve(null); }
92
+ if (res.statusCode !== 200) return resolve({ status: res.statusCode, body });
93
+ try { resolve({ status: 200, body: JSON.parse(body) }); } catch { resolve({ status: res.statusCode, body }); }
45
94
  });
46
95
  }
47
96
  );
@@ -52,30 +101,59 @@ function postJson(urlStr, payload) {
52
101
  });
53
102
  }
54
103
 
55
- /** Returns array of vectors aligned to inputs, or null if unavailable. */
104
+ async function embedOpenAI(inputs) {
105
+ const { base, key, model } = readEnv();
106
+ if (!key) return [];
107
+ try {
108
+ const res = await httpPostJson(`${base.replace(/\/$/, "")}/v1/embeddings`, { model, input: inputs });
109
+ if (!res || res.status !== 200 || !res.body || !Array.isArray(res.body.data)) return [];
110
+ const json = res.body;
111
+ const sorted = [...json.data].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
112
+ return sorted.map((d) => d.embedding).filter(Boolean);
113
+ } catch { return []; }
114
+ }
115
+
116
+ async function embedCloudflare(inputs) {
117
+ const { base: rawBase, key, model } = readEnv();
118
+ if (!key) return [];
119
+ // Cloudflare: model is in URL path; do NOT URL-encode ("@" and "/" must stay literal).
120
+ const base = rawBase.replace(/\/$/, "");
121
+ const url = `${base}/${model}`;
122
+ try {
123
+ const res = await httpPostJson(url, { text: inputs });
124
+ if (!res || res.status !== 200 || !res.body || !res.body.result) return [];
125
+ const data = res.body.result.data;
126
+ if (!Array.isArray(data)) return [];
127
+ return data.filter((v) => Array.isArray(v));
128
+ } catch { return []; }
129
+ }
130
+
131
+ /**
132
+ * Embed an array of strings (or a single string) and return an array of
133
+ * numeric vectors. Returns [] when no API key is configured, deterministic
134
+ * mode is on, or the endpoint fails.
135
+ */
56
136
  export async function embed(inputs) {
57
- if (!KEY || deterministicEnabled()) return null;
137
+ if (deterministicEnabled()) return [];
138
+ const { base, key } = readEnv();
139
+ if (!key) return [];
58
140
  const arr = Array.isArray(inputs) ? inputs : [inputs];
59
- if (arr.length === 0) return [];
60
- const json = await postJson(BASE, { model: MODEL, input: arr });
61
- if (!json || !Array.isArray(json.data)) return null;
62
- try {
63
- const sorted = json.data.slice().sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
64
- const vecs = sorted.map((d) => d.embedding);
65
- if (vecs.some((v) => !Array.isArray(v) || v.length === 0)) return null;
66
- return vecs;
67
- } catch { return null; }
141
+ if (isCloudflareURL(base)) return await embedCloudflare(arr);
142
+ return await embedOpenAI(arr);
68
143
  }
69
144
 
70
145
  export async function embedOne(text) {
71
146
  const v = await embed([text]);
72
- return v ? v[0] : null;
147
+ return v[0] || null;
73
148
  }
74
149
 
75
150
  export function cosine(a, b) {
76
- if (!a || !b || a.length !== b.length) return 0;
151
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return 0;
77
152
  let dot = 0, na = 0, nb = 0;
78
- for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
79
- if (na === 0 || nb === 0) return 0;
80
- return dot / (Math.sqrt(na) * Math.sqrt(nb));
153
+ for (let i = 0; i < a.length; i++) {
154
+ const x = a[i], y = b[i];
155
+ dot += x * y; na += x * x; nb += y * y;
156
+ }
157
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
158
+ return denom === 0 ? 0 : dot / denom;
81
159
  }
@@ -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 ||
@@ -162,7 +163,7 @@ class ProjectMemoryServer {
162
163
  {
163
164
  name: "memory_recall",
164
165
  description:
165
- "Retrieve the most relevant saved notes for a query using the keyword index. Call this at the start of a new session instead of re-pasting context.",
166
+ "Retrieve the most relevant saved notes for a query. Falls back gracefully: semantic -> keyword -> LLM rerank. Set mode to control behavior: 'auto' (default), 'semantic' (require embedding), 'keyword' (skip embedding).",
166
167
  inputSchema: {
167
168
  type: "object",
168
169
  properties: {
@@ -170,6 +171,7 @@ class ProjectMemoryServer {
170
171
  dir: { type: "string", description: "Project directory (defaults to CWD)" },
171
172
  limit: { type: "number", description: "Max notes to return", default: 5 },
172
173
  full: { type: "boolean", description: "Return full bodies instead of excerpts", default: false },
174
+ mode: { type: "string", enum: ["auto", "semantic", "keyword"], default: "auto", description: "Retrieval mode: 'auto' (default, smart fallback), 'semantic' (require embedding), 'keyword' (skip embedding)" },
173
175
  },
174
176
  required: ["query"],
175
177
  },
@@ -220,6 +222,18 @@ class ProjectMemoryServer {
220
222
  },
221
223
  },
222
224
  },
225
+ {
226
+ name: "memory_stats",
227
+ 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.",
228
+ inputSchema: {
229
+ type: "object",
230
+ properties: {
231
+ root: { type: "string", description: "Storage root (default: ~/.codex/memories or first env var of MEMORY_VAULT_DIR / JOURNAL_DIR / CHECKPOINT_DIR)" },
232
+ top: { type: "number", description: "How many entries in top-project / recent-activity lists (default 10)", default: 10 },
233
+ json: { type: "boolean", description: "Return machine-readable JSON instead of human text", default: false },
234
+ },
235
+ },
236
+ },
223
237
  ],
224
238
  }));
225
239
 
@@ -233,6 +247,7 @@ class ProjectMemoryServer {
233
247
  case "memory_get": return await this.get(args || {});
234
248
  case "memory_delete": return await this.del(args || {});
235
249
  case "memory_reindex": return await this.reindex(args || {});
250
+ case "memory_stats": return await this.stats(args || {});
236
251
  default: throw new Error(`Unknown tool: ${name}`);
237
252
  }
238
253
  } catch (error) {
@@ -302,7 +317,7 @@ class ProjectMemoryServer {
302
317
  return { content: [{ type: "text", text: `Saved note ${id} → ${p.slug}\nFile: ${file}\nKeywords indexed: ${keywords.length}${embedded ? " (semantic embedding stored)" : " (keyword-only; embeddings unavailable)"}` }] };
303
318
  }
304
319
 
305
- async recall({ query, dir, limit: lim = 5, full = false }) {
320
+ async recall({ query, dir, limit: lim = 5, full = false, mode: requestedMode = "auto" }) {
306
321
  query = limit(query, "query", 2000);
307
322
  const p = this.paths(dir);
308
323
  const index = await this.loadIndex(p);
@@ -319,10 +334,17 @@ class ProjectMemoryServer {
319
334
  return score;
320
335
  };
321
336
 
337
+ // Tier 2.1: respect requested mode. "auto" = smart fallback.
322
338
  let mode = deterministicEnabled() ? "deterministic" : "keyword";
323
- const qVec = deterministicEnabled() ? null : await embedOne(query);
339
+ const useEmbed = requestedMode !== "keyword" && !deterministicEnabled();
340
+ const qVec = useEmbed ? await embedOne(query) : null;
324
341
  const haveEmb = qVec && notes.some((n) => Array.isArray(n.embedding));
325
342
 
343
+ // Tier 2.1: "semantic" requested but unavailable -> error
344
+ if (requestedMode === "semantic" && !haveEmb) {
345
+ return { content: [{ type: "text", text: `memory_recall(mode=semantic) requested but no embeddings available. Set MCP_EMBED_API_KEY + MCP_EMBED_MODEL, or omit mode for keyword fallback.` }], isError: true };
346
+ }
347
+
326
348
  let scored;
327
349
  if (haveEmb) {
328
350
  mode = "semantic";
@@ -381,7 +403,12 @@ class ProjectMemoryServer {
381
403
  const tag = mode === "semantic" ? `sim:${(sim ?? 0).toFixed(3)}` : (mode === "rerank" ? "llm-ranked" : `score:${score}`);
382
404
  blocks.push(`### ${n.title} (id:${n.id}, ${tag}, ${n.created})\n${text}`);
383
405
  }
384
- return { content: [{ type: "text", text: `Recall for "${query}" in ${p.slug} [${mode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
406
+ // Tier 2.2: annotate mode with rerank indicator
407
+ const isReranked = rerankConfig().enabled && blocks.length > 1;
408
+ const displayMode = mode === "semantic" && isReranked ? "semantic+rerank"
409
+ : mode === "keyword" && isReranked ? "keyword+rerank"
410
+ : mode;
411
+ return { content: [{ type: "text", text: `Recall for "${query}" in ${p.slug} [${displayMode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
385
412
  }
386
413
 
387
414
  async list({ dir, limit: lim = 20 }) {
@@ -439,6 +466,21 @@ class ProjectMemoryServer {
439
466
  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
467
  }
441
468
 
469
+ async stats({ root: explicitRoot, top = 10, json = false } = {}) {
470
+ const root = explicitRoot
471
+ ? path.resolve(explicitRoot)
472
+ : (process.env.MEMORY_VAULT_DIR
473
+ ? path.dirname(process.env.MEMORY_VAULT_DIR)
474
+ : process.env.JOURNAL_DIR
475
+ ? path.dirname(process.env.JOURNAL_DIR)
476
+ : process.env.CHECKPOINT_DIR
477
+ ? path.dirname(process.env.CHECKPOINT_DIR)
478
+ : path.join(os.homedir(), ".codex", "memories"));
479
+ const stats = computeStats({ root, topLimit: top });
480
+ const text = json ? formatJson(stats) : formatText(stats);
481
+ return { content: [{ type: "text", text }] };
482
+ }
483
+
442
484
  async run() {
443
485
  const transport = new StdioServerTransport();
444
486
  await this.server.connect(transport);