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 +19 -0
- package/CHANGELOG.md +43 -0
- package/README.md +68 -0
- package/bin/provider-smoke.mjs +278 -0
- package/lib/provider-smoke.js +234 -0
- package/package.json +3 -2
- package/project-memory/embedding.js +108 -30
- package/project-memory/server.js +17 -4
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
|
+
"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
|
-
*
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 }) {
|