clef-mcp 0.1.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/LICENSE +201 -0
- package/NOTICE +33 -0
- package/README.md +236 -0
- package/dist/cli.js +1970 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +431 -0
- package/dist/index.js +1464 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/tests/evals/dataset.jsonl +30 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1464 @@
|
|
|
1
|
+
// src/config/env.ts
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
// src/config/limits.ts
|
|
6
|
+
var DEFAULT_LIMITS = {
|
|
7
|
+
maxQuestions: 64,
|
|
8
|
+
maxStateBytes: 1024 * 1024,
|
|
9
|
+
maxInstructionsChars: 1e4,
|
|
10
|
+
maxCriteriaPerQuestion: 100,
|
|
11
|
+
maxCriteriaDescChars: 1e3,
|
|
12
|
+
maxQuestionIdChars: 128
|
|
13
|
+
};
|
|
14
|
+
function intFromEnv(env, key, fallback) {
|
|
15
|
+
const raw = env[key];
|
|
16
|
+
if (!raw?.trim()) return fallback;
|
|
17
|
+
const n = Number.parseInt(raw, 10);
|
|
18
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
19
|
+
}
|
|
20
|
+
function limitsFromEnv(env = process.env) {
|
|
21
|
+
return {
|
|
22
|
+
maxQuestions: intFromEnv(env, "CLEF_MAX_QUESTIONS", DEFAULT_LIMITS.maxQuestions),
|
|
23
|
+
maxStateBytes: intFromEnv(env, "CLEF_MAX_STATE_BYTES", DEFAULT_LIMITS.maxStateBytes),
|
|
24
|
+
maxInstructionsChars: intFromEnv(env, "CLEF_MAX_INSTRUCTION_CHARS", DEFAULT_LIMITS.maxInstructionsChars),
|
|
25
|
+
maxCriteriaPerQuestion: intFromEnv(env, "CLEF_MAX_CRITERIA", DEFAULT_LIMITS.maxCriteriaPerQuestion),
|
|
26
|
+
maxCriteriaDescChars: intFromEnv(env, "CLEF_MAX_CRITERIA_DESC_CHARS", DEFAULT_LIMITS.maxCriteriaDescChars),
|
|
27
|
+
maxQuestionIdChars: intFromEnv(env, "CLEF_MAX_QUESTION_ID_CHARS", DEFAULT_LIMITS.maxQuestionIdChars)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/config/env.ts
|
|
32
|
+
var SUPPORTED_LOG_LEVELS = ["error", "warn", "info", "debug"];
|
|
33
|
+
function parseLogLevel(value) {
|
|
34
|
+
if (!value) return void 0;
|
|
35
|
+
const v = value.trim().toLowerCase();
|
|
36
|
+
return SUPPORTED_LOG_LEVELS.includes(v) ? v : void 0;
|
|
37
|
+
}
|
|
38
|
+
function defaultClefHome() {
|
|
39
|
+
return path.join(os.homedir(), ".cache", "clef-mcp");
|
|
40
|
+
}
|
|
41
|
+
function loadConfig(env = process.env) {
|
|
42
|
+
const logLevel = parseLogLevel(env.CLEF_LOG_LEVEL) ?? "error";
|
|
43
|
+
const runtime = env.CLEF_RUNTIME?.trim() || "llama-cpp";
|
|
44
|
+
if (runtime !== "llama-cpp") {
|
|
45
|
+
throw new Error(`Unsupported CLEF_RUNTIME "${runtime}". Supported: llama-cpp`);
|
|
46
|
+
}
|
|
47
|
+
const llamaBin = env.CLEF_LLAMA_BIN?.trim() || void 0;
|
|
48
|
+
const llamaReleaseTag = env.CLEF_LLAMA_RELEASE_TAG?.trim() || void 0;
|
|
49
|
+
return {
|
|
50
|
+
model: env.CLEF_MODEL?.trim() || "clef-flash",
|
|
51
|
+
clefHome: env.CLEF_HOME?.trim() || defaultClefHome(),
|
|
52
|
+
runtime: "llama-cpp",
|
|
53
|
+
logLevel,
|
|
54
|
+
llamaBin,
|
|
55
|
+
llamaReleaseTag,
|
|
56
|
+
limits: limitsFromEnv(env)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/config/paths.ts
|
|
61
|
+
import path2 from "path";
|
|
62
|
+
function pathsFor(clefHome) {
|
|
63
|
+
const root = path2.resolve(clefHome);
|
|
64
|
+
return {
|
|
65
|
+
root,
|
|
66
|
+
modelsDir: path2.join(root, "models"),
|
|
67
|
+
runtimeDir: path2.join(root, "runtime"),
|
|
68
|
+
configJson: path2.join(root, "config.json")
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function quantDirLabel(quant) {
|
|
72
|
+
switch (quant) {
|
|
73
|
+
case "Q4_K_M":
|
|
74
|
+
return "4bit";
|
|
75
|
+
case "Q8_0":
|
|
76
|
+
return "8bit";
|
|
77
|
+
case "BF16":
|
|
78
|
+
return "bf16";
|
|
79
|
+
default:
|
|
80
|
+
return quant.toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function modelQuantDir(modelsDir, model, quant) {
|
|
84
|
+
return path2.join(modelsDir, model, quantDirLabel(quant));
|
|
85
|
+
}
|
|
86
|
+
function manifestPathFor(modelsDir, model, quant) {
|
|
87
|
+
return path2.join(modelQuantDir(modelsDir, model, quant), "manifest.json");
|
|
88
|
+
}
|
|
89
|
+
function llamaRuntimeDir(runtimeDir, platform, arch) {
|
|
90
|
+
return path2.join(runtimeDir, "llama.cpp", `${platform}-${arch}`);
|
|
91
|
+
}
|
|
92
|
+
function llamaServerName(platform) {
|
|
93
|
+
return platform === "win32" ? "llama-server.exe" : "llama-server";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/config/logger.ts
|
|
97
|
+
var LEVEL_WEIGHT = {
|
|
98
|
+
error: 0,
|
|
99
|
+
warn: 1,
|
|
100
|
+
info: 2,
|
|
101
|
+
debug: 3
|
|
102
|
+
};
|
|
103
|
+
function stderrSink(line) {
|
|
104
|
+
process.stderr.write(line + "\n");
|
|
105
|
+
}
|
|
106
|
+
var StderrLogger = class {
|
|
107
|
+
constructor(level, sink = stderrSink) {
|
|
108
|
+
this.level = level;
|
|
109
|
+
this.sink = sink;
|
|
110
|
+
}
|
|
111
|
+
level;
|
|
112
|
+
sink;
|
|
113
|
+
write(level, msg, extra) {
|
|
114
|
+
if (LEVEL_WEIGHT[level] > LEVEL_WEIGHT[this.level]) return;
|
|
115
|
+
const entry = {
|
|
116
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
117
|
+
level,
|
|
118
|
+
msg,
|
|
119
|
+
...extra ? { data: extra } : {}
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
this.sink(JSON.stringify(entry));
|
|
123
|
+
} catch {
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
error(msg, extra) {
|
|
127
|
+
this.write("error", msg, extra);
|
|
128
|
+
}
|
|
129
|
+
warn(msg, extra) {
|
|
130
|
+
this.write("warn", msg, extra);
|
|
131
|
+
}
|
|
132
|
+
info(msg, extra) {
|
|
133
|
+
this.write("info", msg, extra);
|
|
134
|
+
}
|
|
135
|
+
debug(msg, extra) {
|
|
136
|
+
this.write("debug", msg, extra);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
function createLogger(level, sink) {
|
|
140
|
+
return new StderrLogger(level, sink);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/clef/errors.ts
|
|
144
|
+
var ClefErrorCode = {
|
|
145
|
+
MODEL_NOT_INSTALLED: "MODEL_NOT_INSTALLED",
|
|
146
|
+
MODEL_LOAD_FAILED: "MODEL_LOAD_FAILED",
|
|
147
|
+
RUNTIME_NOT_FOUND: "RUNTIME_NOT_FOUND",
|
|
148
|
+
RUNTIME_INIT_FAILED: "RUNTIME_INIT_FAILED",
|
|
149
|
+
RUNTIME_NOT_SUPPORTED: "RUNTIME_NOT_SUPPORTED",
|
|
150
|
+
INVALID_INPUT: "INVALID_INPUT",
|
|
151
|
+
CLEF_INFERENCE_FAILED: "CLEF_INFERENCE_FAILED",
|
|
152
|
+
UNSUPPORTED_PLATFORM: "UNSUPPORTED_PLATFORM",
|
|
153
|
+
OUT_OF_MEMORY: "OUT_OF_MEMORY",
|
|
154
|
+
CHECKSUM_MISMATCH: "CHECKSUM_MISMATCH",
|
|
155
|
+
DOWNLOAD_FAILED: "DOWNLOAD_FAILED"
|
|
156
|
+
};
|
|
157
|
+
var ClefError = class extends Error {
|
|
158
|
+
code;
|
|
159
|
+
hint;
|
|
160
|
+
constructor(code, message, hint) {
|
|
161
|
+
super(message);
|
|
162
|
+
this.name = "ClefError";
|
|
163
|
+
this.code = code;
|
|
164
|
+
this.hint = hint;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
function errorJson(err) {
|
|
168
|
+
if (err instanceof ClefError) {
|
|
169
|
+
return {
|
|
170
|
+
error: {
|
|
171
|
+
code: err.code,
|
|
172
|
+
message: err.message,
|
|
173
|
+
...err.hint ? { hint: err.hint } : {}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
178
|
+
return { error: { code: ClefErrorCode.CLEF_INFERENCE_FAILED, message } };
|
|
179
|
+
}
|
|
180
|
+
function exitCodeFor(err) {
|
|
181
|
+
if (err instanceof ClefError) {
|
|
182
|
+
switch (err.code) {
|
|
183
|
+
case ClefErrorCode.INVALID_INPUT:
|
|
184
|
+
return 2;
|
|
185
|
+
case ClefErrorCode.MODEL_NOT_INSTALLED:
|
|
186
|
+
return 3;
|
|
187
|
+
case ClefErrorCode.RUNTIME_NOT_FOUND:
|
|
188
|
+
case ClefErrorCode.RUNTIME_NOT_SUPPORTED:
|
|
189
|
+
return 4;
|
|
190
|
+
default:
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/clef/systemone.ts
|
|
198
|
+
function stateToString(state) {
|
|
199
|
+
if (typeof state === "string") return state;
|
|
200
|
+
try {
|
|
201
|
+
return JSON.stringify(state) ?? "";
|
|
202
|
+
} catch (err) {
|
|
203
|
+
throw new ClefError(
|
|
204
|
+
ClefErrorCode.INVALID_INPUT,
|
|
205
|
+
`state must be JSON-serializable: ${err instanceof Error ? err.message : String(err)}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function toSystemOneRequest(input, defaultModel) {
|
|
210
|
+
const questions = {};
|
|
211
|
+
for (const [qid, q] of Object.entries(input.questions)) {
|
|
212
|
+
const question = { type: q.type, instructions: q.instructions };
|
|
213
|
+
if (q.criteria !== void 0) question.criteria = q.criteria;
|
|
214
|
+
questions[qid] = question;
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
model: input.model ?? defaultModel,
|
|
218
|
+
state: stateToString(input.state),
|
|
219
|
+
questions
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function finiteNumber(value, where) {
|
|
223
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
224
|
+
throw new ClefError(
|
|
225
|
+
ClefErrorCode.CLEF_INFERENCE_FAILED,
|
|
226
|
+
`Runtime returned a non-numeric probability for ${where}`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
231
|
+
function answerFor(answers, qid) {
|
|
232
|
+
const ans = answers[qid];
|
|
233
|
+
if (!ans || typeof ans !== "object") {
|
|
234
|
+
throw new ClefError(
|
|
235
|
+
ClefErrorCode.CLEF_INFERENCE_FAILED,
|
|
236
|
+
`Runtime response is missing an answer for question "${qid}"`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return ans;
|
|
240
|
+
}
|
|
241
|
+
function probabilitiesMap(ans, qid) {
|
|
242
|
+
const raw = ans.probabilities;
|
|
243
|
+
if (raw && !Array.isArray(raw) && typeof raw === "object") {
|
|
244
|
+
const out = {};
|
|
245
|
+
for (const [k, v] of Object.entries(raw)) out[k] = finiteNumber(v, `"${qid}" option "${k}"`);
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
if (Array.isArray(raw)) {
|
|
249
|
+
const out = {};
|
|
250
|
+
for (const [i, v] of raw.entries()) out[String(i)] = finiteNumber(v, `"${qid}" option #${i}`);
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
throw new ClefError(
|
|
254
|
+
ClefErrorCode.CLEF_INFERENCE_FAILED,
|
|
255
|
+
`Runtime response is missing probabilities for question "${qid}"`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
function normalizeNoul(ans, qid) {
|
|
259
|
+
let pTrue;
|
|
260
|
+
if (typeof ans.noul === "number") {
|
|
261
|
+
pTrue = finiteNumber(ans.noul, `"${qid}"`);
|
|
262
|
+
} else if (typeof ans.probability_true === "number") {
|
|
263
|
+
pTrue = finiteNumber(ans.probability_true, `"${qid}"`);
|
|
264
|
+
} else if (ans.probabilities && !Array.isArray(ans.probabilities)) {
|
|
265
|
+
const map = ans.probabilities;
|
|
266
|
+
const direct = map.true ?? map.yes ?? map.TRUE;
|
|
267
|
+
if (typeof direct === "number") pTrue = finiteNumber(direct, `"${qid}"`);
|
|
268
|
+
} else if (Array.isArray(ans.probabilities) && typeof ans.probabilities[0] === "number") {
|
|
269
|
+
pTrue = finiteNumber(ans.probabilities[0], `"${qid}"`);
|
|
270
|
+
}
|
|
271
|
+
if (pTrue === void 0) {
|
|
272
|
+
throw new ClefError(
|
|
273
|
+
ClefErrorCode.CLEF_INFERENCE_FAILED,
|
|
274
|
+
`Runtime response is missing the true-probability for noul question "${qid}"`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const p = Math.min(1, Math.max(0, pTrue));
|
|
278
|
+
return { true: p, false: 1 - p };
|
|
279
|
+
}
|
|
280
|
+
function normalizeScore(ans, qid, question) {
|
|
281
|
+
const criteria = question.criteria;
|
|
282
|
+
if (!Array.isArray(criteria) || criteria.length === 0) {
|
|
283
|
+
throw new ClefError(ClefErrorCode.INVALID_INPUT, `Score question "${qid}" requires an array of criteria`);
|
|
284
|
+
}
|
|
285
|
+
const raw = ans.probabilities;
|
|
286
|
+
let pairs;
|
|
287
|
+
if (Array.isArray(raw)) {
|
|
288
|
+
if (raw.length !== criteria.length) {
|
|
289
|
+
throw new ClefError(
|
|
290
|
+
ClefErrorCode.CLEF_INFERENCE_FAILED,
|
|
291
|
+
`Runtime returned ${raw.length} probabilities for score question "${qid}" with ${criteria.length} criteria`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
pairs = raw.map((v, i) => [criteria[i], finiteNumber(v, `"${qid}" criterion "${criteria[i]}"`)]);
|
|
295
|
+
} else if (raw && typeof raw === "object") {
|
|
296
|
+
const entries = Object.entries(raw);
|
|
297
|
+
const allNumericKeys = entries.length > 0 && entries.every(([k]) => /^\d+$/.test(k));
|
|
298
|
+
if (allNumericKeys) {
|
|
299
|
+
pairs = entries.map(([k, v]) => {
|
|
300
|
+
const idx = Number(k);
|
|
301
|
+
let label;
|
|
302
|
+
if (Array.isArray(ans.legend)) label = ans.legend[idx];
|
|
303
|
+
else if (ans.legend && typeof ans.legend === "object") label = ans.legend[k];
|
|
304
|
+
const fallback = Number.isInteger(idx) ? criteria[idx] : void 0;
|
|
305
|
+
return [label ?? fallback ?? k, finiteNumber(v, `"${qid}" option #${k}`)];
|
|
306
|
+
});
|
|
307
|
+
} else {
|
|
308
|
+
pairs = entries.map(([k, v]) => [k, finiteNumber(v, `"${qid}" criterion "${k}"`)]);
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
throw new ClefError(
|
|
312
|
+
ClefErrorCode.CLEF_INFERENCE_FAILED,
|
|
313
|
+
`Runtime response is missing the probability distribution for score question "${qid}"`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const out = {};
|
|
317
|
+
for (const criterion of criteria) {
|
|
318
|
+
const pair = pairs.find(([label]) => label === criterion);
|
|
319
|
+
if (!pair) {
|
|
320
|
+
throw new ClefError(
|
|
321
|
+
ClefErrorCode.CLEF_INFERENCE_FAILED,
|
|
322
|
+
`Runtime response is missing probability for score question "${qid}" criterion "${criterion}"`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
out[criterion] = pair[1];
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
function normalizeChoice(ans, qid) {
|
|
330
|
+
return probabilitiesMap(ans, qid);
|
|
331
|
+
}
|
|
332
|
+
function fromSystemOneResponse(response, questions, fallbackModel) {
|
|
333
|
+
const answers = response.answers;
|
|
334
|
+
if (!answers || typeof answers !== "object") {
|
|
335
|
+
throw new ClefError(ClefErrorCode.CLEF_INFERENCE_FAILED, "Runtime response is missing the answers object");
|
|
336
|
+
}
|
|
337
|
+
const decisions = {};
|
|
338
|
+
for (const [qid, question] of Object.entries(questions)) {
|
|
339
|
+
const ans = answerFor(answers, qid);
|
|
340
|
+
let answer;
|
|
341
|
+
switch (question.type) {
|
|
342
|
+
case "choice":
|
|
343
|
+
answer = normalizeChoice(ans, qid);
|
|
344
|
+
break;
|
|
345
|
+
case "noul":
|
|
346
|
+
answer = normalizeNoul(ans, qid);
|
|
347
|
+
break;
|
|
348
|
+
case "score":
|
|
349
|
+
answer = normalizeScore(ans, qid, question);
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
decisions[qid] = { answer };
|
|
353
|
+
}
|
|
354
|
+
return { model: response.model ?? fallbackModel, decisions };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/runtime/llama-cpp.ts
|
|
358
|
+
import { spawn } from "child_process";
|
|
359
|
+
import net from "net";
|
|
360
|
+
var STDERR_TAIL_LIMIT = 4e3;
|
|
361
|
+
function freePort() {
|
|
362
|
+
return new Promise((resolve, reject) => {
|
|
363
|
+
const srv = net.createServer();
|
|
364
|
+
srv.unref();
|
|
365
|
+
srv.on("error", reject);
|
|
366
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
367
|
+
const addr = srv.address();
|
|
368
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
369
|
+
srv.close(() => port > 0 ? resolve(port) : reject(new Error("could not acquire a free port")));
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
async function fetchWithTimeout(url, init, timeoutMs) {
|
|
374
|
+
const controller = new AbortController();
|
|
375
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
376
|
+
try {
|
|
377
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
378
|
+
} finally {
|
|
379
|
+
clearTimeout(timer);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
var OOM_PATTERNS = [
|
|
383
|
+
"out of memory",
|
|
384
|
+
"failed to allocate",
|
|
385
|
+
"unable to allocate",
|
|
386
|
+
"not enough memory",
|
|
387
|
+
"cannot allocate memory"
|
|
388
|
+
];
|
|
389
|
+
var LlamaCppRuntime = class {
|
|
390
|
+
constructor(opts) {
|
|
391
|
+
this.opts = opts;
|
|
392
|
+
this.host = opts.host ?? "127.0.0.1";
|
|
393
|
+
this.loadTimeoutMs = opts.loadTimeoutMs ?? 12e4;
|
|
394
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? 6e4;
|
|
395
|
+
}
|
|
396
|
+
opts;
|
|
397
|
+
proc;
|
|
398
|
+
resolvedPort;
|
|
399
|
+
host;
|
|
400
|
+
loadTimeoutMs;
|
|
401
|
+
requestTimeoutMs;
|
|
402
|
+
/** Serializes decide() calls: the llama.cpp clef path handles one sequence per batch. */
|
|
403
|
+
queue = Promise.resolve();
|
|
404
|
+
get endpoint() {
|
|
405
|
+
if (!this.resolvedPort) throw new ClefError(ClefErrorCode.RUNTIME_INIT_FAILED, "Runtime is not started");
|
|
406
|
+
return `http://${this.host}:${this.resolvedPort}`;
|
|
407
|
+
}
|
|
408
|
+
get running() {
|
|
409
|
+
return this.proc !== void 0 && this.proc.exitCode === null;
|
|
410
|
+
}
|
|
411
|
+
async load() {
|
|
412
|
+
if (this.running) return;
|
|
413
|
+
const port = this.opts.port ?? await freePort();
|
|
414
|
+
this.resolvedPort = port;
|
|
415
|
+
const args = [
|
|
416
|
+
"-m",
|
|
417
|
+
this.opts.modelPath,
|
|
418
|
+
"--host",
|
|
419
|
+
this.host,
|
|
420
|
+
"--port",
|
|
421
|
+
String(port),
|
|
422
|
+
"--no-webui",
|
|
423
|
+
...this.opts.alias ? ["--alias", this.opts.alias] : []
|
|
424
|
+
];
|
|
425
|
+
this.opts.log?.debug("spawning llama-server", { bin: this.opts.serverBin, args });
|
|
426
|
+
let proc;
|
|
427
|
+
try {
|
|
428
|
+
proc = spawn(this.opts.serverBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
429
|
+
} catch (err) {
|
|
430
|
+
throw new ClefError(
|
|
431
|
+
ClefErrorCode.RUNTIME_INIT_FAILED,
|
|
432
|
+
`Failed to start llama-server: ${err instanceof Error ? err.message : String(err)}`,
|
|
433
|
+
"Check that the runtime binary is executable, or run `clef-mcp doctor`."
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
this.proc = proc;
|
|
437
|
+
let stderrTail = "";
|
|
438
|
+
proc.stderr?.on("data", (chunk) => {
|
|
439
|
+
const text = chunk.toString();
|
|
440
|
+
stderrTail = (stderrTail + text).slice(-STDERR_TAIL_LIMIT);
|
|
441
|
+
this.opts.log?.debug(text.trimEnd());
|
|
442
|
+
});
|
|
443
|
+
proc.stdout?.on("data", (chunk) => {
|
|
444
|
+
this.opts.log?.debug(chunk.toString().trimEnd());
|
|
445
|
+
});
|
|
446
|
+
let rejectOnExit;
|
|
447
|
+
const exitedBeforeReady = new Promise((_resolve, reject) => {
|
|
448
|
+
rejectOnExit = reject;
|
|
449
|
+
});
|
|
450
|
+
const onEarlyExit = (code, signal) => {
|
|
451
|
+
const oom = OOM_PATTERNS.some((p) => stderrTail.toLowerCase().includes(p));
|
|
452
|
+
const message = `llama-server exited before becoming ready (code=${code ?? "null"}, signal=${signal ?? "null"})`;
|
|
453
|
+
const tail = stderrTail.trim();
|
|
454
|
+
rejectOnExit?.(
|
|
455
|
+
oom ? new ClefError(
|
|
456
|
+
ClefErrorCode.OUT_OF_MEMORY,
|
|
457
|
+
message,
|
|
458
|
+
"Not enough free memory for this model. Try a smaller quantization (`clef-mcp install --quant Q4_K_M`)."
|
|
459
|
+
) : new ClefError(
|
|
460
|
+
ClefErrorCode.RUNTIME_INIT_FAILED,
|
|
461
|
+
message + (tail ? `
|
|
462
|
+
stderr tail:
|
|
463
|
+
${tail}` : ""),
|
|
464
|
+
"Run `clef-mcp doctor` for a full diagnosis."
|
|
465
|
+
)
|
|
466
|
+
);
|
|
467
|
+
};
|
|
468
|
+
proc.once("exit", onEarlyExit);
|
|
469
|
+
try {
|
|
470
|
+
await Promise.race([this.waitUntilReady(), exitedBeforeReady]);
|
|
471
|
+
} finally {
|
|
472
|
+
proc.removeListener("exit", onEarlyExit);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async waitUntilReady() {
|
|
476
|
+
const deadline = Date.now() + this.loadTimeoutMs;
|
|
477
|
+
let lastError = "";
|
|
478
|
+
while (Date.now() < deadline) {
|
|
479
|
+
if (!this.running) throw new ClefError(ClefErrorCode.RUNTIME_INIT_FAILED, "llama-server is not running");
|
|
480
|
+
try {
|
|
481
|
+
const res = await fetchWithTimeout(`${this.endpoint}/health`, { method: "GET" }, 2e3);
|
|
482
|
+
if (res.status === 200) return;
|
|
483
|
+
lastError = `health status ${res.status}`;
|
|
484
|
+
} catch (err) {
|
|
485
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
486
|
+
}
|
|
487
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
488
|
+
}
|
|
489
|
+
throw new ClefError(
|
|
490
|
+
ClefErrorCode.MODEL_LOAD_FAILED,
|
|
491
|
+
`llama-server did not become ready within ${Math.round(this.loadTimeoutMs / 1e3)}s (last health check: ${lastError})`,
|
|
492
|
+
"Large models can take minutes to load. Run `clef-mcp doctor` or try a smaller quantization."
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
async decide(input) {
|
|
496
|
+
const run = this.queue.then(() => this.decideOnce(input));
|
|
497
|
+
this.queue = run.catch(() => void 0);
|
|
498
|
+
return run;
|
|
499
|
+
}
|
|
500
|
+
async decideOnce(input) {
|
|
501
|
+
if (!this.running) {
|
|
502
|
+
throw new ClefError(ClefErrorCode.RUNTIME_INIT_FAILED, "Runtime is not loaded; call load() first");
|
|
503
|
+
}
|
|
504
|
+
let res;
|
|
505
|
+
try {
|
|
506
|
+
res = await fetchWithTimeout(
|
|
507
|
+
`${this.endpoint}/v1/systemone`,
|
|
508
|
+
{
|
|
509
|
+
method: "POST",
|
|
510
|
+
headers: { "content-type": "application/json" },
|
|
511
|
+
body: JSON.stringify(input)
|
|
512
|
+
},
|
|
513
|
+
this.requestTimeoutMs
|
|
514
|
+
);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
517
|
+
throw new ClefError(
|
|
518
|
+
ClefErrorCode.CLEF_INFERENCE_FAILED,
|
|
519
|
+
`Inference request failed: ${msg}`,
|
|
520
|
+
msg.includes("abort") ? "Request timed out. Reduce state size or increase the request timeout." : void 0
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const bodyText = await res.text();
|
|
524
|
+
let body = void 0;
|
|
525
|
+
try {
|
|
526
|
+
body = bodyText ? JSON.parse(bodyText) : void 0;
|
|
527
|
+
} catch {
|
|
528
|
+
}
|
|
529
|
+
if (!res.ok) {
|
|
530
|
+
const apiMessage = body && typeof body === "object" && "error" in body ? String(body.error?.message ?? bodyText) : bodyText;
|
|
531
|
+
if (res.status === 413) {
|
|
532
|
+
throw new ClefError(
|
|
533
|
+
ClefErrorCode.CLEF_INFERENCE_FAILED,
|
|
534
|
+
`Input exceeds the model context window: ${apiMessage}`,
|
|
535
|
+
"Reduce the state size or the number/size of questions."
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
throw new ClefError(
|
|
539
|
+
ClefErrorCode.CLEF_INFERENCE_FAILED,
|
|
540
|
+
`Inference failed with HTTP ${res.status}: ${apiMessage.slice(0, 2e3)}`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (!body || typeof body !== "object") {
|
|
544
|
+
throw new ClefError(
|
|
545
|
+
ClefErrorCode.CLEF_INFERENCE_FAILED,
|
|
546
|
+
`Runtime returned a non-JSON response (HTTP ${res.status})`,
|
|
547
|
+
"The installed llama.cpp build may not support clef models. Run `clef-mcp doctor`."
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
return body;
|
|
551
|
+
}
|
|
552
|
+
async health() {
|
|
553
|
+
if (!this.running) return { status: "unavailable", detail: "not started" };
|
|
554
|
+
try {
|
|
555
|
+
const res = await fetchWithTimeout(`${this.endpoint}/health`, { method: "GET" }, 1500);
|
|
556
|
+
if (res.status === 200) return { status: "ready" };
|
|
557
|
+
return { status: "loading", detail: `health status ${res.status}` };
|
|
558
|
+
} catch (err) {
|
|
559
|
+
return { status: "unavailable", detail: err instanceof Error ? err.message : String(err) };
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
async unload() {
|
|
563
|
+
const proc = this.proc;
|
|
564
|
+
if (!proc) return;
|
|
565
|
+
this.proc = void 0;
|
|
566
|
+
if (proc.exitCode !== null || proc.signalCode) return;
|
|
567
|
+
await new Promise((resolve) => {
|
|
568
|
+
const killTimer = setTimeout(() => {
|
|
569
|
+
try {
|
|
570
|
+
proc.kill("SIGKILL");
|
|
571
|
+
} catch {
|
|
572
|
+
}
|
|
573
|
+
}, 3e3);
|
|
574
|
+
proc.once("exit", () => {
|
|
575
|
+
clearTimeout(killTimer);
|
|
576
|
+
resolve();
|
|
577
|
+
});
|
|
578
|
+
try {
|
|
579
|
+
proc.kill("SIGTERM");
|
|
580
|
+
} catch {
|
|
581
|
+
clearTimeout(killTimer);
|
|
582
|
+
resolve();
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// src/runtime/binary.ts
|
|
589
|
+
import fs2 from "fs";
|
|
590
|
+
import path4 from "path";
|
|
591
|
+
|
|
592
|
+
// src/models/manifest.ts
|
|
593
|
+
import { createHash } from "crypto";
|
|
594
|
+
import fs from "fs";
|
|
595
|
+
import fsp from "fs/promises";
|
|
596
|
+
import path3 from "path";
|
|
597
|
+
|
|
598
|
+
// src/models/registry.ts
|
|
599
|
+
var GB = 1024 ** 3;
|
|
600
|
+
var CLEF_FLASH = {
|
|
601
|
+
id: "clef-flash",
|
|
602
|
+
repo: "ggml-org/Clef-Flash-GGUF",
|
|
603
|
+
revision: "4a7a08c09bc63baf043b62b5ba89dd67a0357d95",
|
|
604
|
+
licenseId: "Apache-2.0",
|
|
605
|
+
licenseUrl: "https://huggingface.co/Cloudflare/clef-flash",
|
|
606
|
+
originUrl: "https://huggingface.co/Cloudflare/clef-flash",
|
|
607
|
+
defaultQuant: "Q4_K_M",
|
|
608
|
+
quants: [
|
|
609
|
+
{ id: "Q4_K_M", file: "Clef-Flash-Q4_K_M.gguf", bytes: 6486448192, dirLabel: "4bit", minTotalMemBytes: 16 * GB },
|
|
610
|
+
{ id: "Q8_0", file: "Clef-Flash-Q8_0.gguf", bytes: 9657260096, dirLabel: "8bit", minTotalMemBytes: 32 * GB },
|
|
611
|
+
{ id: "BF16", file: "Clef-Flash-BF16.gguf", bytes: 18164488256, dirLabel: "bf16", minTotalMemBytes: 64 * GB }
|
|
612
|
+
]
|
|
613
|
+
};
|
|
614
|
+
var MODELS = {
|
|
615
|
+
[CLEF_FLASH.id]: CLEF_FLASH
|
|
616
|
+
};
|
|
617
|
+
function getModelSpec(model) {
|
|
618
|
+
const spec = MODELS[model];
|
|
619
|
+
if (!spec) {
|
|
620
|
+
throw new ClefError(
|
|
621
|
+
ClefErrorCode.INVALID_INPUT,
|
|
622
|
+
`Unknown model "${model}".`,
|
|
623
|
+
`Available models: ${Object.keys(MODELS).join(", ")}`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
return spec;
|
|
627
|
+
}
|
|
628
|
+
function getQuantSpec(spec, quant) {
|
|
629
|
+
const found = spec.quants.find((q) => q.id === quant);
|
|
630
|
+
if (!found) {
|
|
631
|
+
throw new ClefError(
|
|
632
|
+
ClefErrorCode.INVALID_INPUT,
|
|
633
|
+
`Unknown quantization "${quant}" for model ${spec.id}.`,
|
|
634
|
+
`Available: ${spec.quants.map((q) => q.id).join(", ")}`
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
return found;
|
|
638
|
+
}
|
|
639
|
+
function pickQuant(spec, totalMemBytes, requested) {
|
|
640
|
+
if (requested) return getQuantSpec(spec, requested);
|
|
641
|
+
const fitting = [...spec.quants].sort((a, b) => b.minTotalMemBytes - a.minTotalMemBytes).find((q) => totalMemBytes >= q.minTotalMemBytes);
|
|
642
|
+
if (!fitting) {
|
|
643
|
+
throw new ClefError(
|
|
644
|
+
ClefErrorCode.UNSUPPORTED_PLATFORM,
|
|
645
|
+
`This machine has less than ${Math.round(spec.quants[spec.quants.length - 1].minTotalMemBytes / GB)} GB of memory; the smallest Clef-Flash build (Q4_K_M) needs 16 GB.`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return fitting;
|
|
649
|
+
}
|
|
650
|
+
var QUANT_PREFERENCE = ["Q4_K_M", "Q8_0", "BF16"];
|
|
651
|
+
|
|
652
|
+
// src/models/manifest.ts
|
|
653
|
+
function isModelManifest(v) {
|
|
654
|
+
if (!v || typeof v !== "object") return false;
|
|
655
|
+
const m = v;
|
|
656
|
+
return m.schemaVersion === 1 && typeof m.model === "string" && typeof m.quant === "string" && typeof m.file === "string" && typeof m.modelPath === "string" && typeof m.bytes === "number";
|
|
657
|
+
}
|
|
658
|
+
async function readModelManifest(manifestPath) {
|
|
659
|
+
let raw;
|
|
660
|
+
try {
|
|
661
|
+
raw = await fsp.readFile(manifestPath, "utf8");
|
|
662
|
+
} catch {
|
|
663
|
+
return void 0;
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
const parsed = JSON.parse(raw);
|
|
667
|
+
return isModelManifest(parsed) ? parsed : void 0;
|
|
668
|
+
} catch {
|
|
669
|
+
return void 0;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async function writeModelManifest(manifestPath, manifest) {
|
|
673
|
+
await fsp.mkdir(path3.dirname(manifestPath), { recursive: true });
|
|
674
|
+
await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
675
|
+
}
|
|
676
|
+
async function findInstalledQuant(modelsDir, model, specs) {
|
|
677
|
+
const installed = [];
|
|
678
|
+
for (const spec of specs) {
|
|
679
|
+
const manifest = await readModelManifest(manifestPathFor(modelsDir, model, spec.id));
|
|
680
|
+
if (!manifest) continue;
|
|
681
|
+
try {
|
|
682
|
+
const stat = await fsp.stat(manifest.modelPath);
|
|
683
|
+
if (stat.isFile() && stat.size === manifest.bytes) installed.push(spec);
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return QUANT_PREFERENCE.map((id) => installed.find((s) => s.id === id)).find(Boolean);
|
|
688
|
+
}
|
|
689
|
+
async function verifyModelFiles(manifest) {
|
|
690
|
+
const problems = [];
|
|
691
|
+
let stat;
|
|
692
|
+
try {
|
|
693
|
+
stat = await fsp.stat(manifest.modelPath);
|
|
694
|
+
} catch {
|
|
695
|
+
return { ok: false, problems: [`model file missing: ${manifest.modelPath}`] };
|
|
696
|
+
}
|
|
697
|
+
if (!stat.isFile()) problems.push(`model path is not a file: ${manifest.modelPath}`);
|
|
698
|
+
if (stat.size !== manifest.bytes) {
|
|
699
|
+
problems.push(`size mismatch: expected ${manifest.bytes} bytes, found ${stat.size}`);
|
|
700
|
+
}
|
|
701
|
+
return { ok: problems.length === 0, problems };
|
|
702
|
+
}
|
|
703
|
+
async function deepVerifyChecksum(manifest) {
|
|
704
|
+
const base = await verifyModelFiles(manifest);
|
|
705
|
+
if (!base.ok) return base;
|
|
706
|
+
if (!manifest.sha256) return { ok: false, problems: ["manifest has no recorded sha256"] };
|
|
707
|
+
const hash = createHash("sha256");
|
|
708
|
+
const stream = fs.createReadStream(manifest.modelPath);
|
|
709
|
+
for await (const chunk of stream) hash.update(chunk);
|
|
710
|
+
const digest = hash.digest("hex");
|
|
711
|
+
if (digest !== manifest.sha256) {
|
|
712
|
+
return { ok: false, problems: [`checksum mismatch: expected ${manifest.sha256}, got ${digest}`] };
|
|
713
|
+
}
|
|
714
|
+
return { ok: true, problems: [] };
|
|
715
|
+
}
|
|
716
|
+
async function findInstalledManifest(clefHome, spec) {
|
|
717
|
+
const { modelsDir } = pathsFor(clefHome);
|
|
718
|
+
const quant = await findInstalledQuant(modelsDir, spec.id, spec.quants);
|
|
719
|
+
if (!quant) return void 0;
|
|
720
|
+
return readModelManifest(manifestPathFor(modelsDir, spec.id, quant.id));
|
|
721
|
+
}
|
|
722
|
+
function isRuntimeManifest(v) {
|
|
723
|
+
if (!v || typeof v !== "object") return false;
|
|
724
|
+
const m = v;
|
|
725
|
+
return m.schemaVersion === 1 && m.kind === "llama-cpp" && typeof m.releaseTag === "string" && typeof m.asset === "string" && typeof m.binaryPath === "string";
|
|
726
|
+
}
|
|
727
|
+
async function writeRuntimeManifest(manifestPath, manifest) {
|
|
728
|
+
await fsp.mkdir(path3.dirname(manifestPath), { recursive: true });
|
|
729
|
+
await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
730
|
+
}
|
|
731
|
+
function readRuntimeManifest(manifestPath) {
|
|
732
|
+
try {
|
|
733
|
+
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
734
|
+
return isRuntimeManifest(parsed) ? parsed : void 0;
|
|
735
|
+
} catch {
|
|
736
|
+
return void 0;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// src/runtime/binary.ts
|
|
741
|
+
function existsExecutable(p) {
|
|
742
|
+
try {
|
|
743
|
+
fs2.accessSync(p, fs2.constants.X_OK);
|
|
744
|
+
return fs2.statSync(p).isFile();
|
|
745
|
+
} catch {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function whichBin(name, platform) {
|
|
750
|
+
const dirs = (process.env.PATH ?? "").split(path4.delimiter).filter(Boolean);
|
|
751
|
+
const exts = platform === "win32" ? (process.env.PATHEXT ?? ".exe").split(";") : [""];
|
|
752
|
+
for (const dir of dirs) {
|
|
753
|
+
for (const ext of exts) {
|
|
754
|
+
const candidate = path4.join(dir, name + ext.toLowerCase());
|
|
755
|
+
if (existsExecutable(candidate)) return candidate;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return void 0;
|
|
759
|
+
}
|
|
760
|
+
function managedBinary(clefHome, platform, arch, exists) {
|
|
761
|
+
const platformDir = llamaRuntimeDir(pathsFor(clefHome).runtimeDir, platform, arch);
|
|
762
|
+
const manifest = readRuntimeManifest(path4.join(platformDir, "runtime-manifest.json"));
|
|
763
|
+
if (manifest && exists(manifest.binaryPath)) return manifest.binaryPath;
|
|
764
|
+
const legacy = path4.join(platformDir, llamaServerName(platform));
|
|
765
|
+
if (exists(legacy)) return legacy;
|
|
766
|
+
return void 0;
|
|
767
|
+
}
|
|
768
|
+
function resolveLlamaServerBinary(cfg, deps = {}) {
|
|
769
|
+
const exists = deps.existsExecutable ?? existsExecutable;
|
|
770
|
+
const which = deps.which ?? ((name) => whichBin(name, deps.platform ?? process.platform));
|
|
771
|
+
const platform = deps.platform ?? process.platform;
|
|
772
|
+
const arch = deps.arch ?? process.arch;
|
|
773
|
+
if (cfg.llamaBin) {
|
|
774
|
+
if (!exists(cfg.llamaBin)) {
|
|
775
|
+
throw new ClefError(
|
|
776
|
+
ClefErrorCode.RUNTIME_NOT_FOUND,
|
|
777
|
+
`CLEF_LLAMA_BIN points to a missing or non-executable file: ${cfg.llamaBin}`
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
return { source: "env", path: cfg.llamaBin };
|
|
781
|
+
}
|
|
782
|
+
const managed = managedBinary(cfg.clefHome, platform, arch, exists);
|
|
783
|
+
if (managed) return { source: "managed", path: managed };
|
|
784
|
+
const onPath = which("llama-server");
|
|
785
|
+
if (onPath) return { source: "path", path: onPath };
|
|
786
|
+
throw new ClefError(
|
|
787
|
+
ClefErrorCode.RUNTIME_NOT_FOUND,
|
|
788
|
+
"No llama-server binary found.",
|
|
789
|
+
"Install llama.cpp with clef support (`brew install llama.cpp`, or `curl -LsSf https://llama.app/install.sh | sh`), or run `clef-mcp install` to download a managed runtime."
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// src/runtime/probe.ts
|
|
794
|
+
import fs3 from "fs/promises";
|
|
795
|
+
async function probeInference(cfg, manifest, log, loadTimeoutMs = 12e4) {
|
|
796
|
+
await fs3.access(manifest.modelPath).catch(() => {
|
|
797
|
+
throw new ClefError(
|
|
798
|
+
ClefErrorCode.MODEL_NOT_INSTALLED,
|
|
799
|
+
`Model file is missing: ${manifest.modelPath}`,
|
|
800
|
+
"Run `clef-mcp install` to (re-)download the model."
|
|
801
|
+
);
|
|
802
|
+
});
|
|
803
|
+
const binary = resolveLlamaServerBinary(cfg);
|
|
804
|
+
const runtime = new LlamaCppRuntime({
|
|
805
|
+
serverBin: binary.path,
|
|
806
|
+
modelPath: manifest.modelPath,
|
|
807
|
+
alias: manifest.model,
|
|
808
|
+
loadTimeoutMs,
|
|
809
|
+
log
|
|
810
|
+
});
|
|
811
|
+
const started = Date.now();
|
|
812
|
+
await runtime.load();
|
|
813
|
+
try {
|
|
814
|
+
await runtime.decide({
|
|
815
|
+
model: manifest.model,
|
|
816
|
+
state: "This is an internal clef-mcp installation probe.",
|
|
817
|
+
questions: {
|
|
818
|
+
probe: { type: "noul", instructions: "Is this a clef-mcp installation probe?" }
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
} finally {
|
|
822
|
+
await runtime.unload();
|
|
823
|
+
}
|
|
824
|
+
return { latencyMs: Date.now() - started, binarySource: binary.source, binaryPath: binary.path };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// src/models/hf.ts
|
|
828
|
+
import { createHash as createHash2 } from "crypto";
|
|
829
|
+
import fsp2 from "fs/promises";
|
|
830
|
+
import path5 from "path";
|
|
831
|
+
var HF_BASE_URL = "https://huggingface.co";
|
|
832
|
+
function resolveUrl(repo, revision, file) {
|
|
833
|
+
return `${HF_BASE_URL}/${repo}/resolve/${revision}/${file}`;
|
|
834
|
+
}
|
|
835
|
+
async function fetchFileInfo(repo, revision, file) {
|
|
836
|
+
const url = resolveUrl(repo, revision, file);
|
|
837
|
+
let res;
|
|
838
|
+
try {
|
|
839
|
+
res = await fetch(url, { method: "HEAD", redirect: "manual" });
|
|
840
|
+
} catch (err) {
|
|
841
|
+
throw new ClefError(
|
|
842
|
+
ClefErrorCode.DOWNLOAD_FAILED,
|
|
843
|
+
`Could not reach Hugging Face: ${err instanceof Error ? err.message : String(err)}`,
|
|
844
|
+
"Check network connectivity and try again."
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
if (res.status !== 302 && res.status !== 200) {
|
|
848
|
+
throw new ClefError(
|
|
849
|
+
ClefErrorCode.DOWNLOAD_FAILED,
|
|
850
|
+
`Hugging Face returned HTTP ${res.status} for ${repo}@${revision}/${file}`,
|
|
851
|
+
res.status === 404 ? "The pinned model revision may have moved. Re-check the model registry." : void 0
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
const sha256 = res.headers.get("x-linked-etag")?.replace(/"/g, "");
|
|
855
|
+
const size = res.headers.get("x-linked-size") ? Number(res.headers.get("x-linked-size")) : void 0;
|
|
856
|
+
return {
|
|
857
|
+
downloadUrl: res.headers.get("location") ?? url,
|
|
858
|
+
sha256: sha256 && /^[0-9a-f]{64}$/i.test(sha256) ? sha256 : void 0,
|
|
859
|
+
size: Number.isFinite(size) ? size : void 0
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
async function downloadFile(url, dest, opts = {}) {
|
|
863
|
+
const attempts = opts.attempts ?? 3;
|
|
864
|
+
let lastError;
|
|
865
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
866
|
+
try {
|
|
867
|
+
return await downloadOnce(url, dest, opts);
|
|
868
|
+
} catch (err) {
|
|
869
|
+
if (err instanceof ClefError && err.code === ClefErrorCode.CHECKSUM_MISMATCH) throw err;
|
|
870
|
+
lastError = err;
|
|
871
|
+
if (attempt < attempts) {
|
|
872
|
+
await new Promise((r) => setTimeout(r, 1e3 * 2 ** (attempt - 1)));
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
throw new ClefError(
|
|
877
|
+
ClefErrorCode.DOWNLOAD_FAILED,
|
|
878
|
+
`Download failed after ${attempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
async function downloadOnce(url, dest, opts) {
|
|
882
|
+
const res = await fetch(url, { redirect: "follow", signal: opts.signal });
|
|
883
|
+
if (!res.ok || !res.body) {
|
|
884
|
+
throw new Error(`HTTP ${res.status}`);
|
|
885
|
+
}
|
|
886
|
+
const total = opts.expectedSize ?? (res.headers.get("content-length") ? Number(res.headers.get("content-length")) : void 0);
|
|
887
|
+
const hash = createHash2("sha256");
|
|
888
|
+
const handle = await fsp2.open(dest, "w");
|
|
889
|
+
let bytes = 0;
|
|
890
|
+
try {
|
|
891
|
+
for await (const chunk of res.body) {
|
|
892
|
+
const buf = chunk;
|
|
893
|
+
hash.update(buf);
|
|
894
|
+
await handle.write(buf);
|
|
895
|
+
bytes += buf.length;
|
|
896
|
+
opts.onProgress?.({ bytes, total });
|
|
897
|
+
}
|
|
898
|
+
} finally {
|
|
899
|
+
await handle.close();
|
|
900
|
+
}
|
|
901
|
+
const sha256 = hash.digest("hex");
|
|
902
|
+
if (opts.expectedSha256 && sha256 !== opts.expectedSha256) {
|
|
903
|
+
await fsp2.rm(dest, { force: true });
|
|
904
|
+
throw new ClefError(
|
|
905
|
+
ClefErrorCode.CHECKSUM_MISMATCH,
|
|
906
|
+
`Checksum mismatch after download: expected ${opts.expectedSha256}, got ${sha256}`,
|
|
907
|
+
"The download was discarded. Re-run `clef-mcp install`."
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
if (opts.expectedSize !== void 0 && bytes !== opts.expectedSize) {
|
|
911
|
+
await fsp2.rm(dest, { force: true });
|
|
912
|
+
throw new ClefError(
|
|
913
|
+
ClefErrorCode.DOWNLOAD_FAILED,
|
|
914
|
+
`Downloaded size mismatch: expected ${opts.expectedSize} bytes, got ${bytes}`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
return { bytes, sha256 };
|
|
918
|
+
}
|
|
919
|
+
async function installModel(opts) {
|
|
920
|
+
const { modelSpec, quantSpec } = opts;
|
|
921
|
+
const { modelsDir } = pathsFor(opts.clefHome);
|
|
922
|
+
const manifestPath = manifestPathFor(modelsDir, modelSpec.id, quantSpec.id);
|
|
923
|
+
const existing = await readModelManifest(manifestPath);
|
|
924
|
+
if (existing && (await verifyModelFiles(existing)).ok) {
|
|
925
|
+
opts.log?.debug("model already installed", { model: modelSpec.id, quant: quantSpec.id });
|
|
926
|
+
return { manifest: existing, alreadyInstalled: true };
|
|
927
|
+
}
|
|
928
|
+
const info = await fetchFileInfo(modelSpec.repo, modelSpec.revision, quantSpec.file);
|
|
929
|
+
const finalPath = path5.join(path5.dirname(manifestPath), quantSpec.file);
|
|
930
|
+
await fsp2.mkdir(path5.dirname(finalPath), { recursive: true });
|
|
931
|
+
const tmpPath = path5.join(path5.dirname(finalPath), `.download-${quantSpec.file}`);
|
|
932
|
+
await fsp2.rm(tmpPath, { force: true });
|
|
933
|
+
opts.log?.info("downloading model", { repo: modelSpec.repo, file: quantSpec.file });
|
|
934
|
+
const result = await downloadFile(info.downloadUrl, tmpPath, {
|
|
935
|
+
expectedSha256: info.sha256,
|
|
936
|
+
expectedSize: info.size ?? quantSpec.bytes,
|
|
937
|
+
onProgress: opts.onProgress
|
|
938
|
+
});
|
|
939
|
+
await fsp2.rename(tmpPath, finalPath);
|
|
940
|
+
const manifest = {
|
|
941
|
+
schemaVersion: 1,
|
|
942
|
+
model: modelSpec.id,
|
|
943
|
+
quant: quantSpec.id,
|
|
944
|
+
file: quantSpec.file,
|
|
945
|
+
modelPath: finalPath,
|
|
946
|
+
bytes: result.bytes,
|
|
947
|
+
sha256: result.sha256,
|
|
948
|
+
repo: modelSpec.repo,
|
|
949
|
+
revision: modelSpec.revision,
|
|
950
|
+
licenseId: modelSpec.licenseId,
|
|
951
|
+
licenseUrl: modelSpec.licenseUrl,
|
|
952
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
953
|
+
clefMcpVersion: opts.clefMcpVersion
|
|
954
|
+
};
|
|
955
|
+
await writeModelManifest(manifestPath, manifest);
|
|
956
|
+
opts.log?.info("model installed", { model: modelSpec.id, quant: quantSpec.id, sha256: result.sha256 });
|
|
957
|
+
return { manifest, alreadyInstalled: false };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// src/models/llama-install.ts
|
|
961
|
+
import { execFile } from "child_process";
|
|
962
|
+
import fsp3 from "fs/promises";
|
|
963
|
+
import path6 from "path";
|
|
964
|
+
var GITHUB_API = "https://api.github.com/repos/ggml-org/llama.cpp";
|
|
965
|
+
async function ghJson(url) {
|
|
966
|
+
let res;
|
|
967
|
+
try {
|
|
968
|
+
res = await fetch(url, {
|
|
969
|
+
headers: { accept: "application/vnd.github+json", "user-agent": "clef-mcp" }
|
|
970
|
+
});
|
|
971
|
+
} catch (err) {
|
|
972
|
+
throw new ClefError(
|
|
973
|
+
ClefErrorCode.DOWNLOAD_FAILED,
|
|
974
|
+
`Could not reach GitHub: ${err instanceof Error ? err.message : String(err)}`
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
if (!res.ok) {
|
|
978
|
+
throw new ClefError(
|
|
979
|
+
ClefErrorCode.DOWNLOAD_FAILED,
|
|
980
|
+
`GitHub API returned HTTP ${res.status} for ${url}`,
|
|
981
|
+
res.status === 403 ? "GitHub API rate limit may be hit; install llama.cpp manually and retry." : void 0
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
return await res.json();
|
|
985
|
+
}
|
|
986
|
+
function assetPatterns(platform, arch) {
|
|
987
|
+
switch (platform) {
|
|
988
|
+
case "darwin":
|
|
989
|
+
return arch === "arm64" ? { match: /^llama-.*-bin-macos-arm64\.tar\.gz$/ } : { match: /^llama-.*-bin-macos-x64\.tar\.gz$/ };
|
|
990
|
+
case "linux":
|
|
991
|
+
return arch === "arm64" ? { match: /^llama-.*-bin-ubuntu-arm64\.tar\.gz$/ } : { match: /^llama-.*-bin-ubuntu-x64\.tar\.gz$/ };
|
|
992
|
+
case "win32":
|
|
993
|
+
return {
|
|
994
|
+
match: /^llama-.*-bin-win-avx2-x64\.zip$/,
|
|
995
|
+
alternates: [/^llama-.*-bin-win-noavx-x64\.zip$/, /^llama-.*-bin-win-x64\.zip$/]
|
|
996
|
+
};
|
|
997
|
+
default:
|
|
998
|
+
throw new ClefError(ClefErrorCode.UNSUPPORTED_PLATFORM, `Unsupported platform ${platform}/${arch}`);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
function pickAsset(release, platform, arch) {
|
|
1002
|
+
const { match, alternates } = assetPatterns(platform, arch);
|
|
1003
|
+
const names = release.assets.map((a) => a.name.toLowerCase());
|
|
1004
|
+
const find = (re) => release.assets[names.findIndex((n) => re.test(n))];
|
|
1005
|
+
const asset = find(match) ?? alternates?.map(find).find(Boolean);
|
|
1006
|
+
if (!asset) {
|
|
1007
|
+
throw new ClefError(
|
|
1008
|
+
ClefErrorCode.DOWNLOAD_FAILED,
|
|
1009
|
+
`No llama.cpp release asset for ${platform}/${arch} in ${release.tag_name}`,
|
|
1010
|
+
"Install llama.cpp manually (`brew install llama.cpp` or https://llama.app) and re-run install."
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
return asset;
|
|
1014
|
+
}
|
|
1015
|
+
function execTar(args, cwd) {
|
|
1016
|
+
return new Promise((resolve, reject) => {
|
|
1017
|
+
execFile("tar", args, { cwd, timeout: 3e5 }, (err, _stdout, stderr) => {
|
|
1018
|
+
if (err) reject(new Error(`tar ${args.join(" ")} failed: ${stderr || err.message}`));
|
|
1019
|
+
else resolve();
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
async function findServerBinary(dir, platform) {
|
|
1024
|
+
const target = llamaServerName(platform);
|
|
1025
|
+
const queue = [dir];
|
|
1026
|
+
while (queue.length > 0) {
|
|
1027
|
+
const current = queue.shift();
|
|
1028
|
+
let entries;
|
|
1029
|
+
try {
|
|
1030
|
+
entries = await fsp3.readdir(current, { withFileTypes: true });
|
|
1031
|
+
} catch {
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
for (const entry of entries) {
|
|
1035
|
+
const full = path6.join(current, entry.name);
|
|
1036
|
+
if (entry.isDirectory()) queue.push(full);
|
|
1037
|
+
else if (entry.name === target) return full;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
throw new ClefError(
|
|
1041
|
+
ClefErrorCode.DOWNLOAD_FAILED,
|
|
1042
|
+
`Extracted llama.cpp release did not contain ${target}`
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
async function extractRelease(archivePath, destDir, platform) {
|
|
1046
|
+
await fsp3.mkdir(destDir, { recursive: true });
|
|
1047
|
+
const args = archivePath.endsWith(".zip") ? ["-xf", archivePath] : ["-xzf", archivePath];
|
|
1048
|
+
try {
|
|
1049
|
+
await execTar(args, destDir);
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
throw new ClefError(
|
|
1052
|
+
ClefErrorCode.DOWNLOAD_FAILED,
|
|
1053
|
+
`Could not extract ${path6.basename(archivePath)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1054
|
+
"Extract the archive manually and point CLEF_LLAMA_BIN at llama-server."
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
async function ensureLlamaRuntime(cfg, log) {
|
|
1059
|
+
try {
|
|
1060
|
+
const resolved = resolveLlamaServerBinary(cfg);
|
|
1061
|
+
if (resolved.source === "managed") {
|
|
1062
|
+
const platformDir = llamaRuntimeDir(pathsFor(cfg.clefHome).runtimeDir, process.platform, process.arch);
|
|
1063
|
+
const manifest = readRuntimeManifest(path6.join(platformDir, "runtime-manifest.json"));
|
|
1064
|
+
return { ...resolved, releaseTag: manifest?.releaseTag ?? "unknown", freshInstall: false };
|
|
1065
|
+
}
|
|
1066
|
+
return { ...resolved, releaseTag: resolved.source, freshInstall: false };
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
if (!(err instanceof ClefError) || err.code !== ClefErrorCode.RUNTIME_NOT_FOUND) throw err;
|
|
1069
|
+
}
|
|
1070
|
+
const fresh = await downloadManagedLlama(cfg, log);
|
|
1071
|
+
return { ...fresh, freshInstall: true };
|
|
1072
|
+
}
|
|
1073
|
+
async function downloadManagedLlama(cfg, log) {
|
|
1074
|
+
const platform = process.platform;
|
|
1075
|
+
const arch = process.arch;
|
|
1076
|
+
const { runtimeDir } = pathsFor(cfg.clefHome);
|
|
1077
|
+
const platformDir = llamaRuntimeDir(runtimeDir, platform, arch);
|
|
1078
|
+
let release;
|
|
1079
|
+
if (cfg.llamaReleaseTag) {
|
|
1080
|
+
release = await ghJson(`${GITHUB_API}/releases/tags/${cfg.llamaReleaseTag}`);
|
|
1081
|
+
} else {
|
|
1082
|
+
const releases = await ghJson(`${GITHUB_API}/releases?per_page=15`);
|
|
1083
|
+
const nightly = releases.find((r) => /^b\d+$/.test(r.tag_name));
|
|
1084
|
+
if (!nightly) {
|
|
1085
|
+
throw new ClefError(
|
|
1086
|
+
ClefErrorCode.DOWNLOAD_FAILED,
|
|
1087
|
+
"No llama.cpp nightly release found on GitHub.",
|
|
1088
|
+
"Install llama.cpp manually (`brew install llama.cpp` or https://llama.app) and re-run install."
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
release = nightly;
|
|
1092
|
+
}
|
|
1093
|
+
const asset = pickAsset(release, platform, arch);
|
|
1094
|
+
log?.info("downloading llama.cpp runtime", { tag: release.tag_name, asset: asset.name });
|
|
1095
|
+
const tagDir = path6.join(platformDir, release.tag_name);
|
|
1096
|
+
await fsp3.mkdir(tagDir, { recursive: true });
|
|
1097
|
+
const tmpArchive = path6.join(platformDir, `.download-${asset.name}`);
|
|
1098
|
+
await fsp3.rm(tmpArchive, { force: true });
|
|
1099
|
+
const result = await downloadFile(asset.browser_download_url, tmpArchive, { expectedSize: asset.size, attempts: 3 });
|
|
1100
|
+
await extractRelease(tmpArchive, tagDir, platform);
|
|
1101
|
+
await fsp3.rm(tmpArchive, { force: true });
|
|
1102
|
+
const binaryPath = await findServerBinary(tagDir, platform);
|
|
1103
|
+
if (platform !== "win32") await fsp3.chmod(binaryPath, 493);
|
|
1104
|
+
await writeRuntimeManifest(path6.join(platformDir, "runtime-manifest.json"), {
|
|
1105
|
+
schemaVersion: 1,
|
|
1106
|
+
kind: "llama-cpp",
|
|
1107
|
+
releaseTag: release.tag_name,
|
|
1108
|
+
asset: asset.name,
|
|
1109
|
+
bytes: result.bytes,
|
|
1110
|
+
binaryPath,
|
|
1111
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1112
|
+
});
|
|
1113
|
+
log?.info("llama.cpp runtime installed", { tag: release.tag_name, path: binaryPath });
|
|
1114
|
+
return { source: "managed", path: binaryPath, releaseTag: release.tag_name };
|
|
1115
|
+
}
|
|
1116
|
+
async function removeManagedRuntime(cfg) {
|
|
1117
|
+
const { runtimeDir } = pathsFor(cfg.clefHome);
|
|
1118
|
+
const dir = llamaRuntimeDir(runtimeDir, process.platform, process.arch);
|
|
1119
|
+
try {
|
|
1120
|
+
await fsp3.stat(dir);
|
|
1121
|
+
} catch {
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
1124
|
+
await fsp3.rm(dir, { recursive: true, force: true });
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// src/models/platform.ts
|
|
1129
|
+
import { execFile as execFile2 } from "child_process";
|
|
1130
|
+
import os2 from "os";
|
|
1131
|
+
function exec(cmd, args, timeoutMs) {
|
|
1132
|
+
return new Promise((resolve, reject) => {
|
|
1133
|
+
execFile2(cmd, args, { timeout: timeoutMs, encoding: "utf8" }, (err, stdout) => {
|
|
1134
|
+
if (err) reject(err);
|
|
1135
|
+
else resolve(stdout);
|
|
1136
|
+
});
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
function parseSizeToBytes(raw) {
|
|
1140
|
+
if (!raw) return void 0;
|
|
1141
|
+
const m = raw.trim().match(/^([\d.]+)\s*(GB|MB|GiB|MiB|B)?$/i);
|
|
1142
|
+
if (!m) return void 0;
|
|
1143
|
+
const n = Number.parseFloat(m[1]);
|
|
1144
|
+
if (!Number.isFinite(n)) return void 0;
|
|
1145
|
+
const unit = (m[2] ?? "B").toLowerCase();
|
|
1146
|
+
const mult = unit === "gb" || unit === "gib" ? 1024 ** 3 : unit === "mb" || unit === "mib" ? 1024 ** 2 : 1;
|
|
1147
|
+
return Math.round(n * mult);
|
|
1148
|
+
}
|
|
1149
|
+
async function gpuInfoDarwin() {
|
|
1150
|
+
try {
|
|
1151
|
+
const out = await exec("system_profiler", ["SPDisplaysDataType", "-json"], 1e4);
|
|
1152
|
+
const parsed = JSON.parse(out);
|
|
1153
|
+
const gpu = parsed.SPDisplaysDataType?.[0];
|
|
1154
|
+
if (!gpu) return void 0;
|
|
1155
|
+
const info = { name: typeof gpu.sppci_model === "string" ? gpu.sppci_model : void 0 };
|
|
1156
|
+
for (const key of ["spdisplays_vram_shared", "spdisplays_vram"]) {
|
|
1157
|
+
const bytes = parseSizeToBytes(typeof gpu[key] === "string" ? gpu[key] : void 0);
|
|
1158
|
+
if (bytes) {
|
|
1159
|
+
info.vramBytes = bytes;
|
|
1160
|
+
break;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return info;
|
|
1164
|
+
} catch {
|
|
1165
|
+
return void 0;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
async function gpuInfoLinux() {
|
|
1169
|
+
try {
|
|
1170
|
+
const out = await exec("nvidia-smi", ["--query-gpu=name,memory.total", "--format=csv,noheader"], 5e3);
|
|
1171
|
+
const [name, mib] = out.trim().split(",").map((s) => s.trim());
|
|
1172
|
+
if (name && mib) return { name, vramBytes: Number.parseFloat(mib) * 1024 ** 2 };
|
|
1173
|
+
return void 0;
|
|
1174
|
+
} catch {
|
|
1175
|
+
return void 0;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
async function detectPlatform() {
|
|
1179
|
+
const platform = process.platform;
|
|
1180
|
+
const info = {
|
|
1181
|
+
platform,
|
|
1182
|
+
arch: process.arch,
|
|
1183
|
+
totalMemBytes: os2.totalmem()
|
|
1184
|
+
};
|
|
1185
|
+
if (platform === "darwin") info.gpu = await gpuInfoDarwin();
|
|
1186
|
+
else if (platform === "linux") info.gpu = await gpuInfoLinux();
|
|
1187
|
+
return info;
|
|
1188
|
+
}
|
|
1189
|
+
function assertSupportedPlatform(info) {
|
|
1190
|
+
const ok = info.platform === "darwin" && (info.arch === "arm64" || info.arch === "x64") || info.platform === "linux" && (info.arch === "x64" || info.arch === "arm64") || info.platform === "win32" && info.arch === "x64";
|
|
1191
|
+
if (!ok) {
|
|
1192
|
+
throw new ClefError(
|
|
1193
|
+
ClefErrorCode.UNSUPPORTED_PLATFORM,
|
|
1194
|
+
`Unsupported platform: ${info.platform}/${info.arch}`,
|
|
1195
|
+
"clef-mcp v0.1 supports macOS (arm64/x64), Linux (x64/arm64) and Windows x64."
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
function formatBytes(bytes) {
|
|
1200
|
+
const gib = bytes / 1024 ** 3;
|
|
1201
|
+
if (gib >= 1) return `${gib.toFixed(1)} GB`;
|
|
1202
|
+
return `${Math.round(bytes / 1024 ** 2)} MB`;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// src/mcp/server.ts
|
|
1206
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1207
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1208
|
+
|
|
1209
|
+
// src/mcp/schema.ts
|
|
1210
|
+
import { z } from "zod";
|
|
1211
|
+
function criteriaDescription(limits) {
|
|
1212
|
+
return z.string().min(1).max(limits.maxCriteriaDescChars);
|
|
1213
|
+
}
|
|
1214
|
+
function choiceCriteria(limits) {
|
|
1215
|
+
return z.record(z.string().min(1).max(limits.maxCriteriaDescChars), criteriaDescription(limits)).check((ctx) => {
|
|
1216
|
+
const count = Object.keys(ctx.value).length;
|
|
1217
|
+
if (count === 0) ctx.issues.push({ code: "custom", message: "criteria must not be empty", input: ctx.value });
|
|
1218
|
+
if (count > limits.maxCriteriaPerQuestion) {
|
|
1219
|
+
ctx.issues.push({ code: "custom", message: `too many criteria (max ${limits.maxCriteriaPerQuestion})`, input: ctx.value });
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
function scoreCriteria(limits) {
|
|
1224
|
+
return z.array(criteriaDescription(limits)).check((ctx) => {
|
|
1225
|
+
if (ctx.value.length === 0) ctx.issues.push({ code: "custom", message: "criteria must not be empty", input: ctx.value });
|
|
1226
|
+
if (ctx.value.length > limits.maxCriteriaPerQuestion) {
|
|
1227
|
+
ctx.issues.push({ code: "custom", message: `too many criteria (max ${limits.maxCriteriaPerQuestion})`, input: ctx.value });
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
function questionSchema(limits) {
|
|
1232
|
+
return z.object({
|
|
1233
|
+
type: z.enum(["noul", "choice", "score"]),
|
|
1234
|
+
instructions: z.string().min(1).max(limits.maxInstructionsChars),
|
|
1235
|
+
criteria: z.union([z.array(z.string()), z.record(z.string(), z.string())]).optional()
|
|
1236
|
+
}).superRefine((q, ctx) => {
|
|
1237
|
+
if (q.type === "choice") {
|
|
1238
|
+
const parsed = choiceCriteria(limits).safeParse(q.criteria);
|
|
1239
|
+
if (!parsed.success) {
|
|
1240
|
+
ctx.addIssue({
|
|
1241
|
+
code: "custom",
|
|
1242
|
+
message: `"choice" questions require non-empty criteria as a map of option id -> description (${parsed.error.issues[0]?.message ?? "invalid criteria"})`
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
} else if (q.type === "score") {
|
|
1246
|
+
const parsed = scoreCriteria(limits).safeParse(q.criteria);
|
|
1247
|
+
if (!parsed.success) {
|
|
1248
|
+
ctx.addIssue({
|
|
1249
|
+
code: "custom",
|
|
1250
|
+
message: `"score" questions require non-empty criteria as an ordered list (${parsed.error.issues[0]?.message ?? "invalid criteria"})`
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
} else if (q.criteria !== void 0 && Array.isArray(q.criteria)) {
|
|
1254
|
+
ctx.addIssue({ code: "custom", message: '"noul" criteria (if given) must be a map with "true"/"false" descriptions' });
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
function clefDecideInputShape(limits) {
|
|
1259
|
+
return {
|
|
1260
|
+
state: z.unknown(),
|
|
1261
|
+
questions: z.record(z.string().min(1).max(limits.maxQuestionIdChars), questionSchema(limits)).check((ctx) => {
|
|
1262
|
+
const count = Object.keys(ctx.value).length;
|
|
1263
|
+
if (count === 0) ctx.issues.push({ code: "custom", message: "questions must not be empty", input: ctx.value });
|
|
1264
|
+
if (count > limits.maxQuestions) {
|
|
1265
|
+
ctx.issues.push({ code: "custom", message: `too many questions (max ${limits.maxQuestions})`, input: ctx.value });
|
|
1266
|
+
}
|
|
1267
|
+
}),
|
|
1268
|
+
model: z.string().min(1).optional(),
|
|
1269
|
+
options: z.object({
|
|
1270
|
+
/**
|
|
1271
|
+
* Accepted for forward compatibility. Clef scores all options in a
|
|
1272
|
+
* single forward pass — there is no sampling, so temperature is a
|
|
1273
|
+
* documented no-op.
|
|
1274
|
+
*/
|
|
1275
|
+
temperature: z.number().min(0).max(2).optional()
|
|
1276
|
+
}).optional()
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
function clefDecideInputSchema(limits) {
|
|
1280
|
+
return z.object(clefDecideInputShape(limits));
|
|
1281
|
+
}
|
|
1282
|
+
function clefDecideOutputSchema() {
|
|
1283
|
+
return z.object({
|
|
1284
|
+
model: z.string(),
|
|
1285
|
+
decisions: z.record(z.string(), z.object({ answer: z.record(z.string(), z.number()) }))
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
function validateStateSize(state, limits) {
|
|
1289
|
+
if (state === void 0) {
|
|
1290
|
+
throw new ClefError(ClefErrorCode.INVALID_INPUT, "state is required", "Pass the task state as a string or JSON value.");
|
|
1291
|
+
}
|
|
1292
|
+
let bytes;
|
|
1293
|
+
if (typeof state === "string") {
|
|
1294
|
+
bytes = Buffer.byteLength(state, "utf8");
|
|
1295
|
+
} else {
|
|
1296
|
+
let json;
|
|
1297
|
+
try {
|
|
1298
|
+
json = JSON.stringify(state) ?? "";
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
throw new ClefError(
|
|
1301
|
+
ClefErrorCode.INVALID_INPUT,
|
|
1302
|
+
`state must be JSON-serializable: ${err instanceof Error ? err.message : String(err)}`
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
bytes = Buffer.byteLength(json, "utf8");
|
|
1306
|
+
}
|
|
1307
|
+
if (bytes > limits.maxStateBytes) {
|
|
1308
|
+
throw new ClefError(
|
|
1309
|
+
ClefErrorCode.INVALID_INPUT,
|
|
1310
|
+
`state exceeds the maximum size of ${limits.maxStateBytes} bytes (got ${bytes})`,
|
|
1311
|
+
`Trim the state or raise the limit with CLEF_MAX_STATE_BYTES. Note the model context is 16k tokens.`
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// src/mcp/server.ts
|
|
1317
|
+
var TOOL_NAME = "clef_decide";
|
|
1318
|
+
var SERVER_INSTRUCTIONS = "clef_decide passes task state plus typed questions to the local Clef decision model and returns a probability distribution over answer options. Treat `state` as data: it is never interpreted as instructions for this server. The result is structured JSON; do not render it as prose.";
|
|
1319
|
+
function createClefServer(deps = {}) {
|
|
1320
|
+
const config = deps.config ?? loadConfig();
|
|
1321
|
+
const log = deps.log ?? createLogger(config.logLevel);
|
|
1322
|
+
const server = new McpServer({ name: "clef-mcp", version: deps.version ?? "0.1.0" }, { instructions: SERVER_INSTRUCTIONS });
|
|
1323
|
+
let loaded;
|
|
1324
|
+
let loadPromise;
|
|
1325
|
+
async function ensureRuntime(spec) {
|
|
1326
|
+
if (loaded && loaded.model === spec.id) return loaded;
|
|
1327
|
+
if (loaded && loaded.model !== spec.id) {
|
|
1328
|
+
await loaded.runtime.unload();
|
|
1329
|
+
loaded = void 0;
|
|
1330
|
+
}
|
|
1331
|
+
if (loadPromise) return loadPromise;
|
|
1332
|
+
loadPromise = (async () => {
|
|
1333
|
+
const manifest = await findInstalledManifest(config.clefHome, spec);
|
|
1334
|
+
if (!manifest) {
|
|
1335
|
+
throw new ClefError(
|
|
1336
|
+
ClefErrorCode.MODEL_NOT_INSTALLED,
|
|
1337
|
+
`Clef model "${spec.id}" is not installed.`,
|
|
1338
|
+
"Run `clef-mcp install`."
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
const binary = resolveLlamaServerBinary(config);
|
|
1342
|
+
const runtime = deps.runtimeFactory ? deps.runtimeFactory({ serverBin: binary.path, modelPath: manifest.modelPath, alias: spec.id, log }) : new LlamaCppRuntime({ serverBin: binary.path, modelPath: manifest.modelPath, alias: spec.id, log });
|
|
1343
|
+
log.debug("loading model", { model: spec.id, quant: manifest.quant, bin: binary.path });
|
|
1344
|
+
const loadStarted = Date.now();
|
|
1345
|
+
await runtime.load();
|
|
1346
|
+
log.info("model ready", { model: spec.id, quant: manifest.quant, loadMs: Date.now() - loadStarted });
|
|
1347
|
+
return { runtime, model: spec.id };
|
|
1348
|
+
})();
|
|
1349
|
+
try {
|
|
1350
|
+
loaded = await loadPromise;
|
|
1351
|
+
return loaded;
|
|
1352
|
+
} finally {
|
|
1353
|
+
loadPromise = void 0;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
server.registerTool(
|
|
1357
|
+
TOOL_NAME,
|
|
1358
|
+
{
|
|
1359
|
+
title: "Clef Decide",
|
|
1360
|
+
description: "Pass task state and a set of structured questions (noul / choice / score) to the local Clef decision model. Returns a probability distribution over the allowed answers for every question. The result is structured JSON.",
|
|
1361
|
+
inputSchema: clefDecideInputShape(config.limits),
|
|
1362
|
+
outputSchema: clefDecideOutputSchema().shape
|
|
1363
|
+
},
|
|
1364
|
+
async (args) => {
|
|
1365
|
+
try {
|
|
1366
|
+
const parsed = clefDecideInputSchema(config.limits).parse(args);
|
|
1367
|
+
validateStateSize(parsed.state, config.limits);
|
|
1368
|
+
const spec = getModelSpec(parsed.model ?? config.model);
|
|
1369
|
+
const input = {
|
|
1370
|
+
state: parsed.state,
|
|
1371
|
+
questions: parsed.questions,
|
|
1372
|
+
model: spec.id,
|
|
1373
|
+
options: parsed.options
|
|
1374
|
+
};
|
|
1375
|
+
const request = toSystemOneRequest(input, spec.id);
|
|
1376
|
+
const { runtime } = await ensureRuntime(spec);
|
|
1377
|
+
const response = await runtime.decide(request);
|
|
1378
|
+
const output = fromSystemOneResponse(response, parsed.questions, spec.id);
|
|
1379
|
+
return {
|
|
1380
|
+
content: [{ type: "text", text: JSON.stringify(output) }],
|
|
1381
|
+
structuredContent: output
|
|
1382
|
+
};
|
|
1383
|
+
} catch (err) {
|
|
1384
|
+
const structured = errorJson(err);
|
|
1385
|
+
log.error("clef_decide failed", structured.error);
|
|
1386
|
+
return {
|
|
1387
|
+
isError: true,
|
|
1388
|
+
content: [{ type: "text", text: JSON.stringify(structured) }]
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
);
|
|
1393
|
+
async function shutdown() {
|
|
1394
|
+
if (loaded) {
|
|
1395
|
+
await loaded.runtime.unload();
|
|
1396
|
+
loaded = void 0;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
async function start() {
|
|
1400
|
+
const transport = new StdioServerTransport();
|
|
1401
|
+
await server.connect(transport);
|
|
1402
|
+
log.debug("clef-mcp listening on stdio");
|
|
1403
|
+
const exitAfterUnload = (signal) => {
|
|
1404
|
+
log.debug("shutting down", { signal });
|
|
1405
|
+
void shutdown().finally(() => process.exit(0));
|
|
1406
|
+
};
|
|
1407
|
+
process.once("SIGINT", exitAfterUnload);
|
|
1408
|
+
process.once("SIGTERM", exitAfterUnload);
|
|
1409
|
+
process.stdin.once("end", () => {
|
|
1410
|
+
void shutdown().finally(() => process.exit(0));
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
return { server, start, shutdown };
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// src/version.ts
|
|
1417
|
+
var VERSION = "0.1.0";
|
|
1418
|
+
export {
|
|
1419
|
+
CLEF_FLASH,
|
|
1420
|
+
ClefError,
|
|
1421
|
+
ClefErrorCode,
|
|
1422
|
+
DEFAULT_LIMITS,
|
|
1423
|
+
LlamaCppRuntime,
|
|
1424
|
+
MODELS,
|
|
1425
|
+
SERVER_INSTRUCTIONS,
|
|
1426
|
+
StderrLogger,
|
|
1427
|
+
TOOL_NAME,
|
|
1428
|
+
VERSION,
|
|
1429
|
+
assertSupportedPlatform,
|
|
1430
|
+
clefDecideInputSchema,
|
|
1431
|
+
clefDecideOutputSchema,
|
|
1432
|
+
createClefServer,
|
|
1433
|
+
createLogger,
|
|
1434
|
+
deepVerifyChecksum,
|
|
1435
|
+
defaultClefHome,
|
|
1436
|
+
detectPlatform,
|
|
1437
|
+
downloadFile,
|
|
1438
|
+
downloadManagedLlama,
|
|
1439
|
+
ensureLlamaRuntime,
|
|
1440
|
+
errorJson,
|
|
1441
|
+
exitCodeFor,
|
|
1442
|
+
fetchFileInfo,
|
|
1443
|
+
findInstalledManifest,
|
|
1444
|
+
formatBytes,
|
|
1445
|
+
fromSystemOneResponse,
|
|
1446
|
+
getModelSpec,
|
|
1447
|
+
getQuantSpec,
|
|
1448
|
+
installModel,
|
|
1449
|
+
limitsFromEnv,
|
|
1450
|
+
loadConfig,
|
|
1451
|
+
manifestPathFor,
|
|
1452
|
+
modelQuantDir,
|
|
1453
|
+
pathsFor,
|
|
1454
|
+
pickQuant,
|
|
1455
|
+
probeInference,
|
|
1456
|
+
readModelManifest,
|
|
1457
|
+
removeManagedRuntime,
|
|
1458
|
+
resolveLlamaServerBinary,
|
|
1459
|
+
toSystemOneRequest,
|
|
1460
|
+
validateStateSize,
|
|
1461
|
+
verifyModelFiles,
|
|
1462
|
+
writeModelManifest
|
|
1463
|
+
};
|
|
1464
|
+
//# sourceMappingURL=index.js.map
|