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/.env.example +19 -0
- package/CHANGELOG.md +64 -0
- package/README.md +179 -0
- package/bin/provider-smoke.mjs +278 -0
- package/bin/prune.mjs +158 -0
- package/bin/stats.mjs +101 -0
- package/lib/provider-smoke.js +234 -0
- package/lib/prune.js +114 -0
- package/lib/stats.js +269 -0
- package/package.json +9 -4
- package/project-memory/embedding.js +108 -30
- package/project-memory/server.js +46 -4
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 ${
|
|
84
|
+
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
36
85
|
},
|
|
37
|
-
timeout:
|
|
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(
|
|
44
|
-
try { resolve(JSON.parse(body)); } catch { resolve(
|
|
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
|
-
|
|
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 (
|
|
137
|
+
if (deterministicEnabled()) return [];
|
|
138
|
+
const { base, key } = readEnv();
|
|
139
|
+
if (!key) return [];
|
|
58
140
|
const arr = Array.isArray(inputs) ? inputs : [inputs];
|
|
59
|
-
if (
|
|
60
|
-
|
|
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
|
|
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++) {
|
|
79
|
-
|
|
80
|
-
|
|
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
|
}
|
package/project-memory/server.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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);
|