codex-dev-mcp-suite 1.0.0 → 1.2.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.
@@ -0,0 +1,39 @@
1
+ # Codex CLI Setup
2
+
3
+ Install globally:
4
+
5
+ ```bash
6
+ npm i -g codex-dev-mcp-suite
7
+ ```
8
+
9
+ Add to `~/.codex/config.toml`:
10
+
11
+ ```toml
12
+ [mcp_servers.project-memory]
13
+ command = "project-memory-mcp"
14
+ [mcp_servers.project-memory.env]
15
+ MEMORY_VAULT_DIR = "~/.codex/memories/vault"
16
+ # Optional model features; omit for offline keyword mode.
17
+ MCP_LLM_BASE_URL = "http://localhost:20128"
18
+ MCP_LLM_API_KEY = "sk-..."
19
+ MCP_RERANK_MODEL = "kr/claude-haiku-4.5"
20
+ MCP_EMBED_MODEL = "bm/baai/bge-m3"
21
+
22
+ [mcp_servers.devjournal]
23
+ command = "devjournal-mcp"
24
+ [mcp_servers.devjournal.env]
25
+ JOURNAL_DIR = "~/.codex/memories/journal"
26
+ MCP_LLM_BASE_URL = "http://localhost:20128"
27
+ MCP_LLM_API_KEY = "sk-..."
28
+ MCP_RERANK_MODEL = "kr/claude-haiku-4.5"
29
+
30
+ [mcp_servers.checkpoint]
31
+ command = "checkpoint-mcp"
32
+ [mcp_servers.checkpoint.env]
33
+ CHECKPOINT_DIR = "~/.codex/memories/checkpoints"
34
+
35
+ [mcp_servers.context-pack]
36
+ command = "context-pack-mcp"
37
+ ```
38
+
39
+ Important: the server name is `project-memory` with a hyphen. `project_memory` is not a configured MCP server name in Codex.
@@ -0,0 +1,45 @@
1
+ # Generic MCP Client Setup
2
+
3
+ Any MCP client that can start stdio servers can use Dev MCP Suite.
4
+
5
+ Install globally:
6
+
7
+ ```bash
8
+ npm i -g codex-dev-mcp-suite
9
+ ```
10
+
11
+ Generic server definitions:
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "project-memory": {
17
+ "command": "project-memory-mcp",
18
+ "args": [],
19
+ "env": {
20
+ "MEMORY_VAULT_DIR": "~/.local/share/dev-mcp-suite/memories/vault"
21
+ }
22
+ },
23
+ "devjournal": {
24
+ "command": "devjournal-mcp",
25
+ "args": [],
26
+ "env": {
27
+ "JOURNAL_DIR": "~/.local/share/dev-mcp-suite/memories/journal"
28
+ }
29
+ },
30
+ "checkpoint": {
31
+ "command": "checkpoint-mcp",
32
+ "args": [],
33
+ "env": {
34
+ "CHECKPOINT_DIR": "~/.local/share/dev-mcp-suite/memories/checkpoints"
35
+ }
36
+ },
37
+ "context-pack": {
38
+ "command": "context-pack-mcp",
39
+ "args": []
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ Optional model configuration can be added to `project-memory` and `devjournal` env blocks. See `docs/configuration.md`.
@@ -0,0 +1,123 @@
1
+ # Configuration
2
+
3
+ Dev MCP Suite is MCP-client agnostic. The servers run locally over stdio and work in any client that can launch MCP servers: Codex CLI, Claude Code, Cursor, Cline, Gemini-compatible launchers, Hermes, and generic MCP hosts.
4
+
5
+ ## Storage
6
+
7
+ All storage is local and file-based.
8
+
9
+ | Env var | Used by | Default |
10
+ |---|---|---|
11
+ | `MEMORY_VAULT_DIR` | `project-memory` | `~/.codex/memories/vault` |
12
+ | `JOURNAL_DIR` | `devjournal` | `~/.codex/memories/journal` |
13
+ | `CHECKPOINT_DIR` | `checkpoint` | `~/.codex/memories/checkpoints` |
14
+
15
+ Use a different base path if your MCP client is not Codex, for example `~/.local/share/dev-mcp-suite/...`.
16
+
17
+ ## Optional model features
18
+
19
+ `context-pack` and `checkpoint` never need a model. `project-memory` and `devjournal` work offline by keyword search and can optionally use an OpenAI-compatible API for better recall.
20
+
21
+ No remote calls are made unless you configure a model endpoint. For strict local-only behavior even when model env vars are present, set `MCP_DETERMINISTIC_FALLBACK=true`.
22
+
23
+ | Env var | Purpose |
24
+ |---|---|
25
+ | `MCP_LLM_BASE_URL` | Base URL for an OpenAI-compatible API, e.g. `http://localhost:11434/v1` or `http://localhost:20128` |
26
+ | `MCP_LLM_API_KEY` | API key for that endpoint; leave blank for local servers that do not require auth |
27
+ | `MCP_RERANK_MODEL` | Chat model for LLM reranking, e.g. `llama3.1:8b`, `kr/claude-haiku-4.5`, `gpt-4o-mini` |
28
+ | `MCP_RERANK_BASE_URL` | Optional rerank-specific base URL; falls back to `MCP_LLM_BASE_URL` |
29
+ | `MCP_RERANK_API_KEY` | Optional rerank-specific API key; falls back to `MCP_LLM_API_KEY` |
30
+ | `RERANK_ENABLED` | Set `0` to disable reranking |
31
+ | `RERANK_TIMEOUT_MS` | Rerank request timeout; default `30000` |
32
+ | `MCP_EMBED_MODEL` | Embedding model for semantic recall, e.g. `nomic-embed-text`, `text-embedding-3-small`, `bm/baai/bge-m3` |
33
+ | `MCP_EMBED_BASE_URL` | Optional embedding-specific base URL; falls back to `MCP_LLM_BASE_URL` |
34
+ | `MCP_EMBED_API_KEY` | Optional embedding-specific API key; falls back to `MCP_LLM_API_KEY` |
35
+ | `EMBED_TIMEOUT_MS` | Embedding request timeout; default `15000` |
36
+ | `MCP_DETERMINISTIC_FALLBACK` | Hard no-network fallback when true; accepts `true`, `1`, `yes`, `on` |
37
+
38
+ Recall mode order:
39
+
40
+ 1. `deterministic` if `MCP_DETERMINISTIC_FALLBACK=true`.
41
+ 2. `semantic` if embeddings are configured and available.
42
+ 3. `rerank` if a chat model is configured and enabled.
43
+ 4. `keyword` offline fallback, always available.
44
+
45
+ Failures degrade gracefully. If your embedding endpoint returns `503`, recall falls back to rerank/keyword instead of failing the tool call.
46
+
47
+ ## Provider strategy
48
+
49
+ The suite is provider-neutral. A practical remote setup is:
50
+
51
+ 1. **Groq** for fast rerank/chat when model quality is sufficient.
52
+ 2. **Cerebras** as a second fast provider for outages or model fit.
53
+ 3. **OpenRouter** as broad fallback for many OpenAI-compatible models.
54
+
55
+ In `v1.1`, chat/rerank can use built-in numbered fallback slots. Configure only primary if you want one provider, or add more numbered chain entries.
56
+
57
+ ```bash
58
+ MCP_PROVIDER_PRIMARY=groq
59
+ MCP_PROVIDER_PRIMARY_BASE_URL=https://api.groq.com/openai/v1
60
+ MCP_PROVIDER_PRIMARY_API_KEY=<groq-key>
61
+ MCP_PROVIDER_PRIMARY_MODEL=llama-3.3-70b-versatile
62
+
63
+ MCP_PROVIDER_CHAIN2=cerebras
64
+ MCP_PROVIDER_CHAIN2_BASE_URL=https://api.cerebras.ai/v1
65
+ MCP_PROVIDER_CHAIN2_API_KEY=<cerebras-key>
66
+ MCP_PROVIDER_CHAIN2_MODEL=llama-3.3-70b
67
+
68
+ MCP_PROVIDER_CHAIN3=openrouter
69
+ MCP_PROVIDER_CHAIN3_BASE_URL=https://openrouter.ai/api/v1
70
+ MCP_PROVIDER_CHAIN3_API_KEY=<openrouter-key>
71
+ MCP_PROVIDER_CHAIN3_MODEL=openai/gpt-4o-mini
72
+ ```
73
+
74
+ You can add `MCP_PROVIDER_CHAIN4_*`, `MCP_PROVIDER_CHAIN5_*`, and so on. The provider name is only a label; any OpenAI-compatible endpoint works when `BASE_URL`, `API_KEY`, and `MODEL` are set.
75
+
76
+ On a `429`, `5xx`, timeout, or network error, a provider is put on a short cooldown and the next slot is tried. Tune the window with `MCP_PROVIDER_COOLDOWN_MS` (default `60000`).
77
+
78
+ ## Diagnostics
79
+
80
+ Each server bin supports inspection flags that never start the server:
81
+
82
+ ```bash
83
+ project-memory-mcp --version
84
+ project-memory-mcp --doctor # storage + model/provider config, API keys redacted
85
+ project-memory-mcp --help
86
+ ```
87
+
88
+ `--doctor` reports configured providers and storage paths and shows API keys only as `set (<n> chars)` or `not set`, never the raw value.
89
+
90
+ Example OpenRouter-style config:
91
+
92
+ ```bash
93
+ MCP_LLM_BASE_URL=https://openrouter.ai/api/v1
94
+ MCP_LLM_API_KEY=<your-key>
95
+ MCP_RERANK_MODEL=openai/gpt-4o-mini
96
+ ```
97
+
98
+ Example local-only deterministic config:
99
+
100
+ ```bash
101
+ MCP_DETERMINISTIC_FALLBACK=true
102
+ ```
103
+
104
+ See [privacy and data flow](privacy.md) before using remote providers with sensitive projects.
105
+
106
+ ## Legacy aliases
107
+
108
+ These older names still work for backward compatibility:
109
+
110
+ | Legacy | Preferred |
111
+ |---|---|
112
+ | `LLM_BASE_URL` | `MCP_LLM_BASE_URL` |
113
+ | `LLM_API_KEY` | `MCP_LLM_API_KEY` |
114
+ | `NINEROUTER_URL` | `MCP_LLM_BASE_URL` |
115
+ | `NINEROUTER_KEY` | `MCP_LLM_API_KEY` |
116
+ | `EMBED_MODEL` | `MCP_EMBED_MODEL` |
117
+ | `RERANK_MODEL` | `MCP_RERANK_MODEL` |
118
+ | `EMBED_URL` | `MCP_EMBED_BASE_URL` |
119
+ | `EMBED_KEY` | `MCP_EMBED_API_KEY` |
120
+ | `RERANK_URL` | `MCP_RERANK_BASE_URL` |
121
+ | `RERANK_KEY` | `MCP_RERANK_API_KEY` |
122
+
123
+ Prefer the `MCP_*` names for new installs.
@@ -0,0 +1,39 @@
1
+ # Privacy and Data Flow
2
+
3
+ Dev MCP Suite is local-first. It has no hosted backend, no built-in telemetry, and no account system owned by this project.
4
+
5
+ ## What is stored locally
6
+
7
+ | Server | Local data |
8
+ |---|---|
9
+ | `project-memory` | Markdown notes, keyword index, optional embedding vectors if you run `memory_reindex` with embeddings configured |
10
+ | `devjournal` | Session logs, handoff files, timeline entries |
11
+ | `checkpoint` | Text-file snapshots for rollback |
12
+ | `context-pack` | No persistent project data by default |
13
+
14
+ Default storage paths are under `~/.codex/memories/...`; you can override them with `MEMORY_VAULT_DIR`, `JOURNAL_DIR`, and `CHECKPOINT_DIR`.
15
+
16
+ ## What can leave your machine
17
+
18
+ Nothing leaves your machine by default. `project-memory` and `devjournal` use offline keyword scoring unless you configure an external OpenAI-compatible model endpoint.
19
+
20
+ If you set model environment variables such as `MCP_LLM_BASE_URL`, `MCP_EMBED_BASE_URL`, `MCP_RERANK_BASE_URL`, or numbered provider slots like `MCP_PROVIDER_PRIMARY_BASE_URL`, the related query text and candidate snippets may be sent to that endpoint for embeddings or reranking. The endpoint can be local (Ollama, LM Studio, vLLM, LiteLLM) or remote (Groq, Cerebras, OpenRouter, 9Router, OpenAI-compatible gateways).
21
+
22
+ ## Hard no-network mode
23
+
24
+ Set this when you want deterministic local-only recall even if model keys are present:
25
+
26
+ ```bash
27
+ MCP_DETERMINISTIC_FALLBACK=true
28
+ ```
29
+
30
+ Accepted true values: `true`, `1`, `yes`, `on` (case-insensitive). In this mode:
31
+
32
+ - Embeddings are disabled.
33
+ - LLM reranking is disabled.
34
+ - `memory_reindex` skips embedding generation.
35
+ - Results are labeled `[deterministic]`.
36
+
37
+ ## Recommended provider posture
38
+
39
+ For remote providers, use least-privilege API keys and configure them per MCP client environment. Do not commit API keys to this repository or project repos. Prefer local models for sensitive work; use remote providers only for projects where sending snippets to that provider is acceptable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-dev-mcp-suite",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",
@@ -42,24 +42,34 @@
42
42
  },
43
43
  "files": [
44
44
  "bin/*.mjs",
45
+ "bin/_meta.mjs",
45
46
  "project-memory/*.js",
46
- "project-memory/*.mjs",
47
+ "project-memory/server.js",
48
+ "project-memory/embedding.js",
49
+ "project-memory/env.js",
50
+ "project-memory/provider-chain.js",
51
+ "project-memory/rerank.js",
47
52
  "project-memory/package.json",
48
53
  "devjournal/*.js",
49
- "devjournal/*.mjs",
54
+ "devjournal/server.js",
55
+ "devjournal/env.js",
56
+ "devjournal/provider-chain.js",
57
+ "devjournal/rerank.js",
50
58
  "devjournal/package.json",
51
59
  "checkpoint/*.js",
52
- "checkpoint/*.mjs",
60
+ "checkpoint/server.js",
53
61
  "checkpoint/package.json",
54
62
  "context-pack/*.js",
55
- "context-pack/*.mjs",
63
+ "context-pack/server.js",
56
64
  "context-pack/package.json",
57
- "_testkit/*.mjs",
58
- "run-tests.mjs",
59
65
  "backfill-sessions-v2.mjs",
66
+ "docs/configuration.md",
67
+ "docs/privacy.md",
68
+ "docs/clients/*.md",
60
69
  "CODEX_DEV_MCP_GUIDE.md",
70
+ "CHANGELOG.md",
61
71
  "README.md",
62
72
  "LICENSE",
63
73
  ".env.example"
64
74
  ]
65
- }
75
+ }
@@ -1,19 +1,20 @@
1
1
  /**
2
- * Embedding helper for 9router (OpenAI-compatible /v1/embeddings).
2
+ * Embedding helper for any OpenAI-compatible /v1/embeddings endpoint.
3
3
  * Degrades gracefully: returns null on any failure so callers fall back
4
4
  * to keyword search. Never throws.
5
5
  */
6
6
  import http from "http";
7
7
  import https from "https";
8
8
  import { URL } from "url";
9
+ import { deterministicEnabled } from "./env.js";
9
10
 
10
- const BASE = process.env.LLM_BASE_URL || process.env.EMBED_URL || process.env.NINEROUTER_URL || "http://localhost:20128";
11
- const KEY = process.env.LLM_API_KEY || process.env.EMBED_KEY || process.env.NINEROUTER_KEY || "";
12
- const MODEL = process.env.EMBED_MODEL || "bm/baai/bge-m3";
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";
13
14
  const TIMEOUT_MS = Number(process.env.EMBED_TIMEOUT_MS || 15000);
14
15
 
15
16
  export function embeddingConfig() {
16
- return { base: BASE, model: MODEL, enabled: Boolean(KEY) };
17
+ return { base: BASE, model: MODEL, enabled: Boolean(KEY) && !deterministicEnabled(), deterministic: deterministicEnabled() };
17
18
  }
18
19
 
19
20
  function postJson(urlStr, payload) {
@@ -53,7 +54,7 @@ function postJson(urlStr, payload) {
53
54
 
54
55
  /** Returns array of vectors aligned to inputs, or null if unavailable. */
55
56
  export async function embed(inputs) {
56
- if (!KEY) return null;
57
+ if (!KEY || deterministicEnabled()) return null;
57
58
  const arr = Array.isArray(inputs) ? inputs : [inputs];
58
59
  if (arr.length === 0) return [];
59
60
  const json = await postJson(BASE, { model: MODEL, input: arr });
@@ -0,0 +1,3 @@
1
+ export function deterministicEnabled() {
2
+ return /^(1|true|yes|on)$/i.test(String(process.env.MCP_DETERMINISTIC_FALLBACK || "").trim());
3
+ }
@@ -0,0 +1,109 @@
1
+ import { deterministicEnabled } from "./env.js";
2
+
3
+ const DEFAULT_BASE = "http://localhost:20128";
4
+ const DEFAULT_MODEL = "kr/claude-haiku-4.5";
5
+
6
+ function value(name) {
7
+ return String(process.env[name] || "").trim();
8
+ }
9
+
10
+ export function redactKey(key) {
11
+ const k = String(key || "");
12
+ return k ? `set (${k.length} chars)` : "not set";
13
+ }
14
+
15
+ function providerEnv(prefix) {
16
+ const label = value(prefix);
17
+ const base = value(`${prefix}_BASE_URL`);
18
+ const key = value(`${prefix}_API_KEY`);
19
+ const model = value(`${prefix}_MODEL`);
20
+ if (!label || !base || !key || !model) return null;
21
+ return { label, base, key, model };
22
+ }
23
+
24
+ function slotIssues(prefix) {
25
+ const present = ["", "_BASE_URL", "_API_KEY", "_MODEL"].some((s) => value(`${prefix}${s}`));
26
+ if (!present) return null;
27
+ const missing = [];
28
+ if (!value(prefix)) missing.push(`${prefix}`);
29
+ if (!value(`${prefix}_BASE_URL`)) missing.push(`${prefix}_BASE_URL`);
30
+ if (!value(`${prefix}_API_KEY`)) missing.push(`${prefix}_API_KEY`);
31
+ if (!value(`${prefix}_MODEL`)) missing.push(`${prefix}_MODEL`);
32
+ return missing.length ? { prefix, missing } : null;
33
+ }
34
+
35
+ function numberedPrefixes() {
36
+ const prefixes = ["MCP_PROVIDER_PRIMARY"];
37
+ const nums = Object.keys(process.env)
38
+ .map((name) => name.match(/^MCP_PROVIDER_CHAIN(\d+)/)?.[1])
39
+ .filter(Boolean)
40
+ .map(Number)
41
+ .filter((n) => n >= 2);
42
+ for (const n of [...new Set(nums)].sort((a, b) => a - b)) {
43
+ prefixes.push(`MCP_PROVIDER_CHAIN${n}`);
44
+ }
45
+ return prefixes;
46
+ }
47
+
48
+ function numberedProviders() {
49
+ const providers = [];
50
+ for (const prefix of numberedPrefixes()) {
51
+ const provider = providerEnv(prefix);
52
+ if (provider) providers.push(provider);
53
+ }
54
+ return providers;
55
+ }
56
+
57
+ function legacyProvider() {
58
+ const base = value("MCP_RERANK_BASE_URL") || value("MCP_LLM_BASE_URL") || value("LLM_BASE_URL") || value("RERANK_URL") || value("NINEROUTER_URL") || DEFAULT_BASE;
59
+ const key = value("MCP_RERANK_API_KEY") || value("MCP_LLM_API_KEY") || value("LLM_API_KEY") || value("RERANK_KEY") || value("NINEROUTER_KEY");
60
+ const model = value("MCP_RERANK_MODEL") || value("RERANK_MODEL") || DEFAULT_MODEL;
61
+ if (!key) return null;
62
+ return { label: "legacy", base, key, model };
63
+ }
64
+
65
+ export function providerChainConfig() {
66
+ const deterministic = deterministicEnabled();
67
+ const rerankEnabled = process.env.RERANK_ENABLED !== "0";
68
+ if (deterministic || !rerankEnabled) return { providers: [], enabled: false, deterministic };
69
+ const providers = numberedProviders();
70
+ if (providers.length === 0) {
71
+ const legacy = legacyProvider();
72
+ if (legacy) providers.push(legacy);
73
+ }
74
+ return { providers, enabled: providers.length > 0, deterministic };
75
+ }
76
+
77
+ export function providerChainDiagnostics() {
78
+ const cfg = providerChainConfig();
79
+ const issues = [];
80
+ for (const prefix of numberedPrefixes()) {
81
+ const issue = slotIssues(prefix);
82
+ if (issue) issues.push(issue);
83
+ }
84
+ return {
85
+ deterministic: cfg.deterministic,
86
+ enabled: cfg.enabled,
87
+ providers: cfg.providers.map((p) => ({ label: p.label, base: p.base, model: p.model, apiKey: redactKey(p.key) })),
88
+ issues,
89
+ };
90
+ }
91
+
92
+ const COOLDOWN_MS = Number(process.env.MCP_PROVIDER_COOLDOWN_MS || 60000);
93
+ const cooldowns = new Map();
94
+
95
+ export function isCoolingDown(key, now = Date.now()) {
96
+ const until = cooldowns.get(key);
97
+ if (!until) return false;
98
+ if (now >= until) { cooldowns.delete(key); return false; }
99
+ return true;
100
+ }
101
+
102
+ export function recordOutcome(key, ok, now = Date.now()) {
103
+ if (ok) { cooldowns.delete(key); return; }
104
+ cooldowns.set(key, now + COOLDOWN_MS);
105
+ }
106
+
107
+ export function _resetCooldowns() {
108
+ cooldowns.clear();
109
+ }
@@ -1,29 +1,44 @@
1
1
  /**
2
- * LLM reranker via 9router chat models (e.g. Kiro kr/claude-haiku-4.5).
3
- * Used when embeddings are unavailable: keyword prefilter -> LLM picks the
4
- * most relevant candidates. Degrades gracefully: returns null on any failure
5
- * so callers fall back to keyword ordering. Never throws.
2
+ * LLM reranker via any OpenAI-compatible /v1/chat/completions model.
3
+ * Tries provider chain slots in order, skipping any currently cooling down
4
+ * after a recent failure. Degrades gracefully: returns null on any failure so
5
+ * callers fall back to keyword ordering. Never throws. Never logs keys/bodies.
6
6
  */
7
7
  import http from "http";
8
8
  import https from "https";
9
9
  import { URL } from "url";
10
+ import { deterministicEnabled } from "./env.js";
11
+ import { providerChainConfig, isCoolingDown, recordOutcome } from "./provider-chain.js";
10
12
 
11
- const BASE = process.env.LLM_BASE_URL || process.env.RERANK_URL || process.env.NINEROUTER_URL || "http://localhost:20128";
12
- const KEY = process.env.LLM_API_KEY || process.env.RERANK_KEY || process.env.NINEROUTER_KEY || "";
13
- const MODEL = process.env.RERANK_MODEL || "kr/claude-haiku-4.5";
14
13
  const TIMEOUT_MS = Number(process.env.RERANK_TIMEOUT_MS || 30000);
15
14
  const ENABLED = process.env.RERANK_ENABLED !== "0";
16
15
 
17
16
  export function rerankConfig() {
18
- return { base: BASE, model: MODEL, enabled: Boolean(KEY) && ENABLED };
17
+ const cfg = providerChainConfig();
18
+ const first = cfg.providers[0] || {};
19
+ return {
20
+ base: first.base,
21
+ model: first.model,
22
+ providers: cfg.providers.map(({ label, base, model }) => ({ label, base, model })),
23
+ enabled: ENABLED && cfg.enabled && !deterministicEnabled(),
24
+ deterministic: deterministicEnabled(),
25
+ };
19
26
  }
20
27
 
21
- function chat(messages) {
28
+ function providerKey(provider) {
29
+ return `${provider.label}|${provider.base}|${provider.model}`;
30
+ }
31
+
32
+ /**
33
+ * Resolves { content, retryable }. retryable=true means try the next provider
34
+ * (timeout, network error, 429, or 5xx). retryable=false with null content
35
+ * means a definitive non-retryable response (e.g. 4xx auth).
36
+ */
37
+ function chatWithProvider(provider, messages) {
22
38
  return new Promise((resolve) => {
23
- if (!KEY || !ENABLED) return resolve(null);
24
39
  let u;
25
- try { u = new URL("/v1/chat/completions", BASE); } catch { return resolve(null); }
26
- const payload = Buffer.from(JSON.stringify({ model: MODEL, stream: false, temperature: 0, max_tokens: 200, messages }));
40
+ try { u = new URL("/v1/chat/completions", provider.base); } catch { return resolve({ content: null, retryable: false }); }
41
+ const payload = Buffer.from(JSON.stringify({ model: provider.model, stream: false, temperature: 0, max_tokens: 200, messages }));
27
42
  const lib = u.protocol === "https:" ? https : http;
28
43
  const req = lib.request(
29
44
  {
@@ -31,18 +46,21 @@ function chat(messages) {
31
46
  port: u.port || (u.protocol === "https:" ? 443 : 80),
32
47
  path: u.pathname,
33
48
  method: "POST",
34
- headers: { "Content-Type": "application/json", "Content-Length": payload.length, Authorization: `Bearer ${KEY}` },
49
+ headers: { "Content-Type": "application/json", "Content-Length": payload.length, Authorization: `Bearer ${provider.key}` },
35
50
  timeout: TIMEOUT_MS,
36
51
  },
37
52
  (res) => {
38
53
  let body = "";
39
54
  res.on("data", (c) => (body += c));
40
55
  res.on("end", () => {
41
- if (res.statusCode !== 200) return resolve(null);
42
- // handle plain JSON or SSE stream just in case
56
+ const status = res.statusCode || 0;
57
+ if (status !== 200) {
58
+ const retryable = status === 429 || status >= 500;
59
+ return resolve({ content: null, retryable });
60
+ }
43
61
  try {
44
62
  const j = JSON.parse(body);
45
- return resolve(j?.choices?.[0]?.message?.content ?? null);
63
+ return resolve({ content: j?.choices?.[0]?.message?.content ?? null, retryable: false });
46
64
  } catch { /* maybe SSE */ }
47
65
  try {
48
66
  let acc = "";
@@ -54,24 +72,37 @@ function chat(messages) {
54
72
  const j = JSON.parse(d);
55
73
  acc += j?.choices?.[0]?.delta?.content || j?.choices?.[0]?.message?.content || "";
56
74
  }
57
- return resolve(acc || null);
58
- } catch { return resolve(null); }
75
+ return resolve({ content: acc || null, retryable: false });
76
+ } catch { return resolve({ content: null, retryable: true }); }
59
77
  });
60
78
  }
61
79
  );
62
- req.on("error", () => resolve(null));
63
- req.on("timeout", () => { req.destroy(); resolve(null); });
80
+ req.on("error", () => resolve({ content: null, retryable: true }));
81
+ req.on("timeout", () => { req.destroy(); resolve({ content: null, retryable: true }); });
64
82
  req.write(payload);
65
83
  req.end();
66
84
  });
67
85
  }
68
86
 
87
+ async function chat(messages) {
88
+ if (!ENABLED || deterministicEnabled()) return null;
89
+ const { providers } = providerChainConfig();
90
+ for (const provider of providers) {
91
+ const key = providerKey(provider);
92
+ if (isCoolingDown(key)) continue;
93
+ const { content, retryable } = await chatWithProvider(provider, messages);
94
+ if (content) { recordOutcome(key, true); return content; }
95
+ if (retryable) recordOutcome(key, false);
96
+ }
97
+ return null;
98
+ }
99
+
69
100
  /**
70
101
  * Given a query and candidates [{id, title, snippet}], ask the model to return
71
102
  * the ids ordered by relevance. Returns an array of ids, or null on failure.
72
103
  */
73
104
  export async function rerank(query, candidates, topK = 5) {
74
- if (!KEY || !ENABLED || !candidates || candidates.length === 0) return null;
105
+ if (!ENABLED || deterministicEnabled() || !candidates || candidates.length === 0 || !providerChainConfig().enabled) return null;
75
106
  const list = candidates.map((c, i) => `[${i + 1}] (id:${c.id}) ${c.title}\n ${String(c.snippet || "").replace(/\s+/g, " ").slice(0, 200)}`).join("\n");
76
107
  const sys = "You are a search reranker. Given a user query and a numbered list of notes, return the most relevant notes ordered best-first. Respond with ONLY a comma-separated list of the bracket numbers, e.g. 3,1,5. No prose.";
77
108
  const user = `Query: ${query}\n\nNotes:\n${list}\n\nReturn the top ${topK} bracket numbers, best first, comma-separated:`;
@@ -28,6 +28,7 @@ import os from "os";
28
28
  import crypto from "crypto";
29
29
  import { embed, embedOne, cosine, embeddingConfig } from "./embedding.js";
30
30
  import { rerank, rerankConfig } from "./rerank.js";
31
+ import { deterministicEnabled } from "./env.js";
31
32
 
32
33
  const VAULT_ROOT =
33
34
  process.env.MEMORY_VAULT_DIR ||
@@ -318,8 +319,8 @@ class ProjectMemoryServer {
318
319
  return score;
319
320
  };
320
321
 
321
- let mode = "keyword";
322
- const qVec = await embedOne(query);
322
+ let mode = deterministicEnabled() ? "deterministic" : "keyword";
323
+ const qVec = deterministicEnabled() ? null : await embedOne(query);
323
324
  const haveEmb = qVec && notes.some((n) => Array.isArray(n.embedding));
324
325
 
325
326
  let scored;
@@ -418,6 +419,7 @@ class ProjectMemoryServer {
418
419
 
419
420
  async reindex({ dir, force = false }) {
420
421
  const p = this.paths(dir);
422
+ if (deterministicEnabled()) return { content: [{ type: "text", text: `Reindex ${p.slug}: skipped (deterministic no-network mode; embeddings disabled).` }] };
421
423
  const index = await this.loadIndex(p);
422
424
  const notes = Object.values(index.notes || {});
423
425
  if (notes.length === 0) return { content: [{ type: "text", text: `No memories for ${p.slug} yet.` }] };