codex-dev-mcp-suite 1.3.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 CHANGED
@@ -40,6 +40,25 @@ MCP_RERANK_MODEL=llama3.1:8b
40
40
  RERANK_TIMEOUT_MS=30000
41
41
  RERANK_ENABLED=1
42
42
 
43
+
44
+ # === Cloudflare Workers AI (REST-only, NOT OpenAI-compatible) ===
45
+ # Free tier: 10,000 neurons/day. No laptop resource usage.
46
+ # To get started: dash.cloudflare.com → Workers & Pages → Create Worker → Workers AI → enable
47
+ # Account ID: from dashboard sidebar. API Token: My Profile → API Tokens → "Edit Cloudflare Workers".
48
+ # Note: base URL has trailing slash; model name goes in URL path, not body.
49
+ # Set CLOUDFLARE_EMBED_MODEL (e.g. @cf/baai/bge-base-en-v1.5) to enable semantic recall
50
+ # via Cloudflare instead of 9router/OpenAI/etc.
51
+ # MCP_EMBED_BASE_URL=https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/run/
52
+ # MCP_EMBED_API_KEY=<cf-token>
53
+ # MCP_EMBED_MODEL=@cf/baai/bge-base-en-v1.5
54
+
55
+ # === Google Gemini (OpenAI-compatible, generous free tier) ===
56
+ # Get key: https://aistudio.google.com/app/apikey
57
+ # Free tier: 60 req/min, 1500/day for text-embedding-004
58
+ # GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
59
+ # GEMINI_API_KEY=<gemini-key>
60
+ # GEMINI_MODEL=text-embedding-004
61
+
43
62
  # Storage locations (defaults shown; override if you want)
44
63
  # MEMORY_VAULT_DIR=~/.codex/memories/vault
45
64
  # JOURNAL_DIR=~/.codex/memories/journal
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0 - 2026-06-17
4
+
5
+ ### Added
6
+ - **`provider-smoke` CLI** (`bin/provider-smoke.mjs`): probe every configured LLM provider (chat + embeddings) and print a matrix of latency / status / sample. Useful after adding a new API key or comparing providers.
7
+ - Usage: `npx -y -p codex-dev-mcp-suite provider-smoke` (or globally `provider-smoke`)
8
+ - Flags: `--json`, `--markdown`, `--save-md <path>`, `--chat-only`, `--embed-only`, `--env-file <path>`, `--only a,b,c`, `--timeout <ms>`, `--help`, `--version`
9
+ - Auto-detects providers from `MCP_PROVIDER_*` slots + named env (`GROQ_*`, `CEREBRAS_*`, `MISTRAL_*`, `OPENROUTER_*`, `OPENAI_*`, `OLLAMA_*`, `ANTHROPIC_*`, `GEMINI_*`, `COHERE_*`, `VOYAGE_*`, `CLOUDFLARE_*`) + `MCP_LLM_BASE_URL` / `NINEROUTER_URL` fallback.
10
+ - Pure-function library at `lib/provider-smoke.js` (18 offline tests).
11
+ - `KNOWN_PROVIDERS` registry covers 11 providers with `supportsChat` / `supportsEmbed` flags + `endpoint: "openai-compatible" | "cloudflare"`.
12
+ - Cloudflare embed-only models (`@cf/baai/*`, `@cf/google/embedding*`, etc.) are detected and the chat probe is skipped with a clear annotation.
13
+ - **Cloudflare Workers AI support** for embeddings: drop-in alternative when 9router/OpenAI are unavailable. Set `MCP_EMBED_BASE_URL=https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/run/`, `MCP_EMBED_API_KEY=<token>`, `MCP_EMBED_MODEL=@cf/baai/bge-base-en-v1.5`.
14
+ - Cloudflare's REST API (non-OpenAI-compatible, model-in-path) is auto-detected by URL pattern and dispatched to a separate code path in `project-memory/embedding.js`.
15
+ - New `isCloudflareURL()` + `httpPostJson()` helpers; `embedCloudflare()` handles `{text: [...]}` body and `{result: {data: [[...]]}}` response.
16
+ - `embeddingConfig().mode` and `recallMode()` helpers expose what retrieval mode is active (`semantic` / `keyword` / `deterministic`).
17
+ - **`memory_recall` mode arg** (default `"auto"`): explicit control over fallback behavior. `mode: "semantic"` requires embedding (returns `isError: true` if unavailable), `mode: "keyword"` skips embedding entirely.
18
+ - **Recall output annotation** now shows `[semantic+rerank]` / `[keyword+rerank]` when LLM rerank is active, making the active mode fully transparent.
19
+ - **Top-level test runner hook** (added in 1.3.0, expanded in 1.4.0): `tests/*.test.mjs` are auto-picked-up by `node run-tests.mjs`.
20
+
21
+ ### Tests
22
+ - 85 tests pass (was 60 in v1.3.0): 26 project-memory + 6 checkpoint + 6 context-pack + 11 devjournal + 18 provider-smoke + 9 prune + 9 stats.
23
+ - New fallback tests: `mode=keyword` skips semantic, `mode=semantic` without embed key returns isError, default mode=auto, no `+rerank` suffix when rerank disabled.
24
+
25
+ ### Docs
26
+ - `docs/providers.md` rewritten: 11-provider matrix, Cloudflare setup caveat, Ollama resource cost, "no API key" graceful degradation notes.
27
+ - `README.md` updated: "Memory Recall Modes" section, Cloudflare + Gemini setup examples, 4-mode annotation examples.
28
+ - `.env.example` updated: copy-pasteable config blocks for Cloudflare Workers AI and Google Gemini.
29
+
30
+
31
+
3
32
  ## 1.3.0 - 2026-06-17
4
33
 
5
34
  ### Added
@@ -8,6 +37,14 @@
8
37
  - Flags: `--root <path>`, `--json`, `--top N`, `--help`, `--version`
9
38
  - Pure-function library at `lib/stats.js` (no MCP / no stdio); tested offline.
10
39
  - New top-level test runner hook: `tests/*.test.mjs` are auto-picked-up by `node run-tests.mjs`.
40
+ - New `provider-smoke` CLI: probe every configured LLM provider (chat + embeddings) and print a matrix of latency / status / sample. Useful after adding a new API key or to compare providers.
41
+ - Usage: `npx -y -p codex-dev-mcp-suite provider-smoke`
42
+ - Flags: `--json`, `--markdown`, `--save-md <path>`, `--chat-only`, `--embed-only`, `--env-file <path>`, `--timeout <ms>`
43
+ - Detects providers from `MCP_PROVIDER_*` slots + named env (`GROQ_*`, `CEREBRAS_*`, `MISTRAL_*`, `OPENROUTER_*`, `OPENAI_*`, `OLLAMA_*`, `ANTHROPIC_*`, `GEMINI_*`, `COHERE_*`) + `MCP_LLM_BASE_URL` / `NINEROUTER_URL` fallback.
44
+ - Pure-function library at `lib/provider-smoke.js`; tested offline.
45
+ - New docs page `docs/providers.md` — auto-generated smoke matrix from latest run.
46
+
47
+
11
48
 
12
49
 
13
50
 
@@ -21,6 +58,12 @@
21
58
  - 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
59
  - Args: `root?`, `top?` (default 10), `json?` (default false).
23
60
 
61
+
62
+
63
+ ### Added
64
+ - **Cloudflare Workers AI support** for embeddings and chat: drop-in alternative when 9router/OpenAI are unavailable. Set `MCP_EMBED_BASE_URL=https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/run/`, `MCP_EMBED_API_KEY=<token>`, `MCP_EMBED_MODEL=@cf/baai/bge-base-en-v1.5`. Cloudflare's REST API (non-OpenAI-compatible) is auto-detected by URL pattern and dispatched to a separate code path in `project-memory/embedding.js`. Includes `isCloudflareURL()` detection + `endpoint: "cloudflare"` field in provider matrix.
65
+ - `provider-smoke` CLI: probe Cloudflare alongside OpenAI-compatible providers. Cloudflare chat returns 400 for embed-only models (expected — they don't support chat).
66
+
24
67
  ## 1.2.0 - 2026-06-15
25
68
 
26
69
  ### Added
package/README.md CHANGED
@@ -168,6 +168,30 @@ idea — use the npm bin commands (`project-memory-mcp`, `devjournal-mcp`,
168
168
  `checkpoint-mcp`, `context-pack-mcp`) or `node /abs/path/<server>/server.js`,
169
169
  with optional `env`.
170
170
 
171
+ ## Memory Recall Modes
172
+
173
+ `memory_recall` supports a `mode` arg (default `"auto"`) that controls fallback behavior:
174
+
175
+ | Mode | Behavior |
176
+ |---|---|
177
+ | `auto` (default) | Smart: try semantic → keyword → LLM rerank |
178
+ | `semantic` | Require embedding. Returns `isError: true` if no embed key configured |
179
+ | `keyword` | Skip embedding entirely (faster, pure keyword scoring) |
180
+
181
+ The recall output annotates the active mode for transparency:
182
+
183
+ ```
184
+ Recall for "how do users sign in" in my-project [semantic+rerank]:
185
+ ### JWT login flow (id:..., sim:0.603, ...)
186
+ ```
187
+
188
+ The `+rerank` suffix is appended when LLM rerank is active (MCP_RERANK_ENABLED + key).
189
+ Without embeddings, recall falls back to `[keyword]` or `[keyword+rerank]`.
190
+ With `MCP_DETERMINISTIC_FALLBACK=true`, the mode is always `[deterministic]`.
191
+
192
+ This means you can run codex-dev-mcp-suite with **zero API keys configured** —
193
+ `memory_recall` still works via keyword scoring, just no semantic similarity.
194
+
171
195
  ## Daily workflow
172
196
 
173
197
  - Session start: `pack_overview` + `journal_resume` + `memory_recall "<topic>"`
@@ -230,6 +254,50 @@ directly from your own scripts. The default storage root is
230
254
  `JOURNAL_DIR` / `CHECKPOINT_DIR` env vars) override it.
231
255
 
232
256
 
257
+ ## Provider Smoke CLI
258
+
259
+ Verify every configured LLM provider (chat + embeddings) with one command.
260
+ Useful after adding a new API key, or when comparing provider latency.
261
+
262
+ ```bash
263
+ npx -y -p codex-dev-mcp-suite provider-smoke # uses process.env
264
+ npx -y -p codex-dev-mcp-suite provider-smoke --env-file ~/secrets.env # separate secrets file
265
+ npx -y -p codex-dev-mcp-suite provider-smoke --markdown --save-md docs/providers.md
266
+ ```
267
+
268
+ Auto-detects providers from these env vars (highest precedence first):
269
+
270
+ - **Numbered slots**: `MCP_PROVIDER_PRIMARY` / `_CHAIN2` / `_CHAIN3` / ...
271
+ - **Named env**: `GROQ_*`, `CEREBRAS_*`, `MISTRAL_*`, `OPENROUTER_*`, `OPENAI_*`, `GEMINI_*`, `COHERE_*`, `VOYAGE_*`, `OLLAMA_*`, `ANTHROPIC_*`
272
+ - **Catch-all**: `MCP_LLM_BASE_URL` / `MCP_RERANK_BASE_URL` / `MCP_EMBED_BASE_URL` / `NINEROUTER_URL` / `LLM_BASE_URL` (any OpenAI-compatible endpoint)
273
+
274
+ For each detected provider, runs:
275
+ - **chat probe** (`/v1/chat/completions`) — if the provider supports it
276
+ - **embed probe** (`/v1/embeddings`) — if the provider supports it
277
+
278
+ **Inference-only providers** (Groq, Cerebras, Anthropic) skip the embed probe automatically.
279
+ **Non-OpenAI-compatible providers** (Cloudflare Workers AI) require a custom code path in
280
+ `project-memory/embedding.js` — see [docs/providers.md](docs/providers.md).
281
+
282
+ **No provider assumption is baked in**: the tool only probes what's in your env. Zero-config users
283
+ get an empty matrix; pass `--env-file` to point at a secrets file.
284
+
285
+ Chat-only providers (Groq, Cerebras) skip the embeddings probe automatically
286
+ — they don't expose `/v1/embeddings`. Embedding-capable providers (Mistral,
287
+ OpenRouter, OpenAI, Ollama, Gemini, Cohere) run both probes.
288
+
289
+ Example output (Groq + Cerebras, both configured):
290
+
291
+ ```
292
+ [groq]
293
+ ✓ chat 200 310ms "OK"
294
+
295
+ [cerebras]
296
+ ✓ chat 200 476ms
297
+ ```
298
+
299
+ See [`docs/providers.md`](docs/providers.md) for a saved smoke matrix.
300
+
233
301
  ## Prune CLI
234
302
 
235
303
  Remove temp project slugs (prefix `tmp.`) from vault / journal / checkpoints.
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bin/provider-smoke.mjs — Dev MCP Suite provider smoke test.
4
+ *
5
+ * Probes every provider that can be derived from the current env:
6
+ * - MCP_LLM_BASE_URL / NINEROUTER_URL / LLM_BASE_URL (9router-like)
7
+ * - MCP_PROVIDER_PRIMARY / _CHAIN2 / _CHAIN3 / ... (numbered slots)
8
+ * - Named: GROQ_*, CEREBRAS_*, MISTRAL_*, OPENROUTER_*, OPENAI_*, OLLAMA_*, ...
9
+ *
10
+ * For each provider, runs:
11
+ * - chat probe (/v1/chat/completions, short prompt) if supportsChat
12
+ * - embed probe (/v1/embeddings, single string) if supportsEmbed
13
+ *
14
+ * Output: human text by default; --json for machines; --markdown for docs.
15
+ *
16
+ * Env override:
17
+ * --env-file <path> Source env from this file before probing (e.g. /tmp/secrets.env)
18
+ *
19
+ * Exit code:
20
+ * 0 = all probes ok
21
+ * 1 = at least one probe failed
22
+ */
23
+ import fs from "fs";
24
+ import os from "os";
25
+ import path from "path";
26
+ import { fileURLToPath } from "url";
27
+ import { buildProviderMatrix, shapeProbe, formatText, formatJson, formatMarkdown, isCloudflareURL } from "../lib/provider-smoke.js";
28
+
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
+
31
+ function pkgVersion() {
32
+ try {
33
+ return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).version || "unknown";
34
+ } catch { return "unknown"; }
35
+ }
36
+
37
+
38
+ /** Strip a trailing /v1 (only the literal segment, not /openai/v1) so we can re-append /v1/<endpoint>. */
39
+ function stripV1(baseUrl) {
40
+ return String(baseUrl || "").replace(/\/v1\/?$/, "").replace(/\/$/, "");
41
+ }
42
+
43
+ function parseArgs(argv) {
44
+ const opts = {
45
+ help: false, version: false,
46
+ json: false, markdown: false,
47
+ saveMd: null, envFile: null,
48
+ chatOnly: false, embedOnly: false,
49
+ timeoutMs: 15000,
50
+ only: null,
51
+ };
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const a = argv[i];
54
+ if (a === "-h" || a === "--help") opts.help = true;
55
+ else if (a === "-v" || a === "--version") opts.version = true;
56
+ else if (a === "--json") opts.json = true;
57
+ else if (a === "--markdown") opts.markdown = true;
58
+ else if (a === "--save-md") opts.saveMd = argv[++i];
59
+ else if (a === "--env-file") opts.envFile = argv[++i];
60
+ else if (a === "--chat-only") opts.chatOnly = true;
61
+ else if (a === "--embed-only") opts.embedOnly = true;
62
+ else if (a === "--timeout") opts.timeoutMs = parseInt(argv[++i], 10) || 15000;
63
+ else if (a === "--only") opts.only = (argv[++i] || "").split(",").map(s => s.trim()).filter(Boolean);
64
+ else { process.stderr.write(`provider-smoke: unknown flag: ${a}\n`); process.exit(2); }
65
+ }
66
+ return opts;
67
+ }
68
+
69
+ function printHelp() {
70
+ const out = [
71
+ `provider-smoke (Dev MCP Suite) v${pkgVersion()}`,
72
+ "",
73
+ "Probe every configured LLM provider (chat + embeddings) and print a matrix.",
74
+ "",
75
+ "Usage:",
76
+ " provider-smoke [--json] [--markdown] [--save-md <path>]",
77
+ " [--chat-only | --embed-only] [--env-file <path>]",
78
+ " provider-smoke --help | --version",
79
+ "",
80
+ "Options:",
81
+ " --json Machine-readable JSON output",
82
+ " --markdown Markdown table (for docs/)",
83
+ " --save-md <path> Write markdown report to this file",
84
+ " --env-file <path> Source env vars from this file before probing",
85
+ " (KEY=value lines, one per line)",
86
+ " --chat-only Only probe /v1/chat/completions",
87
+ " --embed-only Only probe /v1/embeddings",
88
+ " --timeout <ms> Per-probe timeout (default 15000)",
89
+ " --only a,b,c Only probe these provider ids (comma-separated); others ignored",
90
+ "",
91
+ "Detected providers (precedence):",
92
+ " MCP_PROVIDER_PRIMARY / _CHAIN2 / _CHAIN3 / ... (numbered slots)",
93
+ " GROQ_*, CEREBRAS_*, MISTRAL_*, OPENROUTER_*, (named)",
94
+ " OPENAI_*, OLLAMA_*, ANTHROPIC_*, GEMINI_*, COHERE_*",
95
+ " MCP_LLM_BASE_URL / NINEROUTER_URL / LLM_BASE_URL (9router / agentrouter)",
96
+ "",
97
+ "Examples:",
98
+ " provider-smoke",
99
+ " provider-smoke --env-file /tmp/codex-dev-smoke.env",
100
+ " provider-smoke --json | jq '.[] | select(.ok == false)'",
101
+ " provider-smoke --markdown --save-md docs/providers.md",
102
+ ];
103
+ process.stdout.write(out.join("\n") + "\n");
104
+ }
105
+
106
+ function loadEnvFile(file) {
107
+ if (!file) return {};
108
+ const text = fs.readFileSync(file, "utf8");
109
+ const env = {};
110
+ for (const raw of text.split(/\r?\n/)) {
111
+ const line = raw.trim();
112
+ if (!line || line.startsWith("#")) continue;
113
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
114
+ if (m) env[m[1]] = m[2];
115
+ }
116
+ return env;
117
+ }
118
+
119
+ function buildHeaders(provider) {
120
+ const h = { "Content-Type": "application/json" };
121
+ if (provider.apiKey) h["Authorization"] = `Bearer ${provider.apiKey}`;
122
+ return h;
123
+ }
124
+
125
+
126
+ const CF_EMBED_ONLY_PREFIXES = ["@cf/baai/", "@cf/google/embedding", "@cf/baai/sentence-"];
127
+
128
+ function isEmbedOnlyModel(model) {
129
+ if (!model) return false;
130
+ return CF_EMBED_ONLY_PREFIXES.some((p) => model.startsWith(p));
131
+ }
132
+
133
+ async function probeCloudflare(provider, timeoutMs, kind) {
134
+ // Skip chat probe for known embed-only Cloudflare model prefixes (annotate, not error).
135
+ if (kind === "chat" && isEmbedOnlyModel(provider.chatModel || provider.embedModel)) {
136
+ return shapeProbe({ name: provider.id, kind: "chat", ok: false, error: "embed-only model (chat not supported)" });
137
+ }
138
+ // Cloudflare Workers AI REST: POST {baseUrl}/{model}, body shape depends on kind.
139
+ // Cloudflare model names contain "/" (e.g. "@cf/baai/bge-base-en-v1.5"); do NOT URL-encode.
140
+ const url = `${provider.baseUrl.replace(/\/$/, "")}/${provider.chatModel || provider.embedModel}`;
141
+ let body;
142
+ if (kind === "chat") {
143
+ body = JSON.stringify({
144
+ messages: [{ role: "user", content: "Say the single word: OK" }],
145
+ max_tokens: 8,
146
+ });
147
+ } else {
148
+ body = JSON.stringify({ text: ["ping"] });
149
+ }
150
+ const t0 = Date.now();
151
+ try {
152
+ const res = await fetch(url, { method: "POST", headers: buildHeaders(provider), body, signal: AbortSignal.timeout(timeoutMs) });
153
+ const text = await res.text();
154
+ const latencyMs = Date.now() - t0;
155
+ if (res.status !== 200) {
156
+ return shapeProbe({ name: provider.id, kind, ok: false, status: res.status, latencyMs, error: text.slice(0, 200) });
157
+ }
158
+ let sample = null, dim = null;
159
+ try {
160
+ const j = JSON.parse(text);
161
+ if (kind === "embed") {
162
+ const data = j?.result?.data;
163
+ if (Array.isArray(data) && Array.isArray(data[0])) dim = data[0].length;
164
+ } else {
165
+ sample = j?.result?.response ?? null;
166
+ }
167
+ } catch { /* ignore */ }
168
+ return shapeProbe({ name: provider.id, kind, ok: true, status: res.status, latencyMs, sample, dim });
169
+ } catch (e) {
170
+ return shapeProbe({ name: provider.id, kind, ok: false, latencyMs: Date.now() - t0, error: String(e && e.message || e) });
171
+ }
172
+ }
173
+
174
+ async function probeChat(provider, timeoutMs) {
175
+ if (!provider.baseUrl || !provider.chatModel) {
176
+ return shapeProbe({ name: provider.id, kind: "chat", ok: false, error: "missing baseUrl or chatModel" });
177
+ }
178
+ const endpoint = provider.endpoint || (isCloudflareURL(provider.baseUrl) ? "cloudflare" : "openai-compatible");
179
+ if (endpoint === "cloudflare") return probeCloudflare(provider, timeoutMs, "chat");
180
+ const url = `${stripV1(provider.baseUrl)}/v1/chat/completions`;
181
+ const body = JSON.stringify({
182
+ model: provider.chatModel,
183
+ messages: [{ role: "user", content: "Say the single word: OK" }],
184
+ max_tokens: 8,
185
+ temperature: 0,
186
+ });
187
+ const t0 = Date.now();
188
+ try {
189
+ const res = await fetch(url, { method: "POST", headers: buildHeaders(provider), body, signal: AbortSignal.timeout(timeoutMs) });
190
+ const text = await res.text();
191
+ const latencyMs = Date.now() - t0;
192
+ if (res.status !== 200) {
193
+ return shapeProbe({ name: provider.id, kind: "chat", ok: false, status: res.status, latencyMs, error: text.slice(0, 200) });
194
+ }
195
+ let sample = null;
196
+ try {
197
+ const j = JSON.parse(text);
198
+ sample = j?.choices?.[0]?.message?.content ?? null;
199
+ } catch { /* ignore */ }
200
+ return shapeProbe({ name: provider.id, kind: "chat", ok: true, status: res.status, latencyMs, sample });
201
+ } catch (e) {
202
+ return shapeProbe({ name: provider.id, kind: "chat", ok: false, latencyMs: Date.now() - t0, error: String(e && e.message || e) });
203
+ }
204
+ }
205
+
206
+ async function probeEmbed(provider, timeoutMs) {
207
+ if (!provider.supportsEmbed) {
208
+ return shapeProbe({ name: provider.id, kind: "embed", ok: false, error: "provider has no /v1/embeddings endpoint" });
209
+ }
210
+ if (!provider.baseUrl || !provider.embedModel) {
211
+ return shapeProbe({ name: provider.id, kind: "embed", ok: false, error: "missing baseUrl or embedModel" });
212
+ }
213
+ if (provider.endpoint === "cloudflare" || isCloudflareURL(provider.baseUrl)) return probeCloudflare(provider, timeoutMs, "embed");
214
+ const url = `${stripV1(provider.baseUrl)}/v1/embeddings`;
215
+ const body = JSON.stringify({ model: provider.embedModel, input: "ping" });
216
+ const t0 = Date.now();
217
+ try {
218
+ const res = await fetch(url, { method: "POST", headers: buildHeaders(provider), body, signal: AbortSignal.timeout(timeoutMs) });
219
+ const text = await res.text();
220
+ const latencyMs = Date.now() - t0;
221
+ if (res.status !== 200) {
222
+ return shapeProbe({ name: provider.id, kind: "embed", ok: false, status: res.status, latencyMs, error: text.slice(0, 200) });
223
+ }
224
+ let dim = null;
225
+ try {
226
+ const j = JSON.parse(text);
227
+ dim = Array.isArray(j?.data?.[0]?.embedding) ? j.data[0].embedding.length : null;
228
+ } catch { /* ignore */ }
229
+ return shapeProbe({ name: provider.id, kind: "embed", ok: true, status: res.status, latencyMs, dim });
230
+ } catch (e) {
231
+ return shapeProbe({ name: provider.id, kind: "embed", ok: false, latencyMs: Date.now() - t0, error: String(e && e.message || e) });
232
+ }
233
+ }
234
+
235
+ const opts = parseArgs(process.argv.slice(2));
236
+ if (opts.version) { process.stdout.write(`${pkgVersion()}\n`); process.exit(0); }
237
+ if (opts.help) { printHelp(); process.exit(0); }
238
+
239
+ // Merge env: process.env + (optional) env file
240
+ const fileEnv = loadEnvFile(opts.envFile);
241
+ const env = { ...process.env, ...fileEnv };
242
+
243
+ let matrix = buildProviderMatrix(env);
244
+ if (opts.only && opts.only.length) {
245
+ matrix = matrix.filter(p => opts.only.includes(p.id));
246
+ }
247
+ if (matrix.length === 0) {
248
+ const hint = opts.only && opts.only.length ? " (--only filter: " + opts.only.join(",") + ")" : "";
249
+ process.stderr.write("No providers detected. Set MCP_PROVIDER_PRIMARY / GROQ_API_KEY / NINEROUTER_URL etc., or pass --env-file." + hint + "\n");
250
+ process.exit(2);
251
+ }
252
+
253
+ process.stderr.write(`Probing ${matrix.length} provider${matrix.length === 1 ? "" : "s"}...\n`);
254
+ const results = [];
255
+ for (const p of matrix) {
256
+ if (!opts.embedOnly && p.supportsChat) {
257
+ results.push(await probeChat(p, opts.timeoutMs));
258
+ }
259
+ if (!opts.chatOnly && p.supportsEmbed) {
260
+ results.push(await probeEmbed(p, opts.timeoutMs));
261
+ }
262
+ }
263
+
264
+ let out;
265
+ if (opts.markdown) out = formatMarkdown(results);
266
+ else if (opts.json) out = formatJson(results);
267
+ else out = formatText(results);
268
+
269
+ process.stdout.write(out + "\n");
270
+
271
+ if (opts.saveMd) {
272
+ fs.mkdirSync(path.dirname(opts.saveMd), { recursive: true });
273
+ fs.writeFileSync(opts.saveMd, formatMarkdown(results));
274
+ process.stderr.write(`Markdown saved to ${opts.saveMd}\n`);
275
+ }
276
+
277
+ const failed = results.filter((r) => !r.ok).length;
278
+ process.exit(failed === 0 ? 0 : 1);
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-dev-mcp-suite",
3
- "version": "1.3.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",
@@ -34,7 +34,8 @@
34
34
  "checkpoint-mcp": "bin/checkpoint.mjs",
35
35
  "context-pack-mcp": "bin/context-pack.mjs",
36
36
  "stats": "bin/stats.mjs",
37
- "prune": "bin/prune.mjs"
37
+ "prune": "bin/prune.mjs",
38
+ "provider-smoke": "bin/provider-smoke.mjs"
38
39
  },
39
40
  "scripts": {
40
41
  "test": "node run-tests.mjs",
@@ -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
  }
@@ -163,7 +163,7 @@ class ProjectMemoryServer {
163
163
  {
164
164
  name: "memory_recall",
165
165
  description:
166
- "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).",
167
167
  inputSchema: {
168
168
  type: "object",
169
169
  properties: {
@@ -171,6 +171,7 @@ class ProjectMemoryServer {
171
171
  dir: { type: "string", description: "Project directory (defaults to CWD)" },
172
172
  limit: { type: "number", description: "Max notes to return", default: 5 },
173
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)" },
174
175
  },
175
176
  required: ["query"],
176
177
  },
@@ -316,7 +317,7 @@ class ProjectMemoryServer {
316
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)"}` }] };
317
318
  }
318
319
 
319
- async recall({ query, dir, limit: lim = 5, full = false }) {
320
+ async recall({ query, dir, limit: lim = 5, full = false, mode: requestedMode = "auto" }) {
320
321
  query = limit(query, "query", 2000);
321
322
  const p = this.paths(dir);
322
323
  const index = await this.loadIndex(p);
@@ -333,10 +334,17 @@ class ProjectMemoryServer {
333
334
  return score;
334
335
  };
335
336
 
337
+ // Tier 2.1: respect requested mode. "auto" = smart fallback.
336
338
  let mode = deterministicEnabled() ? "deterministic" : "keyword";
337
- const qVec = deterministicEnabled() ? null : await embedOne(query);
339
+ const useEmbed = requestedMode !== "keyword" && !deterministicEnabled();
340
+ const qVec = useEmbed ? await embedOne(query) : null;
338
341
  const haveEmb = qVec && notes.some((n) => Array.isArray(n.embedding));
339
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
+
340
348
  let scored;
341
349
  if (haveEmb) {
342
350
  mode = "semantic";
@@ -395,7 +403,12 @@ class ProjectMemoryServer {
395
403
  const tag = mode === "semantic" ? `sim:${(sim ?? 0).toFixed(3)}` : (mode === "rerank" ? "llm-ranked" : `score:${score}`);
396
404
  blocks.push(`### ${n.title} (id:${n.id}, ${tag}, ${n.created})\n${text}`);
397
405
  }
398
- 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")}` }] };
399
412
  }
400
413
 
401
414
  async list({ dir, limit: lim = 20 }) {