clef-mcp 0.1.6 → 0.2.1

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/dist/index.js CHANGED
@@ -29,6 +29,7 @@ function limitsFromEnv(env = process.env) {
29
29
  }
30
30
 
31
31
  // src/config/env.ts
32
+ var SUPPORTED_RUNTIMES = ["llama-cpp", "mlx"];
32
33
  var SUPPORTED_LOG_LEVELS = ["error", "warn", "info", "debug"];
33
34
  function parseLogLevel(value) {
34
35
  if (!value) return void 0;
@@ -40,19 +41,20 @@ function defaultClefHome() {
40
41
  }
41
42
  function loadConfig(env = process.env) {
42
43
  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`);
44
+ const runtimeRaw = (env.CLEF_RUNTIME?.trim() || "llama-cpp").toLowerCase();
45
+ if (!SUPPORTED_RUNTIMES.includes(runtimeRaw)) {
46
+ throw new Error(`Unsupported CLEF_RUNTIME "${runtimeRaw}". Supported: ${SUPPORTED_RUNTIMES.join(", ")}`);
46
47
  }
48
+ const runtime = runtimeRaw;
47
49
  const llamaBin = env.CLEF_LLAMA_BIN?.trim() || void 0;
48
50
  const llamaReleaseTag = env.CLEF_LLAMA_RELEASE_TAG?.trim() || void 0;
49
51
  return {
50
52
  model: env.CLEF_MODEL?.trim() || "clef-flash",
51
53
  clefHome: env.CLEF_HOME?.trim() || defaultClefHome(),
52
- runtime: "llama-cpp",
54
+ runtime,
53
55
  logLevel,
54
- llamaBin,
55
- llamaReleaseTag,
56
+ llamaBin: runtime === "llama-cpp" ? llamaBin : void 0,
57
+ llamaReleaseTag: runtime === "llama-cpp" ? llamaReleaseTag : void 0,
56
58
  limits: limitsFromEnv(env)
57
59
  };
58
60
  }
@@ -664,10 +666,10 @@ function isModelManifest(v) {
664
666
  const m = v;
665
667
  return m.schemaVersion === 1 && typeof m.model === "string" && typeof m.quant === "string" && typeof m.file === "string" && typeof m.modelPath === "string" && typeof m.bytes === "number";
666
668
  }
667
- async function readModelManifest(manifestPath) {
669
+ async function readModelManifest(manifestPath2) {
668
670
  let raw;
669
671
  try {
670
- raw = await fsp.readFile(manifestPath, "utf8");
672
+ raw = await fsp.readFile(manifestPath2, "utf8");
671
673
  } catch {
672
674
  return void 0;
673
675
  }
@@ -678,9 +680,9 @@ async function readModelManifest(manifestPath) {
678
680
  return void 0;
679
681
  }
680
682
  }
681
- async function writeModelManifest(manifestPath, manifest) {
682
- await fsp.mkdir(path3.dirname(manifestPath), { recursive: true });
683
- await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
683
+ async function writeModelManifest(manifestPath2, manifest) {
684
+ await fsp.mkdir(path3.dirname(manifestPath2), { recursive: true });
685
+ await fsp.writeFile(manifestPath2, JSON.stringify(manifest, null, 2) + "\n", "utf8");
684
686
  }
685
687
  async function findInstalledQuant(modelsDir, model, specs) {
686
688
  const installed = [];
@@ -733,13 +735,13 @@ function isRuntimeManifest(v) {
733
735
  const m = v;
734
736
  return m.schemaVersion === 1 && m.kind === "llama-cpp" && typeof m.releaseTag === "string" && typeof m.asset === "string" && typeof m.binaryPath === "string";
735
737
  }
736
- async function writeRuntimeManifest(manifestPath, manifest) {
737
- await fsp.mkdir(path3.dirname(manifestPath), { recursive: true });
738
- await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
738
+ async function writeRuntimeManifest(manifestPath2, manifest) {
739
+ await fsp.mkdir(path3.dirname(manifestPath2), { recursive: true });
740
+ await fsp.writeFile(manifestPath2, JSON.stringify(manifest, null, 2) + "\n", "utf8");
739
741
  }
740
- function readRuntimeManifest(manifestPath) {
742
+ function readRuntimeManifest(manifestPath2) {
741
743
  try {
742
- const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
744
+ const parsed = JSON.parse(fs.readFileSync(manifestPath2, "utf8"));
743
745
  return isRuntimeManifest(parsed) ? parsed : void 0;
744
746
  } catch {
745
747
  return void 0;
@@ -801,6 +803,259 @@ function resolveLlamaServerBinary(cfg, deps = {}) {
801
803
 
802
804
  // src/runtime/probe.ts
803
805
  import fs3 from "fs/promises";
806
+
807
+ // src/models/mlx-install.ts
808
+ import { execFile } from "child_process";
809
+ import fsSync from "fs";
810
+ import fsp2 from "fs/promises";
811
+ import path5 from "path";
812
+ function manifestPath(clefHome) {
813
+ return path5.join(pathsFor(clefHome).modelsDir, "clef-flash", "mlx", "manifest.json");
814
+ }
815
+ async function readMlxManifest(clefHome) {
816
+ let raw;
817
+ try {
818
+ raw = await fsp2.readFile(manifestPath(clefHome), "utf8");
819
+ } catch {
820
+ return void 0;
821
+ }
822
+ try {
823
+ const parsed = JSON.parse(raw);
824
+ if (parsed.kind === "mlx" && typeof parsed.snapshotPath === "string") return parsed;
825
+ return void 0;
826
+ } catch {
827
+ return void 0;
828
+ }
829
+ }
830
+ async function mlxIsInstalled(clefHome) {
831
+ const manifest = await readMlxManifest(clefHome);
832
+ if (!manifest) return void 0;
833
+ try {
834
+ await fsp2.access(path5.join(manifest.snapshotPath, "clef_mlx.py"));
835
+ return manifest;
836
+ } catch {
837
+ return void 0;
838
+ }
839
+ }
840
+ function resolveUv(envUv) {
841
+ if (envUv?.trim()) return envUv.trim();
842
+ const dirs = (process.env.PATH ?? "").split(path5.delimiter).filter(Boolean);
843
+ for (const dir of dirs) {
844
+ const candidate = path5.join(dir, "uv");
845
+ try {
846
+ fsSync.accessSync(candidate, fsSync.constants.X_OK);
847
+ return candidate;
848
+ } catch {
849
+ }
850
+ }
851
+ throw new ClefError(
852
+ ClefErrorCode.RUNTIME_NOT_FOUND,
853
+ "uv is required for the MLX runtime but was not found on PATH.",
854
+ "Install it with: curl -LsSf https://astral.sh/uv/install.sh | sh"
855
+ );
856
+ }
857
+
858
+ // src/runtime/mlx.ts
859
+ import { spawn as spawn2 } from "child_process";
860
+ import net2 from "net";
861
+ var STDERR_TAIL_LIMIT2 = 4e3;
862
+ function freePort2() {
863
+ return new Promise((resolve, reject) => {
864
+ const srv = net2.createServer();
865
+ srv.unref();
866
+ srv.on("error", reject);
867
+ srv.listen(0, "127.0.0.1", () => {
868
+ const addr = srv.address();
869
+ const port = typeof addr === "object" && addr ? addr.port : 0;
870
+ srv.close(() => port > 0 ? resolve(port) : reject(new Error("could not acquire a free port")));
871
+ });
872
+ });
873
+ }
874
+ async function fetchWithTimeout2(url, init, timeoutMs) {
875
+ const controller = new AbortController();
876
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
877
+ try {
878
+ return await fetch(url, { ...init, signal: controller.signal });
879
+ } finally {
880
+ clearTimeout(timer);
881
+ }
882
+ }
883
+ var OOM_PATTERNS2 = ["out of memory", "failed to allocate", "unable to allocate", "not enough memory"];
884
+ var MlxRuntime = class {
885
+ constructor(opts) {
886
+ this.opts = opts;
887
+ this.host = opts.host ?? "127.0.0.1";
888
+ this.loadTimeoutMs = opts.loadTimeoutMs ?? 18e4;
889
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? 12e4;
890
+ }
891
+ opts;
892
+ proc;
893
+ resolvedPort;
894
+ host;
895
+ loadTimeoutMs;
896
+ requestTimeoutMs;
897
+ queue = Promise.resolve();
898
+ get endpoint() {
899
+ if (!this.resolvedPort) throw new ClefError(ClefErrorCode.RUNTIME_INIT_FAILED, "Runtime is not started");
900
+ return `http://${this.host}:${this.resolvedPort}`;
901
+ }
902
+ get running() {
903
+ return this.proc !== void 0 && this.proc.exitCode === null;
904
+ }
905
+ async load() {
906
+ if (this.running) return;
907
+ const port = this.opts.port ?? await freePort2();
908
+ this.resolvedPort = port;
909
+ const args = [
910
+ "run",
911
+ "--directory",
912
+ this.opts.snapshotPath,
913
+ "--with",
914
+ "mlx-vlm>=0.7.4,<0.8",
915
+ "--with",
916
+ "huggingface_hub",
917
+ "python",
918
+ "clef_mlx.py",
919
+ "serve",
920
+ "--host",
921
+ this.host,
922
+ "--port",
923
+ String(port)
924
+ ];
925
+ this.opts.log?.debug("spawning clef_mlx.py serve", { uv: this.opts.uvBin, snapshot: this.opts.snapshotPath });
926
+ let proc;
927
+ try {
928
+ proc = spawn2(this.opts.uvBin, args, { stdio: ["ignore", "pipe", "pipe"], cwd: this.opts.snapshotPath });
929
+ } catch (err) {
930
+ throw new ClefError(
931
+ ClefErrorCode.RUNTIME_INIT_FAILED,
932
+ `Failed to start the MLX server: ${err instanceof Error ? err.message : String(err)}`,
933
+ "Run `clef-mcp doctor` for a full diagnosis."
934
+ );
935
+ }
936
+ this.proc = proc;
937
+ let stderrTail = "";
938
+ proc.stderr?.on("data", (chunk) => {
939
+ const text = chunk.toString();
940
+ stderrTail = (stderrTail + text).slice(-STDERR_TAIL_LIMIT2);
941
+ this.opts.log?.debug(text.trimEnd());
942
+ });
943
+ proc.stdout?.on("data", (chunk) => {
944
+ this.opts.log?.debug(chunk.toString().trimEnd());
945
+ });
946
+ let rejectOnExit;
947
+ const exitedBeforeReady = new Promise((_resolve, reject) => {
948
+ rejectOnExit = reject;
949
+ });
950
+ const onEarlyExit = (code, signal) => {
951
+ const oom = OOM_PATTERNS2.some((p) => stderrTail.toLowerCase().includes(p));
952
+ const message = `clef_mlx.py exited before becoming ready (code=${code ?? "null"}, signal=${signal ?? "null"})`;
953
+ const tail = stderrTail.trim();
954
+ rejectOnExit?.(
955
+ oom ? new ClefError(ClefErrorCode.OUT_OF_MEMORY, message, "Not enough free memory for this model. Try the llama.cpp runtime with Q4_K_M instead.") : new ClefError(ClefErrorCode.RUNTIME_INIT_FAILED, message + (tail ? `
956
+ stderr tail:
957
+ ${tail}` : ""), "Run `clef-mcp doctor` for a full diagnosis.")
958
+ );
959
+ };
960
+ proc.once("exit", onEarlyExit);
961
+ try {
962
+ await Promise.race([this.waitUntilReady(), exitedBeforeReady]);
963
+ } finally {
964
+ proc.removeListener("exit", onEarlyExit);
965
+ }
966
+ }
967
+ async waitUntilReady() {
968
+ const deadline = Date.now() + this.loadTimeoutMs;
969
+ let lastError = "";
970
+ while (Date.now() < deadline) {
971
+ if (!this.running) throw new ClefError(ClefErrorCode.RUNTIME_INIT_FAILED, "MLX server is not running");
972
+ try {
973
+ const res = await fetchWithTimeout2(`${this.endpoint}/health`, { method: "GET" }, 2e3);
974
+ if (res.status === 200) return;
975
+ lastError = `health status ${res.status}`;
976
+ } catch (err) {
977
+ lastError = err instanceof Error ? err.message : String(err);
978
+ }
979
+ await new Promise((r) => setTimeout(r, 250));
980
+ }
981
+ throw new ClefError(
982
+ ClefErrorCode.MODEL_LOAD_FAILED,
983
+ `MLX server did not become ready within ${Math.round(this.loadTimeoutMs / 1e3)}s (last health check: ${lastError})`,
984
+ "The first load can take a while; subsequent loads reuse the snapshot cache."
985
+ );
986
+ }
987
+ async decide(input) {
988
+ const run = this.queue.then(() => this.decideOnce(input));
989
+ this.queue = run.catch(() => void 0);
990
+ return run;
991
+ }
992
+ async decideOnce(input) {
993
+ if (!this.running) {
994
+ throw new ClefError(ClefErrorCode.RUNTIME_INIT_FAILED, "Runtime is not loaded; call load() first");
995
+ }
996
+ let res;
997
+ try {
998
+ res = await fetchWithTimeout2(
999
+ `${this.endpoint}/v1/systemone`,
1000
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) },
1001
+ this.requestTimeoutMs
1002
+ );
1003
+ } catch (err) {
1004
+ const msg = err instanceof Error ? err.message : String(err);
1005
+ throw new ClefError(ClefErrorCode.CLEF_INFERENCE_FAILED, `Inference request failed: ${msg}`);
1006
+ }
1007
+ const bodyText = await res.text();
1008
+ let body = void 0;
1009
+ try {
1010
+ body = bodyText ? JSON.parse(bodyText) : void 0;
1011
+ } catch {
1012
+ }
1013
+ if (!res.ok) {
1014
+ const apiMessage = body && typeof body === "object" && "error" in body ? String(body.error?.message ?? bodyText) : bodyText;
1015
+ throw new ClefError(ClefErrorCode.CLEF_INFERENCE_FAILED, `Inference failed with HTTP ${res.status}: ${apiMessage.slice(0, 2e3)}`);
1016
+ }
1017
+ if (!body || typeof body !== "object") {
1018
+ throw new ClefError(ClefErrorCode.CLEF_INFERENCE_FAILED, `Runtime returned a non-JSON response (HTTP ${res.status})`);
1019
+ }
1020
+ return body;
1021
+ }
1022
+ async health() {
1023
+ if (!this.running) return { status: "unavailable", detail: "not started" };
1024
+ try {
1025
+ const res = await fetchWithTimeout2(`${this.endpoint}/health`, { method: "GET" }, 1500);
1026
+ if (res.status === 200) return { status: "ready" };
1027
+ return { status: "loading", detail: `health status ${res.status}` };
1028
+ } catch (err) {
1029
+ return { status: "unavailable", detail: err instanceof Error ? err.message : String(err) };
1030
+ }
1031
+ }
1032
+ async unload() {
1033
+ const proc = this.proc;
1034
+ if (!proc) return;
1035
+ this.proc = void 0;
1036
+ if (proc.exitCode !== null || proc.signalCode) return;
1037
+ await new Promise((resolve) => {
1038
+ const killTimer = setTimeout(() => {
1039
+ try {
1040
+ proc.kill("SIGKILL");
1041
+ } catch {
1042
+ }
1043
+ }, 3e3);
1044
+ proc.once("exit", () => {
1045
+ clearTimeout(killTimer);
1046
+ resolve();
1047
+ });
1048
+ try {
1049
+ proc.kill("SIGTERM");
1050
+ } catch {
1051
+ clearTimeout(killTimer);
1052
+ resolve();
1053
+ }
1054
+ });
1055
+ }
1056
+ };
1057
+
1058
+ // src/runtime/probe.ts
804
1059
  async function probeInference(cfg, manifest, log, loadTimeoutMs = 12e4) {
805
1060
  await fs3.access(manifest.modelPath).catch(() => {
806
1061
  throw new ClefError(
@@ -835,8 +1090,8 @@ async function probeInference(cfg, manifest, log, loadTimeoutMs = 12e4) {
835
1090
 
836
1091
  // src/models/hf.ts
837
1092
  import { createHash as createHash2 } from "crypto";
838
- import fsp2 from "fs/promises";
839
- import path5 from "path";
1093
+ import fsp3 from "fs/promises";
1094
+ import path6 from "path";
840
1095
  var HF_BASE_URL = "https://huggingface.co";
841
1096
  function resolveUrl(repo, revision, file) {
842
1097
  return `${HF_BASE_URL}/${repo}/resolve/${revision}/${file}`;
@@ -894,7 +1149,7 @@ async function downloadOnce(url, dest, opts) {
894
1149
  }
895
1150
  const total = opts.expectedSize ?? (res.headers.get("content-length") ? Number(res.headers.get("content-length")) : void 0);
896
1151
  const hash = createHash2("sha256");
897
- const handle = await fsp2.open(dest, "w");
1152
+ const handle = await fsp3.open(dest, "w");
898
1153
  let bytes = 0;
899
1154
  try {
900
1155
  for await (const chunk of res.body) {
@@ -909,7 +1164,7 @@ async function downloadOnce(url, dest, opts) {
909
1164
  }
910
1165
  const sha256 = hash.digest("hex");
911
1166
  if (opts.expectedSha256 && sha256 !== opts.expectedSha256) {
912
- await fsp2.rm(dest, { force: true });
1167
+ await fsp3.rm(dest, { force: true });
913
1168
  throw new ClefError(
914
1169
  ClefErrorCode.CHECKSUM_MISMATCH,
915
1170
  `Checksum mismatch after download: expected ${opts.expectedSha256}, got ${sha256}`,
@@ -917,7 +1172,7 @@ async function downloadOnce(url, dest, opts) {
917
1172
  );
918
1173
  }
919
1174
  if (opts.expectedSize !== void 0 && bytes !== opts.expectedSize) {
920
- await fsp2.rm(dest, { force: true });
1175
+ await fsp3.rm(dest, { force: true });
921
1176
  throw new ClefError(
922
1177
  ClefErrorCode.DOWNLOAD_FAILED,
923
1178
  `Downloaded size mismatch: expected ${opts.expectedSize} bytes, got ${bytes}`
@@ -928,24 +1183,24 @@ async function downloadOnce(url, dest, opts) {
928
1183
  async function installModel(opts) {
929
1184
  const { modelSpec, quantSpec } = opts;
930
1185
  const { modelsDir } = pathsFor(opts.clefHome);
931
- const manifestPath = manifestPathFor(modelsDir, modelSpec.id, quantSpec.id);
932
- const existing = await readModelManifest(manifestPath);
1186
+ const manifestPath2 = manifestPathFor(modelsDir, modelSpec.id, quantSpec.id);
1187
+ const existing = await readModelManifest(manifestPath2);
933
1188
  if (existing && (await verifyModelFiles(existing)).ok) {
934
1189
  opts.log?.debug("model already installed", { model: modelSpec.id, quant: quantSpec.id });
935
1190
  return { manifest: existing, alreadyInstalled: true };
936
1191
  }
937
1192
  const info = await fetchFileInfo(modelSpec.repo, modelSpec.revision, quantSpec.file);
938
- const finalPath = path5.join(path5.dirname(manifestPath), quantSpec.file);
939
- await fsp2.mkdir(path5.dirname(finalPath), { recursive: true });
940
- const tmpPath = path5.join(path5.dirname(finalPath), `.download-${quantSpec.file}`);
941
- await fsp2.rm(tmpPath, { force: true });
1193
+ const finalPath = path6.join(path6.dirname(manifestPath2), quantSpec.file);
1194
+ await fsp3.mkdir(path6.dirname(finalPath), { recursive: true });
1195
+ const tmpPath = path6.join(path6.dirname(finalPath), `.download-${quantSpec.file}`);
1196
+ await fsp3.rm(tmpPath, { force: true });
942
1197
  opts.log?.info("downloading model", { repo: modelSpec.repo, file: quantSpec.file });
943
1198
  const result = await downloadFile(info.downloadUrl, tmpPath, {
944
1199
  expectedSha256: info.sha256,
945
1200
  expectedSize: info.size ?? quantSpec.bytes,
946
1201
  onProgress: opts.onProgress
947
1202
  });
948
- await fsp2.rename(tmpPath, finalPath);
1203
+ await fsp3.rename(tmpPath, finalPath);
949
1204
  const manifest = {
950
1205
  schemaVersion: 1,
951
1206
  model: modelSpec.id,
@@ -961,15 +1216,15 @@ async function installModel(opts) {
961
1216
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
962
1217
  clefMcpVersion: opts.clefMcpVersion
963
1218
  };
964
- await writeModelManifest(manifestPath, manifest);
1219
+ await writeModelManifest(manifestPath2, manifest);
965
1220
  opts.log?.info("model installed", { model: modelSpec.id, quant: quantSpec.id, sha256: result.sha256 });
966
1221
  return { manifest, alreadyInstalled: false };
967
1222
  }
968
1223
 
969
1224
  // src/models/llama-install.ts
970
- import { execFile } from "child_process";
971
- import fsp3 from "fs/promises";
972
- import path6 from "path";
1225
+ import { execFile as execFile2 } from "child_process";
1226
+ import fsp4 from "fs/promises";
1227
+ import path7 from "path";
973
1228
  var GITHUB_API = "https://api.github.com/repos/ggml-org/llama.cpp";
974
1229
  async function ghJson(url) {
975
1230
  let res;
@@ -1023,7 +1278,7 @@ function pickAsset(release, platform, arch) {
1023
1278
  }
1024
1279
  function execTar(args, cwd) {
1025
1280
  return new Promise((resolve, reject) => {
1026
- execFile("tar", args, { cwd, timeout: 3e5 }, (err, _stdout, stderr) => {
1281
+ execFile2("tar", args, { cwd, timeout: 3e5 }, (err, _stdout, stderr) => {
1027
1282
  if (err) reject(new Error(`tar ${args.join(" ")} failed: ${stderr || err.message}`));
1028
1283
  else resolve();
1029
1284
  });
@@ -1036,12 +1291,12 @@ async function findServerBinary(dir, platform) {
1036
1291
  const current = queue.shift();
1037
1292
  let entries;
1038
1293
  try {
1039
- entries = await fsp3.readdir(current, { withFileTypes: true });
1294
+ entries = await fsp4.readdir(current, { withFileTypes: true });
1040
1295
  } catch {
1041
1296
  continue;
1042
1297
  }
1043
1298
  for (const entry of entries) {
1044
- const full = path6.join(current, entry.name);
1299
+ const full = path7.join(current, entry.name);
1045
1300
  if (entry.isDirectory()) queue.push(full);
1046
1301
  else if (entry.name === target) return full;
1047
1302
  }
@@ -1052,14 +1307,14 @@ async function findServerBinary(dir, platform) {
1052
1307
  );
1053
1308
  }
1054
1309
  async function extractRelease(archivePath, destDir, platform) {
1055
- await fsp3.mkdir(destDir, { recursive: true });
1310
+ await fsp4.mkdir(destDir, { recursive: true });
1056
1311
  const args = archivePath.endsWith(".zip") ? ["-xf", archivePath] : ["-xzf", archivePath];
1057
1312
  try {
1058
1313
  await execTar(args, destDir);
1059
1314
  } catch (err) {
1060
1315
  throw new ClefError(
1061
1316
  ClefErrorCode.DOWNLOAD_FAILED,
1062
- `Could not extract ${path6.basename(archivePath)}: ${err instanceof Error ? err.message : String(err)}`,
1317
+ `Could not extract ${path7.basename(archivePath)}: ${err instanceof Error ? err.message : String(err)}`,
1063
1318
  "Extract the archive manually and point CLEF_LLAMA_BIN at llama-server."
1064
1319
  );
1065
1320
  }
@@ -1069,7 +1324,7 @@ async function ensureLlamaRuntime(cfg, log) {
1069
1324
  const resolved = resolveLlamaServerBinary(cfg);
1070
1325
  if (resolved.source === "managed") {
1071
1326
  const platformDir = llamaRuntimeDir(pathsFor(cfg.clefHome).runtimeDir, process.platform, process.arch);
1072
- const manifest = readRuntimeManifest(path6.join(platformDir, "runtime-manifest.json"));
1327
+ const manifest = readRuntimeManifest(path7.join(platformDir, "runtime-manifest.json"));
1073
1328
  return { ...resolved, releaseTag: manifest?.releaseTag ?? "unknown", freshInstall: false };
1074
1329
  }
1075
1330
  return { ...resolved, releaseTag: resolved.source, freshInstall: false };
@@ -1101,16 +1356,16 @@ async function downloadManagedLlama(cfg, log) {
1101
1356
  }
1102
1357
  const asset = pickAsset(release, platform, arch);
1103
1358
  log?.info("downloading llama.cpp runtime", { tag: release.tag_name, asset: asset.name });
1104
- const tagDir = path6.join(platformDir, release.tag_name);
1105
- await fsp3.mkdir(tagDir, { recursive: true });
1106
- const tmpArchive = path6.join(platformDir, `.download-${asset.name}`);
1107
- await fsp3.rm(tmpArchive, { force: true });
1359
+ const tagDir = path7.join(platformDir, release.tag_name);
1360
+ await fsp4.mkdir(tagDir, { recursive: true });
1361
+ const tmpArchive = path7.join(platformDir, `.download-${asset.name}`);
1362
+ await fsp4.rm(tmpArchive, { force: true });
1108
1363
  const result = await downloadFile(asset.browser_download_url, tmpArchive, { expectedSize: asset.size, attempts: 3 });
1109
1364
  await extractRelease(tmpArchive, tagDir, platform);
1110
- await fsp3.rm(tmpArchive, { force: true });
1365
+ await fsp4.rm(tmpArchive, { force: true });
1111
1366
  const binaryPath = await findServerBinary(tagDir, platform);
1112
- if (platform !== "win32") await fsp3.chmod(binaryPath, 493);
1113
- await writeRuntimeManifest(path6.join(platformDir, "runtime-manifest.json"), {
1367
+ if (platform !== "win32") await fsp4.chmod(binaryPath, 493);
1368
+ await writeRuntimeManifest(path7.join(platformDir, "runtime-manifest.json"), {
1114
1369
  schemaVersion: 1,
1115
1370
  kind: "llama-cpp",
1116
1371
  releaseTag: release.tag_name,
@@ -1126,20 +1381,20 @@ async function removeManagedRuntime(cfg) {
1126
1381
  const { runtimeDir } = pathsFor(cfg.clefHome);
1127
1382
  const dir = llamaRuntimeDir(runtimeDir, process.platform, process.arch);
1128
1383
  try {
1129
- await fsp3.stat(dir);
1384
+ await fsp4.stat(dir);
1130
1385
  } catch {
1131
1386
  return false;
1132
1387
  }
1133
- await fsp3.rm(dir, { recursive: true, force: true });
1388
+ await fsp4.rm(dir, { recursive: true, force: true });
1134
1389
  return true;
1135
1390
  }
1136
1391
 
1137
1392
  // src/models/platform.ts
1138
- import { execFile as execFile2 } from "child_process";
1393
+ import { execFile as execFile3 } from "child_process";
1139
1394
  import os2 from "os";
1140
1395
  function exec(cmd, args, timeoutMs) {
1141
1396
  return new Promise((resolve, reject) => {
1142
- execFile2(cmd, args, { timeout: timeoutMs, encoding: "utf8" }, (err, stdout) => {
1397
+ execFile3(cmd, args, { timeout: timeoutMs, encoding: "utf8" }, (err, stdout) => {
1143
1398
  if (err) reject(err);
1144
1399
  else resolve(stdout);
1145
1400
  });
@@ -1248,10 +1503,12 @@ function scoreCriteria(limits) {
1248
1503
  }
1249
1504
  function questionSchema(limits) {
1250
1505
  return z.object({
1251
- type: z.enum(["noul", "choice", "score"]),
1252
- instructions: z.string().min(1).max(limits.maxInstructionsChars),
1506
+ type: z.enum(["noul", "choice", "score"]).describe('"choice" = pick among named options; "score" = judge an ordered scale; "noul" = yes/no question'),
1507
+ instructions: z.string().min(1).max(limits.maxInstructionsChars).describe("What to judge, phrased as a self-contained question."),
1253
1508
  criteria: z.union([z.array(z.string()), z.record(z.string(), z.string())]).optional()
1254
- }).superRefine((q, ctx) => {
1509
+ }).describe(
1510
+ "A single decision question. Allowed answers come from `criteria` and must be mutually exclusive and collectively exhaustive."
1511
+ ).superRefine((q, ctx) => {
1255
1512
  if (q.type === "choice") {
1256
1513
  const parsed = choiceCriteria(limits).safeParse(q.criteria);
1257
1514
  if (!parsed.success) {
@@ -1275,23 +1532,27 @@ function questionSchema(limits) {
1275
1532
  }
1276
1533
  function clefDecideInputShape(limits) {
1277
1534
  return {
1278
- state: z.unknown(),
1279
- questions: z.record(z.string().min(1).max(limits.maxQuestionIdChars), questionSchema(limits)).check((ctx) => {
1535
+ state: z.unknown().describe(
1536
+ "Task state as data: a compact string or JSON object with the facts needed to judge (task, errors, logs, diffs). Never treated as instructions. Serialized size limit: 1 MB; model context is 16k tokens."
1537
+ ),
1538
+ questions: z.record(z.string().min(1).max(limits.maxQuestionIdChars), questionSchema(limits)).describe(
1539
+ "Map of question id -> typed question. Up to 64 questions per call; all are scored together in a single forward pass, so batch related decisions here."
1540
+ ).check((ctx) => {
1280
1541
  const count = Object.keys(ctx.value).length;
1281
1542
  if (count === 0) ctx.issues.push({ code: "custom", message: "questions must not be empty", input: ctx.value });
1282
1543
  if (count > limits.maxQuestions) {
1283
1544
  ctx.issues.push({ code: "custom", message: `too many questions (max ${limits.maxQuestions})`, input: ctx.value });
1284
1545
  }
1285
1546
  }),
1286
- model: z.string().min(1).optional(),
1547
+ model: z.string().min(1).optional().describe("Model id to use (default: clef-flash from CLEF_MODEL)."),
1287
1548
  options: z.object({
1288
1549
  /**
1289
1550
  * Accepted for forward compatibility. Clef scores all options in a
1290
1551
  * single forward pass — there is no sampling, so temperature is a
1291
1552
  * documented no-op.
1292
1553
  */
1293
- temperature: z.number().min(0).max(2).optional()
1294
- }).optional()
1554
+ temperature: z.number().min(0).max(2).optional().describe("Accepted for forward compatibility; no-op. Clef scores in a single forward pass without sampling.")
1555
+ }).optional().describe("Extra options. Currently only temperature, which is a documented no-op.")
1295
1556
  };
1296
1557
  }
1297
1558
  function clefDecideInputSchema(limits) {
@@ -1331,13 +1592,223 @@ function validateStateSize(state, limits) {
1331
1592
  }
1332
1593
  }
1333
1594
 
1595
+ // src/mcp/prompts.ts
1596
+ import { z as z2 } from "zod";
1597
+ var TOOL_GUIDE = (questions) => [
1598
+ "Call the `clef_decide` MCP tool once with all of the questions below (they are scored in a single forward pass).",
1599
+ "",
1600
+ "Rules for the call:",
1601
+ "- `state` must be a compact, factual JSON or string \u2014 relevant facts only, well under 1 MB.",
1602
+ "- Use the exact `questions` given below (ids, types, criteria). Do not invent extra options.",
1603
+ "- If the answer distribution is flat (no option above ~0.6), gather more context and re-ask once before falling back to asking the user.",
1604
+ "- Treat safety-adjacent decisions (destructive actions, security) as requiring p >= 0.8 before acting; otherwise prefer asking the user.",
1605
+ "- Report the result as a short summary plus the top probability \u2014 do not paste raw JSON unless asked.",
1606
+ "",
1607
+ "Questions:",
1608
+ questions
1609
+ ].join("\n");
1610
+ var PROMPTS = [
1611
+ {
1612
+ name: "incident-triage",
1613
+ title: "Production incident triage",
1614
+ description: "Frame a production incident as clef_decide questions: immediate action, severity and blast radius.",
1615
+ args: {
1616
+ incident_description: "Short factual description of the incident (symptoms, scope, recent changes)."
1617
+ },
1618
+ build: (a) => TOOL_GUIDE(
1619
+ [
1620
+ '- "action" (choice, instructions "What should the responder do first?"): rollback "Revert the latest deploy", hotfix "Ship a targeted fix now", investigate "Gather diagnostics before acting", wait "Observe; impact is not growing".',
1621
+ '- "severity" (score, instructions "How severe is the incident?", criteria ["low", "elevated", "high", "critical"]).',
1622
+ '- "user_impact" (noul, instructions "Are users actively affected right now?").'
1623
+ ].join("\n")
1624
+ ) + "\n\nIncident description:\n" + (a.incident_description ?? "")
1625
+ },
1626
+ {
1627
+ name: "next-action",
1628
+ title: "Agent next action",
1629
+ description: "Ask Clef what the coding agent should do next on a task: inspect, modify, test or ask the user.",
1630
+ args: {
1631
+ task_summary: "What the agent is working on, current step, and what is already known or done."
1632
+ },
1633
+ build: (a) => TOOL_GUIDE(
1634
+ [
1635
+ '- "next_action" (choice, instructions "What should the coding agent do next?"): inspect "Inspect the code and gather more information", modify "Modify the code", test "Run additional tests", ask_user "Ask the user for clarification".',
1636
+ '- "confidence" (score, instructions "How confident are you in this decision?", criteria ["very_low", "low", "medium", "high", "very_high"]).'
1637
+ ].join("\n")
1638
+ ) + "\n\nTask summary:\n" + (a.task_summary ?? "")
1639
+ },
1640
+ {
1641
+ name: "ticket-routing",
1642
+ title: "Support ticket routing",
1643
+ description: "Classify an incoming message into the team that should handle it.",
1644
+ args: {
1645
+ ticket_text: "The customer or user message, verbatim if possible."
1646
+ },
1647
+ build: (a) => TOOL_GUIDE(
1648
+ [
1649
+ '- "department" (choice, instructions "Which team should handle the message?"): billing "Payments, invoices and refunds", technical "Bugs, errors and outages", sales "Pricing and new purchases", other "Anything else".',
1650
+ '- "urgent" (noul, instructions "Does this need a response within the hour?").'
1651
+ ].join("\n")
1652
+ ) + "\n\nTicket text:\n" + (a.ticket_text ?? "")
1653
+ },
1654
+ {
1655
+ name: "security-review",
1656
+ title: "Security sanity check",
1657
+ description: "Ask Clef whether a code snippet, change or setup smells like a security problem.",
1658
+ args: {
1659
+ change_summary: "The code, config or setup to review \u2014 compact but complete enough to judge."
1660
+ },
1661
+ build: (a) => TOOL_GUIDE(
1662
+ [
1663
+ '- "is_vulnerable" (noul, instructions "Does this contain a realistic security vulnerability?").',
1664
+ '- "risk" (score, instructions "How bad is it if this ships as-is?", criteria ["informational", "low", "medium", "high", "critical"]).',
1665
+ '- "first_mitigation" (choice, instructions "What is the first mitigation?"): fix_code "Fix the code before merging", restrict "Restrict access or add guardrails", monitor "Add detection and monitoring", none "No mitigation needed".'
1666
+ ].join("\n") + "\nSafety rule: only act without human review when p(is_vulnerable=false) >= 0.8."
1667
+ ) + "\n\nChange to review:\n" + (a.change_summary ?? "")
1668
+ }
1669
+ ];
1670
+ function registerPrompts(server) {
1671
+ for (const p of PROMPTS) {
1672
+ server.registerPrompt(
1673
+ p.name,
1674
+ {
1675
+ title: p.title,
1676
+ description: p.description,
1677
+ argsSchema: Object.fromEntries(Object.entries(p.args).map(([k, v]) => [k, z2.string().min(1).describe(v)]))
1678
+ },
1679
+ ({ ...args }) => {
1680
+ for (const key of Object.keys(p.args)) {
1681
+ if (typeof args[key] !== "string" || !args[key]) {
1682
+ throw new ClefError(ClefErrorCode.INVALID_INPUT, `Prompt argument "${key}" is required`);
1683
+ }
1684
+ }
1685
+ return {
1686
+ messages: [{ role: "user", content: { type: "text", text: p.build(args) } }]
1687
+ };
1688
+ }
1689
+ );
1690
+ }
1691
+ }
1692
+
1693
+ // src/mcp/resources.ts
1694
+ import fsSync3 from "fs";
1695
+ import fsp6 from "fs/promises";
1696
+
1697
+ // src/cli/evals.ts
1698
+ import fsp5 from "fs/promises";
1699
+ import fsSync2 from "fs";
1700
+ import { fileURLToPath } from "url";
1701
+ import path8 from "path";
1702
+ function defaultDatasetPath() {
1703
+ let dir = path8.dirname(fileURLToPath(import.meta.url));
1704
+ for (let i = 0; i < 6; i++) {
1705
+ const candidate = path8.join(dir, "tests", "evals", "dataset.jsonl");
1706
+ if (fsSync2.existsSync(candidate)) return candidate;
1707
+ dir = path8.dirname(dir);
1708
+ }
1709
+ return fileURLToPath(new URL("../tests/evals/dataset.jsonl", import.meta.url));
1710
+ }
1711
+
1712
+ // src/mcp/resources.ts
1713
+ var EVALS_SCHEMA_MD = `# clef-mcp eval dataset format
1714
+
1715
+ JSONL: one JSON object per line.
1716
+
1717
+ {
1718
+ "id": "unique-case-id",
1719
+ "category": "coding | security | classification | routing | yes_no",
1720
+ "state": <string | JSON value>,
1721
+ "question": {
1722
+ "id": "question-id",
1723
+ "type": "choice | score | noul",
1724
+ "instructions": "\u2026",
1725
+ "criteria": { "option": "description" } // choice (or ["a","b"] list)
1726
+ | ["low", "high"] // score, ordered
1727
+ },
1728
+ "expected": {
1729
+ "kind": "argmax | prob_gt | min_prob | top_mass",
1730
+ // argmax: { "expect": "option", "among": ["option", ...] (optional) }
1731
+ // prob_gt: { "a": "opt", "b": "opt" }
1732
+ // min_prob: { "option": "opt", "min": 0.7 }
1733
+ // top_mass: { "options": ["a","b"], "min": 0.8 }
1734
+ }
1735
+ }
1736
+
1737
+ Run: \`clef-mcp evals\` (requires an installed model).
1738
+ `;
1739
+ async function readDataset() {
1740
+ try {
1741
+ return await fsp6.readFile(defaultDatasetPath(), "utf8");
1742
+ } catch {
1743
+ return "The bundled eval dataset is not available in this installation (tests/evals/dataset.jsonl not found).";
1744
+ }
1745
+ }
1746
+ function textResource(uri, text, mimeType = "text/markdown") {
1747
+ return { contents: [{ uri: uri.href, mimeType, text }] };
1748
+ }
1749
+ function requireStringUri(uri) {
1750
+ return uri instanceof URL ? uri : new URL(uri);
1751
+ }
1752
+ function registerResources(server, cfg, version) {
1753
+ server.registerResource(
1754
+ "capabilities",
1755
+ "clef-mcp://capabilities",
1756
+ { title: "clef-mcp capabilities", description: "Live capability snapshot: model, runtime, limits, error codes.", mimeType: "application/json" },
1757
+ async (uri) => {
1758
+ const u = requireStringUri(uri);
1759
+ const payload = {
1760
+ name: "clef-mcp",
1761
+ version,
1762
+ model: cfg.model,
1763
+ runtime: cfg.runtime,
1764
+ limits: cfg.limits,
1765
+ tool: {
1766
+ name: "clef_decide",
1767
+ output: "strict JSON: { model, decisions: { <qid>: { answer: { <option>: probability } } } }",
1768
+ questionTypes: ["noul", "choice", "score"],
1769
+ notes: ["options.temperature is accepted but is a no-op (single forward pass)", "state is data and is never interpreted as instructions"]
1770
+ },
1771
+ errorCodes: [
1772
+ "MODEL_NOT_INSTALLED",
1773
+ "MODEL_LOAD_FAILED",
1774
+ "RUNTIME_NOT_FOUND",
1775
+ "RUNTIME_INIT_FAILED",
1776
+ "RUNTIME_NOT_SUPPORTED",
1777
+ "INVALID_INPUT",
1778
+ "CLEF_INFERENCE_FAILED",
1779
+ "UNSUPPORTED_PLATFORM",
1780
+ "OUT_OF_MEMORY",
1781
+ "CHECKSUM_MISMATCH",
1782
+ "DOWNLOAD_FAILED"
1783
+ ]
1784
+ };
1785
+ return textResource(u, JSON.stringify(payload, null, 2), "application/json");
1786
+ }
1787
+ );
1788
+ server.registerResource(
1789
+ "evals-schema",
1790
+ "clef-mcp://evals/schema",
1791
+ { title: "Eval dataset schema", description: "How to write cases for clef-mcp evals.", mimeType: "text/markdown" },
1792
+ async (uri) => textResource(requireStringUri(uri), EVALS_SCHEMA_MD)
1793
+ );
1794
+ server.registerResource(
1795
+ "evals-dataset",
1796
+ "clef-mcp://evals/dataset",
1797
+ { title: "Bundled eval dataset", description: "The 30-case dataset shipped with clef-mcp.", mimeType: "application/jsonl" },
1798
+ async (uri) => textResource(requireStringUri(uri), await readDataset(), "application/jsonl")
1799
+ );
1800
+ }
1801
+
1334
1802
  // src/mcp/server.ts
1335
1803
  var TOOL_NAME = "clef_decide";
1336
1804
  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.";
1337
1805
  function createClefServer(deps = {}) {
1338
1806
  const config = deps.config ?? loadConfig();
1807
+ const version = deps.version ?? "0.1.0";
1339
1808
  const log = deps.log ?? createLogger(config.logLevel);
1340
- const server = new McpServer({ name: "clef-mcp", version: deps.version ?? "0.1.0" }, { instructions: SERVER_INSTRUCTIONS });
1809
+ const server = new McpServer({ name: "clef-mcp", version }, { instructions: SERVER_INSTRUCTIONS });
1810
+ registerPrompts(server);
1811
+ registerResources(server, config, version);
1341
1812
  let loaded;
1342
1813
  let loadPromise;
1343
1814
  async function ensureRuntime(spec) {
@@ -1348,6 +1819,23 @@ function createClefServer(deps = {}) {
1348
1819
  }
1349
1820
  if (loadPromise) return loadPromise;
1350
1821
  loadPromise = (async () => {
1822
+ if (config.runtime === "mlx") {
1823
+ const manifest2 = await mlxIsInstalled(config.clefHome);
1824
+ if (!manifest2) {
1825
+ throw new ClefError(
1826
+ ClefErrorCode.MODEL_NOT_INSTALLED,
1827
+ `Clef model "${spec.id}" is not installed for the MLX runtime.`,
1828
+ "Run `clef-mcp install --runtime mlx`."
1829
+ );
1830
+ }
1831
+ const uvBin = resolveUv(process.env.CLEF_MLX_UV);
1832
+ const runtime2 = deps.runtimeFactory ? deps.runtimeFactory({ kind: "mlx", snapshotPath: manifest2.snapshotPath, alias: spec.id, log }) : new MlxRuntime({ uvBin, snapshotPath: manifest2.snapshotPath, log });
1833
+ log.debug("loading model (mlx)", { model: spec.id, snapshot: manifest2.snapshotPath });
1834
+ const loadStarted2 = Date.now();
1835
+ await runtime2.load();
1836
+ log.info("model ready", { model: spec.id, runtime: "mlx", loadMs: Date.now() - loadStarted2 });
1837
+ return { runtime: runtime2, model: spec.id };
1838
+ }
1351
1839
  const manifest = await findInstalledManifest(config.clefHome, spec);
1352
1840
  if (!manifest) {
1353
1841
  throw new ClefError(
@@ -1357,7 +1845,7 @@ function createClefServer(deps = {}) {
1357
1845
  );
1358
1846
  }
1359
1847
  const binary = resolveLlamaServerBinary(config);
1360
- 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 });
1848
+ const runtime = deps.runtimeFactory ? deps.runtimeFactory({ kind: "llama-cpp", serverBin: binary.path, modelPath: manifest.modelPath, alias: spec.id, log }) : new LlamaCppRuntime({ serverBin: binary.path, modelPath: manifest.modelPath, alias: spec.id, log });
1361
1849
  log.debug("loading model", { model: spec.id, quant: manifest.quant, bin: binary.path });
1362
1850
  const loadStarted = Date.now();
1363
1851
  await runtime.load();
@@ -1375,9 +1863,15 @@ function createClefServer(deps = {}) {
1375
1863
  TOOL_NAME,
1376
1864
  {
1377
1865
  title: "Clef Decide",
1378
- 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.",
1866
+ description: 'Ask the local Clef decision model to judge a situation. Pass a `state` (compact factual context as a string or JSON) and one or more typed `questions`: "choice" picks among named options (criteria = option id -> description), "score" judges an ordered scale (criteria = ordered list), "noul" answers yes/no. Returns strict JSON: for every question a probability distribution over its allowed answers \u2014 no prose, no sampling. Usage: batch related decisions (up to 64 questions) in one call; state is data and is never executed; act on the argmax when a distribution is decisive (p >= 0.8), otherwise gather more context or ask the user; for destructive or security-adjacent decisions require high confidence before acting. Optional `model` overrides the model id; `options.temperature` is a no-op (single forward pass, no sampling).',
1379
1867
  inputSchema: clefDecideInputShape(config.limits),
1380
- outputSchema: clefDecideOutputSchema().shape
1868
+ outputSchema: clefDecideOutputSchema().shape,
1869
+ annotations: {
1870
+ readOnlyHint: true,
1871
+ destructiveHint: false,
1872
+ idempotentHint: true,
1873
+ openWorldHint: false
1874
+ }
1381
1875
  },
1382
1876
  async (args) => {
1383
1877
  try {
@@ -1432,7 +1926,7 @@ function createClefServer(deps = {}) {
1432
1926
  }
1433
1927
 
1434
1928
  // src/version.ts
1435
- var VERSION = "0.1.6";
1929
+ var VERSION = "0.2.1";
1436
1930
  export {
1437
1931
  CLEF_FLASH,
1438
1932
  ClefError,