codex-dev-mcp-suite 1.3.0 → 1.5.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 +52 -0
- package/README.md +79 -1
- package/bin/provider-smoke.mjs +278 -0
- package/lib/provider-smoke.js +234 -0
- package/package.json +3 -2
- package/project-memory/dedup.js +49 -0
- package/project-memory/embedding.js +108 -30
- package/project-memory/global-index.js +46 -0
- package/project-memory/graph.js +86 -0
- package/project-memory/server.js +197 -11
|
@@ -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.5.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",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function jaccard(left, right) {
|
|
2
|
+
const leftSet = new Set(left.filter(Boolean));
|
|
3
|
+
const rightSet = new Set(right.filter(Boolean));
|
|
4
|
+
const intersection = [...leftSet].filter((item) => rightSet.has(item)).length;
|
|
5
|
+
const union = new Set([...leftSet, ...rightSet]).size || 1;
|
|
6
|
+
return intersection / union;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function tokenizeBody(text) {
|
|
10
|
+
return String(text || "").toLowerCase().split(/\W+/).filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function scoreDuplicatePair({ a, b, aBody, bBody }) {
|
|
14
|
+
const reasons = [];
|
|
15
|
+
let score = 0;
|
|
16
|
+
|
|
17
|
+
if (String(a.title || "").trim().toLowerCase() === String(b.title || "").trim().toLowerCase()) {
|
|
18
|
+
score += 0.5;
|
|
19
|
+
reasons.push("exact normalized title match");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const bodyScore = jaccard(tokenizeBody(aBody), tokenizeBody(bBody));
|
|
23
|
+
score += bodyScore * 0.4;
|
|
24
|
+
if (bodyScore >= 0.75) reasons.push("strong content overlap");
|
|
25
|
+
|
|
26
|
+
const linkScore = jaccard((a.links || []).map((link) => `${link.project || ""}:${link.ref || ""}`), (b.links || []).map((link) => `${link.project || ""}:${link.ref || ""}`));
|
|
27
|
+
score += linkScore * 0.1;
|
|
28
|
+
if (linkScore >= 0.75 && (a.links || []).length && (b.links || []).length) reasons.push("similar link neighborhood");
|
|
29
|
+
|
|
30
|
+
return { score: Math.min(1, score), reasons };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function findDuplicateCandidates({ rows, threshold }) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
36
|
+
for (let j = i + 1; j < rows.length; j += 1) {
|
|
37
|
+
const result = scoreDuplicatePair({
|
|
38
|
+
a: rows[i].note,
|
|
39
|
+
b: rows[j].note,
|
|
40
|
+
aBody: rows[i].body,
|
|
41
|
+
bBody: rows[j].body,
|
|
42
|
+
});
|
|
43
|
+
if (result.score >= threshold) {
|
|
44
|
+
out.push({ left: rows[i], right: rows[j], score: result.score, reasons: result.reasons });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out.sort((a, b) => b.score - a.score);
|
|
49
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export async function listProjects(vaultRoot) {
|
|
5
|
+
let slugs = [];
|
|
6
|
+
try {
|
|
7
|
+
slugs = await fs.readdir(vaultRoot);
|
|
8
|
+
} catch {
|
|
9
|
+
slugs = [];
|
|
10
|
+
}
|
|
11
|
+
return slugs.map((slug) => ({
|
|
12
|
+
slug,
|
|
13
|
+
projectDir: path.join(vaultRoot, slug),
|
|
14
|
+
indexFile: path.join(vaultRoot, slug, "index.json"),
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loadGlobalNotes(vaultRoot) {
|
|
19
|
+
const projects = await listProjects(vaultRoot);
|
|
20
|
+
const rows = [];
|
|
21
|
+
for (const project of projects) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await fs.readFile(project.indexFile, "utf8");
|
|
24
|
+
const index = JSON.parse(raw);
|
|
25
|
+
for (const note of Object.values(index.notes || {})) rows.push({ slug: project.slug, note });
|
|
26
|
+
} catch {
|
|
27
|
+
// ignore invalid or incomplete project index
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function findGlobalCandidates({ vaultRoot, currentSlug, query }) {
|
|
34
|
+
const q = String(query || "").toLowerCase();
|
|
35
|
+
const rows = await loadGlobalNotes(vaultRoot);
|
|
36
|
+
return rows
|
|
37
|
+
.filter(({ note }) =>
|
|
38
|
+
String(note.title || "").toLowerCase().includes(q) ||
|
|
39
|
+
(note.keywords || []).some((keyword) => String(keyword).toLowerCase().includes(q))
|
|
40
|
+
)
|
|
41
|
+
.sort((a, b) => {
|
|
42
|
+
const ap = a.slug === currentSlug ? 0 : 1;
|
|
43
|
+
const bp = b.slug === currentSlug ? 0 : 1;
|
|
44
|
+
return ap - bp || (a.note.created < b.note.created ? 1 : -1);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { parseFrontmatter } from "./server.js";
|
|
4
|
+
|
|
5
|
+
export function extractWikiLinks(body) {
|
|
6
|
+
const out = [];
|
|
7
|
+
const re = /\[\[([^\]]+)\]\]/g;
|
|
8
|
+
let match;
|
|
9
|
+
while ((match = re.exec(String(body || "")))) {
|
|
10
|
+
const raw = match[0];
|
|
11
|
+
const inner = match[1].trim();
|
|
12
|
+
const colon = inner.indexOf(":");
|
|
13
|
+
const project = colon > 0 ? inner.slice(0, colon).trim() : null;
|
|
14
|
+
const ref = colon > 0 ? inner.slice(colon + 1).trim() : inner;
|
|
15
|
+
const kind = /^[0-9A-Za-z_-]{6,}$/.test(ref) && !/\s/.test(ref) ? "id" : "title";
|
|
16
|
+
out.push({ raw, ref, project, kind });
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function loadNoteBody(projectDir, file) {
|
|
22
|
+
const raw = await fs.readFile(path.join(projectDir, file), "utf8");
|
|
23
|
+
return parseFrontmatter(raw).body;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function ensureGraphState({ vaultRoot, projectDir, slug, index, noteLoader }) {
|
|
27
|
+
void vaultRoot;
|
|
28
|
+
void slug;
|
|
29
|
+
let changed = false;
|
|
30
|
+
const backlinks = new Map();
|
|
31
|
+
for (const note of Object.values(index.notes || {})) {
|
|
32
|
+
const body = await noteLoader(projectDir, note.file);
|
|
33
|
+
const links = extractWikiLinks(body).map((link) => ({
|
|
34
|
+
raw: link.raw,
|
|
35
|
+
ref: link.ref,
|
|
36
|
+
project: link.project,
|
|
37
|
+
kind: link.kind,
|
|
38
|
+
}));
|
|
39
|
+
const next = JSON.stringify(links);
|
|
40
|
+
const prev = JSON.stringify(note.links || []);
|
|
41
|
+
if (next !== prev) {
|
|
42
|
+
note.links = links;
|
|
43
|
+
changed = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const note of Object.values(index.notes || {})) {
|
|
47
|
+
for (const link of note.links || []) {
|
|
48
|
+
if (link.project || link.kind !== "id") continue;
|
|
49
|
+
const arr = backlinks.get(link.ref) || [];
|
|
50
|
+
arr.push({ id: note.id, title: note.title });
|
|
51
|
+
backlinks.set(link.ref, arr);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const note of Object.values(index.notes || {})) {
|
|
55
|
+
const next = backlinks.get(note.id) || [];
|
|
56
|
+
if (JSON.stringify(next) !== JSON.stringify(note.backlinks || [])) {
|
|
57
|
+
note.backlinks = next;
|
|
58
|
+
changed = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { index, changed };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function resolveLink({ vaultRoot, currentSlug, ref, project, kind }) {
|
|
65
|
+
const targetSlug = project || currentSlug;
|
|
66
|
+
const indexFile = path.join(vaultRoot, targetSlug, "index.json");
|
|
67
|
+
let index;
|
|
68
|
+
try {
|
|
69
|
+
index = JSON.parse(await fs.readFile(indexFile, "utf8"));
|
|
70
|
+
} catch {
|
|
71
|
+
return { status: "missing" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const notes = Object.values(index.notes || {});
|
|
75
|
+
let candidates;
|
|
76
|
+
if (kind === "id") {
|
|
77
|
+
candidates = notes.filter((note) => note.id === ref);
|
|
78
|
+
} else {
|
|
79
|
+
const target = String(ref || "").trim().toLowerCase();
|
|
80
|
+
candidates = notes.filter((note) => String(note.title || "").trim().toLowerCase() === target);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (candidates.length === 0) return { status: "missing" };
|
|
84
|
+
if (candidates.length === 1) return { status: "resolved", match: candidates[0] };
|
|
85
|
+
return { status: "ambiguous", candidates };
|
|
86
|
+
}
|