clef-mcp 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -12
- package/dist/cli.js +705 -81
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/examples/classify/README.md +27 -0
- package/examples/classify/support-routing.yaml +18 -0
- package/examples/classify/tickets.csv +4 -0
- package/examples/classify/tickets.jsonl +3 -0
- package/examples/hooks/pretooluse-guard/README.md +39 -6
- package/examples/hooks/pretooluse-guard/guard.sh +6 -2
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -104,9 +104,9 @@ __export(setup_exports, {
|
|
|
104
104
|
});
|
|
105
105
|
import { execFile as execFile5 } from "child_process";
|
|
106
106
|
import fsSync4 from "fs";
|
|
107
|
-
import
|
|
108
|
-
import
|
|
109
|
-
import
|
|
107
|
+
import fsp13 from "fs/promises";
|
|
108
|
+
import os5 from "os";
|
|
109
|
+
import path12 from "path";
|
|
110
110
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
111
111
|
function bundledSkillPath() {
|
|
112
112
|
return fileURLToPath2(new URL("../skills/clef-decisions/SKILL.md", import.meta.url));
|
|
@@ -114,7 +114,7 @@ function bundledSkillPath() {
|
|
|
114
114
|
async function mergeJsonServerEntry(file, serverKey, container) {
|
|
115
115
|
let doc = {};
|
|
116
116
|
try {
|
|
117
|
-
doc = JSON.parse(await
|
|
117
|
+
doc = JSON.parse(await fsp13.readFile(file, "utf8"));
|
|
118
118
|
} catch {
|
|
119
119
|
doc = {};
|
|
120
120
|
}
|
|
@@ -131,15 +131,15 @@ async function mergeJsonServerEntry(file, serverKey, container) {
|
|
|
131
131
|
servers[serverKey] = { command: SERVER_ENTRY.command, args: [...SERVER_ENTRY.args] };
|
|
132
132
|
doc.mcpServers = servers;
|
|
133
133
|
}
|
|
134
|
-
await
|
|
135
|
-
await
|
|
134
|
+
await fsp13.mkdir(path12.dirname(file), { recursive: true });
|
|
135
|
+
await fsp13.writeFile(file, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
136
136
|
return "configured";
|
|
137
137
|
}
|
|
138
138
|
async function setupCodex() {
|
|
139
|
-
const file =
|
|
139
|
+
const file = path12.join(HOME(), ".codex", "config.toml");
|
|
140
140
|
let content = "";
|
|
141
141
|
try {
|
|
142
|
-
content = await
|
|
142
|
+
content = await fsp13.readFile(file, "utf8");
|
|
143
143
|
} catch {
|
|
144
144
|
content = "";
|
|
145
145
|
}
|
|
@@ -147,24 +147,24 @@ async function setupCodex() {
|
|
|
147
147
|
return { client: "codex", status: "already-configured", detail: file };
|
|
148
148
|
}
|
|
149
149
|
const next = (content && !content.endsWith("\n") ? content + "\n" : content) + "\n" + CODEX_SECTION;
|
|
150
|
-
await
|
|
151
|
-
await
|
|
150
|
+
await fsp13.mkdir(path12.dirname(file), { recursive: true });
|
|
151
|
+
await fsp13.writeFile(file, next, "utf8");
|
|
152
152
|
return { client: "codex", status: "configured", detail: file };
|
|
153
153
|
}
|
|
154
154
|
async function setupZcode() {
|
|
155
|
-
const file =
|
|
155
|
+
const file = path12.join(HOME(), ".zcode", "cli", "config.json");
|
|
156
156
|
const status = await mergeJsonServerEntry(file, "clef-mcp", "mcp.servers");
|
|
157
157
|
return { client: "zcode", status, detail: file };
|
|
158
158
|
}
|
|
159
159
|
async function setupCursor() {
|
|
160
|
-
const file =
|
|
160
|
+
const file = path12.join(HOME(), ".cursor", "mcp.json");
|
|
161
161
|
const status = await mergeJsonServerEntry(file, "clef-mcp", "mcpServers");
|
|
162
162
|
return { client: "cursor", status, detail: file };
|
|
163
163
|
}
|
|
164
164
|
function whichCli(bin) {
|
|
165
|
-
const dirs = (process.env.PATH ?? "").split(
|
|
165
|
+
const dirs = (process.env.PATH ?? "").split(path12.delimiter).filter(Boolean);
|
|
166
166
|
for (const dir of dirs) {
|
|
167
|
-
const candidate =
|
|
167
|
+
const candidate = path12.join(dir, bin);
|
|
168
168
|
try {
|
|
169
169
|
fsSync4.accessSync(candidate, fsSync4.constants.X_OK);
|
|
170
170
|
return candidate;
|
|
@@ -200,19 +200,19 @@ async function setupClaudeCode() {
|
|
|
200
200
|
}
|
|
201
201
|
async function installSkill() {
|
|
202
202
|
const src = bundledSkillPath();
|
|
203
|
-
const destDir =
|
|
204
|
-
const dest =
|
|
203
|
+
const destDir = path12.join(HOME(), ".agents", "skills", "clef-decisions");
|
|
204
|
+
const dest = path12.join(destDir, "SKILL.md");
|
|
205
205
|
let content = "";
|
|
206
206
|
try {
|
|
207
|
-
content = await
|
|
207
|
+
content = await fsp13.readFile(dest, "utf8");
|
|
208
208
|
} catch {
|
|
209
209
|
}
|
|
210
|
-
const incoming = await
|
|
210
|
+
const incoming = await fsp13.readFile(src, "utf8");
|
|
211
211
|
if (content === incoming) {
|
|
212
212
|
return { client: "skill", status: "already-configured", detail: dest };
|
|
213
213
|
}
|
|
214
|
-
await
|
|
215
|
-
await
|
|
214
|
+
await fsp13.mkdir(destDir, { recursive: true });
|
|
215
|
+
await fsp13.writeFile(dest, incoming, "utf8");
|
|
216
216
|
return { client: "skill", status: "configured", detail: dest };
|
|
217
217
|
}
|
|
218
218
|
function parseClients(raw) {
|
|
@@ -281,7 +281,7 @@ var init_setup = __esm({
|
|
|
281
281
|
init_errors();
|
|
282
282
|
init_prompt();
|
|
283
283
|
SERVER_ENTRY = { command: "clef-mcp", args: [], type: "stdio" };
|
|
284
|
-
HOME = () =>
|
|
284
|
+
HOME = () => os5.homedir();
|
|
285
285
|
CODEX_SECTION = [
|
|
286
286
|
"[mcp_servers.clef-mcp]",
|
|
287
287
|
'command = "clef-mcp"',
|
|
@@ -492,6 +492,15 @@ var DEFAULT_LIMITS = {
|
|
|
492
492
|
maxCriteriaDescChars: 1e3,
|
|
493
493
|
maxQuestionIdChars: 128
|
|
494
494
|
};
|
|
495
|
+
var CLASSIFY_LIMITS = {
|
|
496
|
+
maxProfileBytes: 1024 * 1024,
|
|
497
|
+
maxRecordBytes: 1024 * 1024,
|
|
498
|
+
maxResultBytes: 1024 * 1024,
|
|
499
|
+
maxExamples: 20,
|
|
500
|
+
maxFields: 1e3,
|
|
501
|
+
maxDepth: 64,
|
|
502
|
+
maxMetadataBytes: 16 * 1024
|
|
503
|
+
};
|
|
495
504
|
function intFromEnv(env, key, fallback) {
|
|
496
505
|
const raw = env[key];
|
|
497
506
|
if (!raw?.trim()) return fallback;
|
|
@@ -947,21 +956,21 @@ function whichBin(name, platform) {
|
|
|
947
956
|
}
|
|
948
957
|
return void 0;
|
|
949
958
|
}
|
|
950
|
-
function managedBinary(clefHome, platform, arch,
|
|
959
|
+
function managedBinary(clefHome, platform, arch, exists2) {
|
|
951
960
|
const platformDir = llamaRuntimeDir(pathsFor(clefHome).runtimeDir, platform, arch);
|
|
952
961
|
const manifest = readRuntimeManifest(path5.join(platformDir, "runtime-manifest.json"));
|
|
953
|
-
if (manifest &&
|
|
962
|
+
if (manifest && exists2(manifest.binaryPath)) return manifest.binaryPath;
|
|
954
963
|
const legacy = path5.join(platformDir, llamaServerName(platform));
|
|
955
|
-
if (
|
|
964
|
+
if (exists2(legacy)) return legacy;
|
|
956
965
|
return void 0;
|
|
957
966
|
}
|
|
958
967
|
function resolveLlamaServerBinary(cfg, deps = {}) {
|
|
959
|
-
const
|
|
968
|
+
const exists2 = deps.existsExecutable ?? existsExecutable;
|
|
960
969
|
const which = deps.which ?? ((name) => whichBin(name, deps.platform ?? process.platform));
|
|
961
970
|
const platform = deps.platform ?? process.platform;
|
|
962
971
|
const arch = deps.arch ?? process.arch;
|
|
963
972
|
if (cfg.llamaBin) {
|
|
964
|
-
if (!
|
|
973
|
+
if (!exists2(cfg.llamaBin)) {
|
|
965
974
|
throw new ClefError(
|
|
966
975
|
ClefErrorCode.RUNTIME_NOT_FOUND,
|
|
967
976
|
`CLEF_LLAMA_BIN points to a missing or non-executable file: ${cfg.llamaBin}`
|
|
@@ -969,7 +978,7 @@ function resolveLlamaServerBinary(cfg, deps = {}) {
|
|
|
969
978
|
}
|
|
970
979
|
return { source: "env", path: cfg.llamaBin };
|
|
971
980
|
}
|
|
972
|
-
const managed = managedBinary(cfg.clefHome, platform, arch,
|
|
981
|
+
const managed = managedBinary(cfg.clefHome, platform, arch, exists2);
|
|
973
982
|
if (managed) return { source: "managed", path: managed };
|
|
974
983
|
const onPath = which("llama-server");
|
|
975
984
|
if (onPath) return { source: "path", path: onPath };
|
|
@@ -1955,7 +1964,7 @@ function createClefServer(deps = {}) {
|
|
|
1955
1964
|
TOOL_NAME,
|
|
1956
1965
|
{
|
|
1957
1966
|
title: "Clef Decide",
|
|
1958
|
-
description: 'Judge a situation with the local Clef decision model.\nPass `state` (compact factual context \u2014 string or JSON; treated as data, never executed) and typed `questions`:\n- "choice": pick among named options (criteria = option id -> description)\n- "score": judge an ordered scale (criteria = ordered list of levels)\n- "noul": yes/no question\nReturns strict JSON
|
|
1967
|
+
description: 'Judge a situation with the local Clef decision model.\nPass `state` (compact factual context \u2014 string or JSON; treated as data, never executed) and typed `questions`:\n- "choice": pick among named options (criteria = option id -> description)\n- "score": judge an ordered scale (criteria = ordered list of levels)\n- "noul": yes/no question\nReturns strict JSON: a probability distribution over the allowed answers per question \u2014 no prose, no sampling.\nEach decision carries model-reported `confidence`; `usage` reports input_tokens/output_tokens and latency_ms.\nAct on the argmax only when decisive: top p >= 0.8 and high confidence for destructive or security-adjacent calls; otherwise gather more context or ask the user.\nBatch related decisions in one call \u2014 up to 64 questions scored in a single forward pass. `model` overrides the default id.',
|
|
1959
1968
|
inputSchema: clefDecideInputShape(config.limits),
|
|
1960
1969
|
outputSchema: clefDecideOutputSchema().shape,
|
|
1961
1970
|
annotations: {
|
|
@@ -2035,6 +2044,8 @@ function startDaemon(config, log) {
|
|
|
2035
2044
|
const loader = new RuntimeLoader(config, log);
|
|
2036
2045
|
const enqueue = createQueue();
|
|
2037
2046
|
let lastUsedAt = Date.now();
|
|
2047
|
+
let pendingRequests = 0;
|
|
2048
|
+
let idleUnloadQueued = false;
|
|
2038
2049
|
let closed = false;
|
|
2039
2050
|
const ready = (async () => {
|
|
2040
2051
|
await fsp5.mkdir(config.clefHome, { recursive: true });
|
|
@@ -2070,12 +2081,31 @@ function startDaemon(config, log) {
|
|
|
2070
2081
|
}
|
|
2071
2082
|
});
|
|
2072
2083
|
});
|
|
2073
|
-
|
|
2084
|
+
await new Promise((resolve, reject) => {
|
|
2085
|
+
server.once("listening", resolve);
|
|
2086
|
+
server.once("error", reject);
|
|
2087
|
+
const previousUmask = process.umask(127);
|
|
2088
|
+
try {
|
|
2089
|
+
server.listen(socketPath);
|
|
2090
|
+
} finally {
|
|
2091
|
+
process.umask(previousUmask);
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
2094
|
+
await fsp5.chmod(socketPath, 384);
|
|
2074
2095
|
log.info("clef-mcp daemon listening", { socket: socketPath });
|
|
2075
2096
|
const idleMs = config.daemonIdleSeconds * 1e3;
|
|
2076
2097
|
const idleTimer = config.daemonIdleSeconds > 0 ? setInterval(() => {
|
|
2077
|
-
if (closed || Date.now() - lastUsedAt < idleMs) return;
|
|
2078
|
-
|
|
2098
|
+
if (closed || pendingRequests > 0 || idleUnloadQueued || Date.now() - lastUsedAt < idleMs) return;
|
|
2099
|
+
idleUnloadQueued = true;
|
|
2100
|
+
void enqueue(async () => {
|
|
2101
|
+
if (closed || pendingRequests > 0 || Date.now() - lastUsedAt < idleMs) return;
|
|
2102
|
+
await loader.dispose();
|
|
2103
|
+
log.info("daemon idle, model unloaded");
|
|
2104
|
+
}).catch((err) => {
|
|
2105
|
+
log.error("daemon idle unload failed", errorJson(err).error);
|
|
2106
|
+
}).finally(() => {
|
|
2107
|
+
idleUnloadQueued = false;
|
|
2108
|
+
});
|
|
2079
2109
|
}, Math.min(idleMs, 6e4)) : void 0;
|
|
2080
2110
|
if (idleTimer) idleTimer.unref();
|
|
2081
2111
|
const shutdown = () => {
|
|
@@ -2090,10 +2120,6 @@ function startDaemon(config, log) {
|
|
|
2090
2120
|
};
|
|
2091
2121
|
process.once("SIGINT", shutdown);
|
|
2092
2122
|
process.once("SIGTERM", shutdown);
|
|
2093
|
-
await new Promise((resolve, reject) => {
|
|
2094
|
-
server.once("listening", resolve);
|
|
2095
|
-
server.once("error", reject);
|
|
2096
|
-
});
|
|
2097
2123
|
})();
|
|
2098
2124
|
async function handleLine(line, socket) {
|
|
2099
2125
|
let request;
|
|
@@ -2107,9 +2133,14 @@ function startDaemon(config, log) {
|
|
|
2107
2133
|
writeLine(socket, { pong: true, model: config.model });
|
|
2108
2134
|
return;
|
|
2109
2135
|
}
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2136
|
+
pendingRequests += 1;
|
|
2137
|
+
try {
|
|
2138
|
+
const result = await enqueue(() => decideOnce(request));
|
|
2139
|
+
writeLine(socket, result);
|
|
2140
|
+
} finally {
|
|
2141
|
+
pendingRequests -= 1;
|
|
2142
|
+
lastUsedAt = Date.now();
|
|
2143
|
+
}
|
|
2113
2144
|
}
|
|
2114
2145
|
async function decideOnce(request) {
|
|
2115
2146
|
let parsed;
|
|
@@ -2269,7 +2300,8 @@ async function runDecide(opts) {
|
|
|
2269
2300
|
questions = record.questions;
|
|
2270
2301
|
if (typeof record.model === "string") model = record.model;
|
|
2271
2302
|
}
|
|
2272
|
-
|
|
2303
|
+
model = opts.model ?? model ?? config.model;
|
|
2304
|
+
const doc = { state, questions, model };
|
|
2273
2305
|
if (opts.daemon) {
|
|
2274
2306
|
const answered = await tryDaemon(config, doc);
|
|
2275
2307
|
if (answered) {
|
|
@@ -2327,18 +2359,603 @@ function validateDoc(config, doc) {
|
|
|
2327
2359
|
return parsed;
|
|
2328
2360
|
}
|
|
2329
2361
|
|
|
2362
|
+
// src/cli/classify.ts
|
|
2363
|
+
init_errors();
|
|
2364
|
+
|
|
2365
|
+
// src/classify/input.ts
|
|
2366
|
+
init_errors();
|
|
2367
|
+
import { createHash as createHash3 } from "crypto";
|
|
2368
|
+
import fsp8 from "fs/promises";
|
|
2369
|
+
import { Readable } from "stream";
|
|
2370
|
+
import { parse } from "csv-parse";
|
|
2371
|
+
|
|
2372
|
+
// src/classify/profile.ts
|
|
2373
|
+
init_errors();
|
|
2374
|
+
import { createHash as createHash2 } from "crypto";
|
|
2375
|
+
import fsp7 from "fs/promises";
|
|
2376
|
+
import { parseAllDocuments } from "yaml";
|
|
2377
|
+
import { z as z3 } from "zod";
|
|
2378
|
+
function invalid2(message) {
|
|
2379
|
+
return new ClefError(ClefErrorCode.INVALID_INPUT, message);
|
|
2380
|
+
}
|
|
2381
|
+
var safeId = z3.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/);
|
|
2382
|
+
var threshold = z3.number().finite().min(0).max(1);
|
|
2383
|
+
var reserved = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype", "<<"]);
|
|
2384
|
+
function validateData(value, depth = 0) {
|
|
2385
|
+
if (depth > CLASSIFY_LIMITS.maxDepth) throw invalid2("Profile nesting exceeds the limit");
|
|
2386
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
2387
|
+
if (typeof value === "number" && Number.isFinite(value)) return;
|
|
2388
|
+
if (typeof value !== "object" || value instanceof Date || value instanceof Map || value instanceof Set) throw invalid2("Profile must contain JSON-compatible data");
|
|
2389
|
+
for (const [key, child] of Object.entries(value)) {
|
|
2390
|
+
if (reserved.has(key)) throw invalid2(`Reserved profile key "${key}"`);
|
|
2391
|
+
validateData(child, depth + 1);
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
function canonicalJson(value) {
|
|
2395
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
2396
|
+
if (value !== null && typeof value === "object") {
|
|
2397
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
2398
|
+
}
|
|
2399
|
+
return JSON.stringify(value);
|
|
2400
|
+
}
|
|
2401
|
+
function profileSchema(limits) {
|
|
2402
|
+
return z3.strictObject({
|
|
2403
|
+
schema_version: z3.literal(1),
|
|
2404
|
+
name: safeId.max(64),
|
|
2405
|
+
instructions: z3.string().trim().min(1).max(limits.maxInstructionsChars),
|
|
2406
|
+
labels: z3.record(safeId, z3.string().trim().min(1).max(limits.maxCriteriaDescChars)).refine((v) => Object.keys(v).length >= 2 && Object.keys(v).length <= Math.min(100, limits.maxCriteriaPerQuestion), "Expected 2\u2013100 labels within the configured criteria limit"),
|
|
2407
|
+
model: z3.string().trim().min(1).optional(),
|
|
2408
|
+
input: z3.strictObject({
|
|
2409
|
+
id_field: z3.string().min(1).optional(),
|
|
2410
|
+
fields: z3.array(z3.string().min(1)).min(1).max(CLASSIFY_LIMITS.maxFields).refine((v) => new Set(v).size === v.length, "Selected fields must be unique").optional()
|
|
2411
|
+
}).optional(),
|
|
2412
|
+
policy: z3.strictObject({
|
|
2413
|
+
min_probability: threshold.default(0.8),
|
|
2414
|
+
min_margin: threshold.default(0.15),
|
|
2415
|
+
min_confidence: threshold.optional()
|
|
2416
|
+
}).default({ min_probability: 0.8, min_margin: 0.15 }),
|
|
2417
|
+
examples: z3.array(z3.strictObject({ input: z3.record(z3.string(), z3.unknown()), label: safeId })).max(CLASSIFY_LIMITS.maxExamples).optional()
|
|
2418
|
+
}).superRefine((p, ctx) => {
|
|
2419
|
+
for (const example of p.examples ?? []) {
|
|
2420
|
+
if (!Object.hasOwn(p.labels, example.label)) ctx.addIssue({ code: "custom", message: `Example label "${example.label}" is not configured` });
|
|
2421
|
+
}
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
function parseProfile(text, limits) {
|
|
2425
|
+
if (Buffer.byteLength(text) > CLASSIFY_LIMITS.maxProfileBytes) throw invalid2("Profile exceeds the maximum size in bytes");
|
|
2426
|
+
try {
|
|
2427
|
+
const docs = parseAllDocuments(text, { version: "1.2", schema: "core", uniqueKeys: true, stringKeys: true, strict: true, resolveKnownTags: false, customTags: [], merge: false, logLevel: "silent" });
|
|
2428
|
+
if (docs.length !== 1) throw invalid2("Profile must contain exactly one YAML document");
|
|
2429
|
+
const doc = docs[0];
|
|
2430
|
+
if (doc.errors.length || doc.warnings.length) throw invalid2(`Invalid YAML profile: ${(doc.errors[0] ?? doc.warnings[0]).message}`);
|
|
2431
|
+
const data = doc.toJS({ maxAliasCount: 0 });
|
|
2432
|
+
validateData(data);
|
|
2433
|
+
return profileSchema(limits).parse(JSON.parse(canonicalJson(data)));
|
|
2434
|
+
} catch (err) {
|
|
2435
|
+
if (err instanceof ClefError) throw err;
|
|
2436
|
+
throw invalid2(`Invalid profile: ${err instanceof Error ? err.message : String(err)}`);
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
async function loadProfile(file, limits) {
|
|
2440
|
+
const handle = await fsp7.open(file, "r");
|
|
2441
|
+
try {
|
|
2442
|
+
const stat = await handle.stat();
|
|
2443
|
+
if (!stat.isFile() || stat.size > CLASSIFY_LIMITS.maxProfileBytes) throw invalid2("Profile must be a regular file within the size limit");
|
|
2444
|
+
const chunks = [];
|
|
2445
|
+
let size = 0;
|
|
2446
|
+
const stream = handle.createReadStream({ autoClose: false });
|
|
2447
|
+
for await (const chunk of stream) {
|
|
2448
|
+
size += chunk.length;
|
|
2449
|
+
if (size > CLASSIFY_LIMITS.maxProfileBytes) throw invalid2("Profile exceeds the maximum size in bytes");
|
|
2450
|
+
chunks.push(chunk);
|
|
2451
|
+
}
|
|
2452
|
+
return parseProfile(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)), limits);
|
|
2453
|
+
} finally {
|
|
2454
|
+
await handle.close();
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
function selectRecord(profile, record) {
|
|
2458
|
+
if (profile.input?.id_field) {
|
|
2459
|
+
const id = record[profile.input.id_field];
|
|
2460
|
+
if (!Object.hasOwn(record, profile.input.id_field) || !(typeof id === "string" || typeof id === "number" && Number.isFinite(id))) throw invalid2(`Missing or invalid id field "${profile.input.id_field}"`);
|
|
2461
|
+
}
|
|
2462
|
+
if (!profile.input?.fields) return record;
|
|
2463
|
+
return Object.fromEntries(profile.input.fields.map((field) => {
|
|
2464
|
+
if (!Object.hasOwn(record, field)) throw invalid2(`Missing selected field "${field}"`);
|
|
2465
|
+
return [field, record[field]];
|
|
2466
|
+
}));
|
|
2467
|
+
}
|
|
2468
|
+
function profileState(profile, record) {
|
|
2469
|
+
return { record, ...profile.examples?.length ? { examples: profile.examples } : {} };
|
|
2470
|
+
}
|
|
2471
|
+
function profileDigest(profile) {
|
|
2472
|
+
return createHash2("sha256").update(canonicalJson(profile)).digest("hex");
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// src/classify/input.ts
|
|
2476
|
+
async function* decoded(source) {
|
|
2477
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
2478
|
+
try {
|
|
2479
|
+
for await (const chunk of source) yield decoder.decode(typeof chunk === "string" ? Buffer.from(chunk) : chunk, { stream: true });
|
|
2480
|
+
const tail = decoder.decode();
|
|
2481
|
+
if (tail) yield tail;
|
|
2482
|
+
} catch (err) {
|
|
2483
|
+
throw invalid2(`Cannot read UTF-8 input: ${err instanceof Error ? err.message : String(err)}`);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
async function* boundedLines(source, maxBytes, discardUnterminated = false) {
|
|
2487
|
+
let pieces = [], size = 0, oversized = false, first = true;
|
|
2488
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
2489
|
+
const line = (terminated) => {
|
|
2490
|
+
let text = "";
|
|
2491
|
+
if (!oversized && (terminated || !discardUnterminated)) {
|
|
2492
|
+
try {
|
|
2493
|
+
text = decoder.decode(Buffer.concat(pieces, size));
|
|
2494
|
+
} catch {
|
|
2495
|
+
throw invalid2("Cannot read UTF-8 input: invalid encoded record");
|
|
2496
|
+
}
|
|
2497
|
+
if (first) text = text.replace(/^\uFEFF/, "");
|
|
2498
|
+
text = text.replace(/\r$/, "");
|
|
2499
|
+
}
|
|
2500
|
+
first = false;
|
|
2501
|
+
return { text, oversized, terminated, bytes: size + (terminated ? 1 : 0) };
|
|
2502
|
+
};
|
|
2503
|
+
for await (const raw of source) {
|
|
2504
|
+
const chunk = typeof raw === "string" ? Buffer.from(raw) : raw;
|
|
2505
|
+
let start = 0;
|
|
2506
|
+
for (; ; ) {
|
|
2507
|
+
const end = chunk.indexOf(10, start);
|
|
2508
|
+
const part = chunk.subarray(start, end < 0 ? void 0 : end);
|
|
2509
|
+
size += part.length;
|
|
2510
|
+
if (!oversized) {
|
|
2511
|
+
if (size > maxBytes) {
|
|
2512
|
+
oversized = true;
|
|
2513
|
+
pieces = [];
|
|
2514
|
+
} else if (part.length) pieces.push(part);
|
|
2515
|
+
}
|
|
2516
|
+
if (end < 0) break;
|
|
2517
|
+
yield line(true);
|
|
2518
|
+
pieces = [];
|
|
2519
|
+
size = 0;
|
|
2520
|
+
oversized = false;
|
|
2521
|
+
start = end + 1;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
if (size) yield line(false);
|
|
2525
|
+
}
|
|
2526
|
+
async function* readRows(source, format) {
|
|
2527
|
+
let row = 0;
|
|
2528
|
+
if (format === "jsonl") {
|
|
2529
|
+
for await (const line of boundedLines(source, CLASSIFY_LIMITS.maxRecordBytes)) {
|
|
2530
|
+
if (!line.oversized && !line.text.trim()) continue;
|
|
2531
|
+
row++;
|
|
2532
|
+
try {
|
|
2533
|
+
if (line.oversized) throw invalid2("Record exceeds the maximum size");
|
|
2534
|
+
const record = JSON.parse(line.text);
|
|
2535
|
+
if (record === null || typeof record !== "object" || Array.isArray(record)) throw invalid2("JSONL records must be JSON objects");
|
|
2536
|
+
yield { row, record };
|
|
2537
|
+
} catch (err) {
|
|
2538
|
+
yield { row, error: errorJson(invalid2(`Row ${row}: ${err instanceof Error ? err.message : String(err)}`)).error };
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
const parser = parse({
|
|
2544
|
+
bom: true,
|
|
2545
|
+
cast: false,
|
|
2546
|
+
skip_empty_lines: true,
|
|
2547
|
+
relax_column_count: false,
|
|
2548
|
+
max_record_size: CLASSIFY_LIMITS.maxRecordBytes,
|
|
2549
|
+
columns: (headers) => {
|
|
2550
|
+
if (headers.length > CLASSIFY_LIMITS.maxFields || headers.some((h) => !h.trim()) || new Set(headers).size !== headers.length) throw invalid2("CSV headers must be nonempty, unique and within the field limit");
|
|
2551
|
+
if (headers.some((h) => ["__proto__", "constructor", "prototype"].includes(h))) throw invalid2("Reserved CSV header");
|
|
2552
|
+
return headers;
|
|
2553
|
+
}
|
|
2554
|
+
});
|
|
2555
|
+
const upstream = Readable.from(decoded(source));
|
|
2556
|
+
upstream.on("error", (err) => parser.destroy(err));
|
|
2557
|
+
upstream.pipe(parser);
|
|
2558
|
+
try {
|
|
2559
|
+
for await (const record of parser) yield { row: ++row, record };
|
|
2560
|
+
} catch (err) {
|
|
2561
|
+
throw invalid2(`Invalid CSV: ${err instanceof Error ? err.message : String(err)}`);
|
|
2562
|
+
} finally {
|
|
2563
|
+
upstream.destroy();
|
|
2564
|
+
parser.destroy();
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
async function* fileChunks(handle) {
|
|
2568
|
+
let position = 0;
|
|
2569
|
+
for (; ; ) {
|
|
2570
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
2571
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
2572
|
+
if (!bytesRead) return;
|
|
2573
|
+
position += bytesRead;
|
|
2574
|
+
yield buffer.subarray(0, bytesRead);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
function sameFile(a, b) {
|
|
2578
|
+
return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
2579
|
+
}
|
|
2580
|
+
async function openInput(file, requestedFormat, _limits) {
|
|
2581
|
+
const format = requestedFormat ?? (file === "-" ? "jsonl" : file.toLowerCase().endsWith(".jsonl") ? "jsonl" : file.toLowerCase().endsWith(".csv") ? "csv" : void 0);
|
|
2582
|
+
if (format !== "jsonl" && format !== "csv") throw invalid2("Use --format jsonl|csv for this input");
|
|
2583
|
+
if (file === "-") return { format, records: () => readRows(process.stdin.iterator({ destroyOnReturn: false }), format), verifyUnchanged: async () => {
|
|
2584
|
+
}, close: async () => {
|
|
2585
|
+
} };
|
|
2586
|
+
const handle = await fsp8.open(file, "r");
|
|
2587
|
+
try {
|
|
2588
|
+
const initial = await handle.stat();
|
|
2589
|
+
if (!initial.isFile()) throw invalid2("Input must be a regular file or stdin");
|
|
2590
|
+
const verifyUnchanged = async () => {
|
|
2591
|
+
let currentPath;
|
|
2592
|
+
try {
|
|
2593
|
+
currentPath = await fsp8.stat(file);
|
|
2594
|
+
} catch {
|
|
2595
|
+
throw invalid2("Input changed during classification");
|
|
2596
|
+
}
|
|
2597
|
+
if (!sameFile(initial, await handle.stat()) || !sameFile(initial, currentPath)) throw invalid2("Input changed during classification");
|
|
2598
|
+
};
|
|
2599
|
+
const hash = createHash3("sha256");
|
|
2600
|
+
for await (const chunk of fileChunks(handle)) hash.update(chunk);
|
|
2601
|
+
await verifyUnchanged();
|
|
2602
|
+
return { format, fingerprint: hash.digest("hex"), records: () => readRows(fileChunks(handle), format), verifyUnchanged, close: () => handle.close() };
|
|
2603
|
+
} catch (err) {
|
|
2604
|
+
await handle.close();
|
|
2605
|
+
throw err;
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
// src/classify/output.ts
|
|
2610
|
+
init_errors();
|
|
2611
|
+
import { randomUUID } from "crypto";
|
|
2612
|
+
import { constants } from "fs";
|
|
2613
|
+
import fsp9 from "fs/promises";
|
|
2614
|
+
import os2 from "os";
|
|
2615
|
+
import path8 from "path";
|
|
2616
|
+
import { z as z4 } from "zod";
|
|
2617
|
+
var baseShape = { row: z4.number().int().positive(), id: z4.string().optional(), profile: z4.string().min(1), model: z4.string().min(1) };
|
|
2618
|
+
var policyShape = {
|
|
2619
|
+
label: z4.string().nullable(),
|
|
2620
|
+
suggested_label: z4.string(),
|
|
2621
|
+
probability: z4.number().min(0).max(1),
|
|
2622
|
+
margin: z4.number().min(0).max(1),
|
|
2623
|
+
probabilities: z4.record(z4.string(), z4.number().min(0).max(1)),
|
|
2624
|
+
confidence: z4.number().min(0).max(1).optional(),
|
|
2625
|
+
review_reasons: z4.array(z4.enum(["low_probability", "low_margin", "tie", "low_confidence", "missing_confidence"])),
|
|
2626
|
+
usage: z4.strictObject({ input_tokens: z4.number().optional(), output_tokens: z4.number().optional(), latency_ms: z4.number().optional() }).optional()
|
|
2627
|
+
};
|
|
2628
|
+
var resultSchema = z4.discriminatedUnion("status", [
|
|
2629
|
+
z4.strictObject({ ...baseShape, ...policyShape, status: z4.literal("classified") }),
|
|
2630
|
+
z4.strictObject({ ...baseShape, ...policyShape, status: z4.literal("needs_review") }),
|
|
2631
|
+
z4.strictObject({ ...baseShape, status: z4.literal("error"), label: z4.null(), error: z4.strictObject({ code: z4.enum(Object.values(ClefErrorCode)), message: z4.string(), hint: z4.string().optional() }) })
|
|
2632
|
+
]);
|
|
2633
|
+
function validateResult(value, expectedRow, metadata) {
|
|
2634
|
+
const parsed = resultSchema.safeParse(value);
|
|
2635
|
+
if (!parsed.success) throw invalid2("Invalid classification result in checkpoint");
|
|
2636
|
+
const result = parsed.data;
|
|
2637
|
+
if (result.row !== expectedRow || result.profile !== metadata.profile || result.model !== metadata.model) throw invalid2("Checkpoint row sequence or profile/model identity does not match");
|
|
2638
|
+
if (result.status === "error") return;
|
|
2639
|
+
const entries = Object.entries(result.probabilities).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
2640
|
+
if (entries.length < 2 || entries.length > 100 || Math.abs(entries.reduce((s, [, p]) => s + p, 0) - 1) > 1e-3 + 1e-12 || entries[0][0] !== result.suggested_label || Math.abs(entries[0][1] - result.probability) > 1e-12 || Math.abs(entries[0][1] - entries[1][1] - result.margin) > 1e-12 || (result.status === "classified" ? result.label !== result.suggested_label || result.review_reasons.length !== 0 || result.margin === 0 : result.label !== null || result.review_reasons.length === 0)) throw invalid2("Invalid classification policy fields in checkpoint");
|
|
2641
|
+
}
|
|
2642
|
+
async function exists(file) {
|
|
2643
|
+
try {
|
|
2644
|
+
await fsp9.lstat(file);
|
|
2645
|
+
return true;
|
|
2646
|
+
} catch (err) {
|
|
2647
|
+
if (err.code === "ENOENT") return false;
|
|
2648
|
+
throw err;
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
async function protectFiles(candidates, protectedPaths) {
|
|
2652
|
+
for (const candidate of candidates) {
|
|
2653
|
+
for (const source of protectedPaths.filter((p) => p !== "-")) {
|
|
2654
|
+
if (path8.resolve(candidate) === path8.resolve(source)) throw invalid2("Output must not overwrite an input or profile file");
|
|
2655
|
+
try {
|
|
2656
|
+
const [a, b] = await Promise.all([fsp9.stat(candidate), fsp9.stat(source)]);
|
|
2657
|
+
if (a.dev === b.dev && a.ino === b.ino) throw invalid2("Output aliases an input or profile file");
|
|
2658
|
+
} catch (err) {
|
|
2659
|
+
if (err.code !== "ENOENT") throw err;
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
async function openRegular(file, write = false) {
|
|
2665
|
+
const handle = await fsp9.open(file, (write ? constants.O_RDWR | constants.O_APPEND : constants.O_RDONLY) | (constants.O_NOFOLLOW ?? 0));
|
|
2666
|
+
const stat = await handle.stat();
|
|
2667
|
+
if (!stat.isFile() || stat.nlink !== 1 || (await fsp9.lstat(file)).isSymbolicLink()) {
|
|
2668
|
+
await handle.close();
|
|
2669
|
+
throw invalid2("Checkpoint and metadata must be regular files without links");
|
|
2670
|
+
}
|
|
2671
|
+
return handle;
|
|
2672
|
+
}
|
|
2673
|
+
async function readJson(file) {
|
|
2674
|
+
const handle = await openRegular(file);
|
|
2675
|
+
try {
|
|
2676
|
+
const chunks = [];
|
|
2677
|
+
let size = 0;
|
|
2678
|
+
for await (const chunk of fileChunks(handle)) {
|
|
2679
|
+
size += chunk.length;
|
|
2680
|
+
if (size > CLASSIFY_LIMITS.maxMetadataBytes) throw invalid2("Checkpoint metadata or lock exceeds the size limit");
|
|
2681
|
+
chunks.push(chunk);
|
|
2682
|
+
}
|
|
2683
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
|
|
2684
|
+
} finally {
|
|
2685
|
+
await handle.close();
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
async function acquireLock(file, resume) {
|
|
2689
|
+
const lock = `${file}.lock`, claim = `${lock}.claim`, token = randomUUID();
|
|
2690
|
+
let guard;
|
|
2691
|
+
try {
|
|
2692
|
+
guard = await fsp9.open(claim, "wx", 384);
|
|
2693
|
+
} catch {
|
|
2694
|
+
throw invalid2(`Output lock acquisition is busy: ${claim}. Preserve active locks; inspect a crash-left claim before removing it.`);
|
|
2695
|
+
}
|
|
2696
|
+
try {
|
|
2697
|
+
if (await exists(lock)) {
|
|
2698
|
+
const owner = await readJson(lock);
|
|
2699
|
+
let dead = false;
|
|
2700
|
+
if (resume && owner.host === os2.hostname() && Number.isInteger(owner.pid) && owner.pid > 0 && owner.pid <= 2147483647 && typeof owner.token === "string") {
|
|
2701
|
+
try {
|
|
2702
|
+
process.kill(owner.pid, 0);
|
|
2703
|
+
} catch (err) {
|
|
2704
|
+
dead = err.code === "ESRCH";
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
if (!dead) throw invalid2(`Output lock belongs to an active or unverifiable writer: ${lock}`);
|
|
2708
|
+
await fsp9.unlink(lock);
|
|
2709
|
+
}
|
|
2710
|
+
const handle = await fsp9.open(lock, "wx", 384);
|
|
2711
|
+
try {
|
|
2712
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, host: os2.hostname(), token }));
|
|
2713
|
+
await handle.sync();
|
|
2714
|
+
} finally {
|
|
2715
|
+
await handle.close();
|
|
2716
|
+
}
|
|
2717
|
+
} finally {
|
|
2718
|
+
await guard.close();
|
|
2719
|
+
await fsp9.unlink(claim);
|
|
2720
|
+
}
|
|
2721
|
+
return async () => {
|
|
2722
|
+
const owner = await readJson(lock);
|
|
2723
|
+
if (owner.token === token) await fsp9.unlink(lock);
|
|
2724
|
+
};
|
|
2725
|
+
}
|
|
2726
|
+
async function writeMetadata(file, metadata) {
|
|
2727
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
2728
|
+
const handle = await fsp9.open(temp, "wx", 384);
|
|
2729
|
+
try {
|
|
2730
|
+
await handle.writeFile(JSON.stringify(metadata) + "\n");
|
|
2731
|
+
await handle.sync();
|
|
2732
|
+
await fsp9.link(temp, file);
|
|
2733
|
+
} finally {
|
|
2734
|
+
await handle.close();
|
|
2735
|
+
await fsp9.unlink(temp);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
async function openOutput(options) {
|
|
2739
|
+
const { metadata } = options;
|
|
2740
|
+
let handle, release;
|
|
2741
|
+
let completed = 0, closed = false, createdOutput = false;
|
|
2742
|
+
try {
|
|
2743
|
+
if (options.resume && !options.path) throw invalid2("--resume requires --output");
|
|
2744
|
+
if (options.path) {
|
|
2745
|
+
const file = options.path;
|
|
2746
|
+
await protectFiles([file, `${file}.meta.json`, `${file}.lock`, `${file}.lock.claim`], options.protectedPaths ?? []);
|
|
2747
|
+
if (!options.resume && (await exists(file) || await exists(`${file}.meta.json`))) throw invalid2("Output or metadata already exists; use --resume or a new output path");
|
|
2748
|
+
release = await acquireLock(file, !!options.resume);
|
|
2749
|
+
if (options.resume) {
|
|
2750
|
+
if (canonicalJson(await readJson(`${file}.meta.json`)) !== canonicalJson(metadata)) throw invalid2("Resume metadata does not match input, profile, model, runtime or format");
|
|
2751
|
+
handle = await openRegular(file, true);
|
|
2752
|
+
let safeOffset = 0, partial = false;
|
|
2753
|
+
for await (const line of boundedLines(fileChunks(handle), CLASSIFY_LIMITS.maxResultBytes, true)) {
|
|
2754
|
+
if (!line.terminated) {
|
|
2755
|
+
partial = true;
|
|
2756
|
+
break;
|
|
2757
|
+
}
|
|
2758
|
+
if (line.oversized) throw invalid2("Checkpoint result exceeds the size limit");
|
|
2759
|
+
let value;
|
|
2760
|
+
try {
|
|
2761
|
+
value = JSON.parse(line.text);
|
|
2762
|
+
} catch {
|
|
2763
|
+
throw invalid2("Corrupt complete JSONL checkpoint row");
|
|
2764
|
+
}
|
|
2765
|
+
validateResult(value, completed + 1, metadata);
|
|
2766
|
+
completed++;
|
|
2767
|
+
safeOffset += line.bytes;
|
|
2768
|
+
}
|
|
2769
|
+
if (partial) {
|
|
2770
|
+
await handle.truncate(safeOffset);
|
|
2771
|
+
await handle.sync();
|
|
2772
|
+
}
|
|
2773
|
+
} else {
|
|
2774
|
+
handle = await fsp9.open(file, "wx", 384);
|
|
2775
|
+
createdOutput = true;
|
|
2776
|
+
await writeMetadata(`${file}.meta.json`, metadata);
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
} catch (err) {
|
|
2780
|
+
try {
|
|
2781
|
+
await handle?.close();
|
|
2782
|
+
} finally {
|
|
2783
|
+
if (createdOutput && options.path) await fsp9.unlink(options.path);
|
|
2784
|
+
await release?.();
|
|
2785
|
+
}
|
|
2786
|
+
throw err;
|
|
2787
|
+
}
|
|
2788
|
+
const stream = options.stream ?? process.stdout;
|
|
2789
|
+
return {
|
|
2790
|
+
get completedRows() {
|
|
2791
|
+
return completed;
|
|
2792
|
+
},
|
|
2793
|
+
async append(result) {
|
|
2794
|
+
if (closed) throw invalid2("Output is closed");
|
|
2795
|
+
validateResult(result, completed + 1, metadata);
|
|
2796
|
+
const line = JSON.stringify(result) + "\n";
|
|
2797
|
+
if (Buffer.byteLength(line) > CLASSIFY_LIMITS.maxResultBytes) throw invalid2("Result exceeds the maximum size");
|
|
2798
|
+
if (handle) {
|
|
2799
|
+
await handle.writeFile(line, "utf8");
|
|
2800
|
+
await handle.sync();
|
|
2801
|
+
} else await new Promise((resolve, reject) => stream.write(line, (err) => err ? reject(err) : resolve()));
|
|
2802
|
+
completed = result.row;
|
|
2803
|
+
},
|
|
2804
|
+
async finish(totalRows) {
|
|
2805
|
+
if (completed !== totalRows) throw invalid2("Checkpoint rows exceed the input or processing is incomplete");
|
|
2806
|
+
},
|
|
2807
|
+
async close() {
|
|
2808
|
+
if (closed) return;
|
|
2809
|
+
closed = true;
|
|
2810
|
+
try {
|
|
2811
|
+
await handle?.close();
|
|
2812
|
+
} finally {
|
|
2813
|
+
await release?.();
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// src/classify/policy.ts
|
|
2820
|
+
init_errors();
|
|
2821
|
+
function evaluateClassification(profile, decision) {
|
|
2822
|
+
const entries = Object.entries(decision.answer);
|
|
2823
|
+
const labels = Object.keys(profile.labels);
|
|
2824
|
+
const total = entries.reduce((sum, [, p]) => sum + p, 0);
|
|
2825
|
+
if (entries.length !== labels.length || entries.some(([label, p]) => !Object.hasOwn(profile.labels, label) || !Number.isFinite(p) || p < 0 || p > 1) || Math.abs(total - 1) > 1e-3 + 1e-12 || decision.confidence !== void 0 && (!Number.isFinite(decision.confidence) || decision.confidence < 0 || decision.confidence > 1)) {
|
|
2826
|
+
throw new ClefError(ClefErrorCode.CLEF_INFERENCE_FAILED, "Runtime returned an invalid classification distribution or confidence");
|
|
2827
|
+
}
|
|
2828
|
+
const ranked = entries.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
2829
|
+
const top = ranked[0];
|
|
2830
|
+
const margin = top[1] - ranked[1][1];
|
|
2831
|
+
const reasons = [];
|
|
2832
|
+
if (top[1] < profile.policy.min_probability - 1e-12) reasons.push("low_probability");
|
|
2833
|
+
if (margin < profile.policy.min_margin - 1e-12) reasons.push("low_margin");
|
|
2834
|
+
if (top[1] === ranked[1][1]) reasons.push("tie");
|
|
2835
|
+
if (profile.policy.min_confidence !== void 0) {
|
|
2836
|
+
if (decision.confidence === void 0) reasons.push("missing_confidence");
|
|
2837
|
+
else if (decision.confidence < profile.policy.min_confidence - 1e-12) reasons.push("low_confidence");
|
|
2838
|
+
}
|
|
2839
|
+
return {
|
|
2840
|
+
status: reasons.length ? "needs_review" : "classified",
|
|
2841
|
+
label: reasons.length ? null : top[0],
|
|
2842
|
+
suggested_label: top[0],
|
|
2843
|
+
probability: top[1],
|
|
2844
|
+
margin,
|
|
2845
|
+
probabilities: decision.answer,
|
|
2846
|
+
...decision.confidence !== void 0 ? { confidence: decision.confidence } : {},
|
|
2847
|
+
review_reasons: reasons
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
// src/cli/classify.ts
|
|
2852
|
+
async function runClassify(inputPath, options) {
|
|
2853
|
+
const config = loadConfig();
|
|
2854
|
+
const profile = await loadProfile(options.profile, config.limits);
|
|
2855
|
+
const model = options.model ?? profile.model ?? config.model;
|
|
2856
|
+
const spec = getModelSpec(model);
|
|
2857
|
+
if (options.resume && (inputPath === "-" || !options.output)) throw invalid2("--resume requires a regular input file and --output");
|
|
2858
|
+
const loader = new RuntimeLoader(config, createLogger(config.logLevel));
|
|
2859
|
+
let source, output;
|
|
2860
|
+
let interrupted = false, total = 0;
|
|
2861
|
+
const counts = { classified: 0, needs_review: 0, errors: 0, skipped: 0 };
|
|
2862
|
+
const interrupt = () => {
|
|
2863
|
+
interrupted = true;
|
|
2864
|
+
if (inputPath === "-") process.stdin.destroy();
|
|
2865
|
+
};
|
|
2866
|
+
process.on("SIGINT", interrupt);
|
|
2867
|
+
process.on("SIGTERM", interrupt);
|
|
2868
|
+
try {
|
|
2869
|
+
source = await openInput(inputPath, options.format, config.limits);
|
|
2870
|
+
if (interrupted) return 130;
|
|
2871
|
+
output = await openOutput({
|
|
2872
|
+
path: options.output,
|
|
2873
|
+
resume: options.resume,
|
|
2874
|
+
protectedPaths: [inputPath, options.profile],
|
|
2875
|
+
metadata: { schema_version: 1, input_sha256: source.fingerprint ?? "", profile_sha256: profileDigest(profile), profile: profile.name, model, runtime: config.runtime, format: source.format }
|
|
2876
|
+
});
|
|
2877
|
+
await source.verifyUnchanged();
|
|
2878
|
+
const committed = output.completedRows;
|
|
2879
|
+
const commit = async (result) => {
|
|
2880
|
+
await source.verifyUnchanged();
|
|
2881
|
+
await output.append(result);
|
|
2882
|
+
};
|
|
2883
|
+
for await (const row of source.records()) {
|
|
2884
|
+
if (interrupted) break;
|
|
2885
|
+
total = row.row;
|
|
2886
|
+
if (row.row <= committed) {
|
|
2887
|
+
counts.skipped++;
|
|
2888
|
+
continue;
|
|
2889
|
+
}
|
|
2890
|
+
const rawId = profile.input?.id_field && row.record ? row.record[profile.input.id_field] : void 0;
|
|
2891
|
+
const base = { row: row.row, ...typeof rawId === "string" || typeof rawId === "number" && Number.isFinite(rawId) ? { id: String(rawId) } : {}, profile: profile.name, model };
|
|
2892
|
+
let doc;
|
|
2893
|
+
let rowError = row.error;
|
|
2894
|
+
if (!rowError) {
|
|
2895
|
+
try {
|
|
2896
|
+
const selected = selectRecord(profile, row.record);
|
|
2897
|
+
doc = { model, state: profileState(profile, selected), questions: {
|
|
2898
|
+
classification: { type: "choice", instructions: profile.instructions, criteria: profile.labels }
|
|
2899
|
+
} };
|
|
2900
|
+
clefDecideInputSchema(config.limits).parse(doc);
|
|
2901
|
+
validateStateSize(doc.state, config.limits);
|
|
2902
|
+
} catch (err) {
|
|
2903
|
+
rowError = errorJson(err instanceof ClefError ? err : invalid2(err instanceof Error ? err.message : String(err))).error;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
if (rowError) {
|
|
2907
|
+
await commit({ ...base, status: "error", label: null, error: rowError });
|
|
2908
|
+
counts.errors++;
|
|
2909
|
+
continue;
|
|
2910
|
+
}
|
|
2911
|
+
const { runtime } = await loader.ensure(spec);
|
|
2912
|
+
if (interrupted) break;
|
|
2913
|
+
await source.verifyUnchanged();
|
|
2914
|
+
const response = await runtime.decide(toSystemOneRequest(doc, model));
|
|
2915
|
+
const adapted = fromSystemOneResponse(response, doc.questions, model);
|
|
2916
|
+
const result = evaluateClassification(profile, adapted.decisions.classification);
|
|
2917
|
+
await commit({ ...base, ...result, ...adapted.usage ? { usage: adapted.usage } : {} });
|
|
2918
|
+
counts[result.status]++;
|
|
2919
|
+
}
|
|
2920
|
+
if (interrupted) return 130;
|
|
2921
|
+
await source.verifyUnchanged();
|
|
2922
|
+
await output.finish(total);
|
|
2923
|
+
return counts.errors ? 1 : 0;
|
|
2924
|
+
} catch (err) {
|
|
2925
|
+
if (interrupted) return 130;
|
|
2926
|
+
throw err;
|
|
2927
|
+
} finally {
|
|
2928
|
+
process.removeListener("SIGINT", interrupt);
|
|
2929
|
+
process.removeListener("SIGTERM", interrupt);
|
|
2930
|
+
try {
|
|
2931
|
+
await loader.dispose();
|
|
2932
|
+
} finally {
|
|
2933
|
+
try {
|
|
2934
|
+
await output?.close();
|
|
2935
|
+
} finally {
|
|
2936
|
+
try {
|
|
2937
|
+
await source?.close();
|
|
2938
|
+
} finally {
|
|
2939
|
+
process.stderr.write(`classification summary: ${JSON.stringify(counts)}${interrupted ? " (interrupted)" : ""}
|
|
2940
|
+
`);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2330
2947
|
// src/cli/doctor.ts
|
|
2331
2948
|
init_errors();
|
|
2332
2949
|
import { execFile as execFile3 } from "child_process";
|
|
2333
|
-
import
|
|
2950
|
+
import os4 from "os";
|
|
2334
2951
|
import { promisify } from "util";
|
|
2335
|
-
import
|
|
2336
|
-
import
|
|
2952
|
+
import fsp10 from "fs/promises";
|
|
2953
|
+
import path9 from "path";
|
|
2337
2954
|
|
|
2338
2955
|
// src/models/platform.ts
|
|
2339
2956
|
init_errors();
|
|
2340
2957
|
import { execFile as execFile2 } from "child_process";
|
|
2341
|
-
import
|
|
2958
|
+
import os3 from "os";
|
|
2342
2959
|
function exec(cmd, args, timeoutMs) {
|
|
2343
2960
|
return new Promise((resolve, reject) => {
|
|
2344
2961
|
execFile2(cmd, args, { timeout: timeoutMs, encoding: "utf8" }, (err, stdout) => {
|
|
@@ -2391,7 +3008,7 @@ async function detectPlatform() {
|
|
|
2391
3008
|
const info = {
|
|
2392
3009
|
platform,
|
|
2393
3010
|
arch: process.arch,
|
|
2394
|
-
totalMemBytes:
|
|
3011
|
+
totalMemBytes: os3.totalmem()
|
|
2395
3012
|
};
|
|
2396
3013
|
if (platform === "darwin") info.gpu = await gpuInfoDarwin();
|
|
2397
3014
|
else if (platform === "linux") info.gpu = await gpuInfoLinux();
|
|
@@ -2485,16 +3102,16 @@ async function checkBinaryVersion(binaryPath) {
|
|
|
2485
3102
|
}
|
|
2486
3103
|
}
|
|
2487
3104
|
async function detectMcpConfigs() {
|
|
2488
|
-
const home =
|
|
3105
|
+
const home = os4.homedir();
|
|
2489
3106
|
const hints = [
|
|
2490
|
-
{ client: "Codex", path:
|
|
2491
|
-
{ client: "Claude Code (user)", path:
|
|
2492
|
-
{ client: "Claude Code (project)", path:
|
|
2493
|
-
{ client: "Cursor", path:
|
|
3107
|
+
{ client: "Codex", path: path9.join(home, ".codex", "config.toml"), configured: false },
|
|
3108
|
+
{ client: "Claude Code (user)", path: path9.join(home, ".claude.json"), configured: false },
|
|
3109
|
+
{ client: "Claude Code (project)", path: path9.resolve(".mcp.json"), configured: false },
|
|
3110
|
+
{ client: "Cursor", path: path9.join(process.cwd(), ".cursor", "mcp.json"), configured: false }
|
|
2494
3111
|
];
|
|
2495
3112
|
for (const hint of hints) {
|
|
2496
3113
|
try {
|
|
2497
|
-
const content = await
|
|
3114
|
+
const content = await fsp10.readFile(hint.path, "utf8");
|
|
2498
3115
|
hint.configured = content.includes("clef-mcp");
|
|
2499
3116
|
} catch {
|
|
2500
3117
|
hint.configured = false;
|
|
@@ -2707,14 +3324,14 @@ init_errors();
|
|
|
2707
3324
|
// src/models/llama-install.ts
|
|
2708
3325
|
init_errors();
|
|
2709
3326
|
import { execFile as execFile4 } from "child_process";
|
|
2710
|
-
import
|
|
2711
|
-
import
|
|
3327
|
+
import fsp12 from "fs/promises";
|
|
3328
|
+
import path11 from "path";
|
|
2712
3329
|
|
|
2713
3330
|
// src/models/hf.ts
|
|
2714
3331
|
init_errors();
|
|
2715
|
-
import { createHash as
|
|
2716
|
-
import
|
|
2717
|
-
import
|
|
3332
|
+
import { createHash as createHash4 } from "crypto";
|
|
3333
|
+
import fsp11 from "fs/promises";
|
|
3334
|
+
import path10 from "path";
|
|
2718
3335
|
var HF_BASE_URL = "https://huggingface.co";
|
|
2719
3336
|
function resolveUrl(repo, revision, file) {
|
|
2720
3337
|
return `${HF_BASE_URL}/${repo}/resolve/${revision}/${file}`;
|
|
@@ -2771,8 +3388,8 @@ async function downloadOnce(url, dest, opts) {
|
|
|
2771
3388
|
throw new Error(`HTTP ${res.status}`);
|
|
2772
3389
|
}
|
|
2773
3390
|
const total = opts.expectedSize ?? (res.headers.get("content-length") ? Number(res.headers.get("content-length")) : void 0);
|
|
2774
|
-
const hash =
|
|
2775
|
-
const handle = await
|
|
3391
|
+
const hash = createHash4("sha256");
|
|
3392
|
+
const handle = await fsp11.open(dest, "w");
|
|
2776
3393
|
let bytes = 0;
|
|
2777
3394
|
try {
|
|
2778
3395
|
for await (const chunk of res.body) {
|
|
@@ -2787,7 +3404,7 @@ async function downloadOnce(url, dest, opts) {
|
|
|
2787
3404
|
}
|
|
2788
3405
|
const sha256 = hash.digest("hex");
|
|
2789
3406
|
if (opts.expectedSha256 && sha256 !== opts.expectedSha256) {
|
|
2790
|
-
await
|
|
3407
|
+
await fsp11.rm(dest, { force: true });
|
|
2791
3408
|
throw new ClefError(
|
|
2792
3409
|
ClefErrorCode.CHECKSUM_MISMATCH,
|
|
2793
3410
|
`Checksum mismatch after download: expected ${opts.expectedSha256}, got ${sha256}`,
|
|
@@ -2795,7 +3412,7 @@ async function downloadOnce(url, dest, opts) {
|
|
|
2795
3412
|
);
|
|
2796
3413
|
}
|
|
2797
3414
|
if (opts.expectedSize !== void 0 && bytes !== opts.expectedSize) {
|
|
2798
|
-
await
|
|
3415
|
+
await fsp11.rm(dest, { force: true });
|
|
2799
3416
|
throw new ClefError(
|
|
2800
3417
|
ClefErrorCode.DOWNLOAD_FAILED,
|
|
2801
3418
|
`Downloaded size mismatch: expected ${opts.expectedSize} bytes, got ${bytes}`
|
|
@@ -2813,17 +3430,17 @@ async function installModel(opts) {
|
|
|
2813
3430
|
return { manifest: existing, alreadyInstalled: true };
|
|
2814
3431
|
}
|
|
2815
3432
|
const info = await fetchFileInfo(modelSpec.repo, modelSpec.revision, quantSpec.file);
|
|
2816
|
-
const finalPath =
|
|
2817
|
-
await
|
|
2818
|
-
const tmpPath =
|
|
2819
|
-
await
|
|
3433
|
+
const finalPath = path10.join(path10.dirname(manifestPath2), quantSpec.file);
|
|
3434
|
+
await fsp11.mkdir(path10.dirname(finalPath), { recursive: true });
|
|
3435
|
+
const tmpPath = path10.join(path10.dirname(finalPath), `.download-${quantSpec.file}`);
|
|
3436
|
+
await fsp11.rm(tmpPath, { force: true });
|
|
2820
3437
|
opts.log?.info("downloading model", { repo: modelSpec.repo, file: quantSpec.file });
|
|
2821
3438
|
const result = await downloadFile(info.downloadUrl, tmpPath, {
|
|
2822
3439
|
expectedSha256: info.sha256,
|
|
2823
3440
|
expectedSize: info.size ?? quantSpec.bytes,
|
|
2824
3441
|
onProgress: opts.onProgress
|
|
2825
3442
|
});
|
|
2826
|
-
await
|
|
3443
|
+
await fsp11.rename(tmpPath, finalPath);
|
|
2827
3444
|
const manifest = {
|
|
2828
3445
|
schemaVersion: 1,
|
|
2829
3446
|
model: modelSpec.id,
|
|
@@ -2911,12 +3528,12 @@ async function findServerBinary(dir, platform) {
|
|
|
2911
3528
|
const current = queue.shift();
|
|
2912
3529
|
let entries;
|
|
2913
3530
|
try {
|
|
2914
|
-
entries = await
|
|
3531
|
+
entries = await fsp12.readdir(current, { withFileTypes: true });
|
|
2915
3532
|
} catch {
|
|
2916
3533
|
continue;
|
|
2917
3534
|
}
|
|
2918
3535
|
for (const entry of entries) {
|
|
2919
|
-
const full =
|
|
3536
|
+
const full = path11.join(current, entry.name);
|
|
2920
3537
|
if (entry.isDirectory()) queue.push(full);
|
|
2921
3538
|
else if (entry.name === target) return full;
|
|
2922
3539
|
}
|
|
@@ -2927,14 +3544,14 @@ async function findServerBinary(dir, platform) {
|
|
|
2927
3544
|
);
|
|
2928
3545
|
}
|
|
2929
3546
|
async function extractRelease(archivePath, destDir, platform) {
|
|
2930
|
-
await
|
|
3547
|
+
await fsp12.mkdir(destDir, { recursive: true });
|
|
2931
3548
|
const args = archivePath.endsWith(".zip") ? ["-xf", archivePath] : ["-xzf", archivePath];
|
|
2932
3549
|
try {
|
|
2933
3550
|
await execTar(args, destDir);
|
|
2934
3551
|
} catch (err) {
|
|
2935
3552
|
throw new ClefError(
|
|
2936
3553
|
ClefErrorCode.DOWNLOAD_FAILED,
|
|
2937
|
-
`Could not extract ${
|
|
3554
|
+
`Could not extract ${path11.basename(archivePath)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2938
3555
|
"Extract the archive manually and point CLEF_LLAMA_BIN at llama-server."
|
|
2939
3556
|
);
|
|
2940
3557
|
}
|
|
@@ -2944,7 +3561,7 @@ async function ensureLlamaRuntime(cfg, log) {
|
|
|
2944
3561
|
const resolved = resolveLlamaServerBinary(cfg);
|
|
2945
3562
|
if (resolved.source === "managed") {
|
|
2946
3563
|
const platformDir = llamaRuntimeDir(pathsFor(cfg.clefHome).runtimeDir, process.platform, process.arch);
|
|
2947
|
-
const manifest = readRuntimeManifest(
|
|
3564
|
+
const manifest = readRuntimeManifest(path11.join(platformDir, "runtime-manifest.json"));
|
|
2948
3565
|
return { ...resolved, releaseTag: manifest?.releaseTag ?? "unknown", freshInstall: false };
|
|
2949
3566
|
}
|
|
2950
3567
|
return { ...resolved, releaseTag: resolved.source, freshInstall: false };
|
|
@@ -2976,16 +3593,16 @@ async function downloadManagedLlama(cfg, log) {
|
|
|
2976
3593
|
}
|
|
2977
3594
|
const asset = pickAsset(release, platform, arch);
|
|
2978
3595
|
log?.info("downloading llama.cpp runtime", { tag: release.tag_name, asset: asset.name });
|
|
2979
|
-
const tagDir =
|
|
2980
|
-
await
|
|
2981
|
-
const tmpArchive =
|
|
2982
|
-
await
|
|
3596
|
+
const tagDir = path11.join(platformDir, release.tag_name);
|
|
3597
|
+
await fsp12.mkdir(tagDir, { recursive: true });
|
|
3598
|
+
const tmpArchive = path11.join(platformDir, `.download-${asset.name}`);
|
|
3599
|
+
await fsp12.rm(tmpArchive, { force: true });
|
|
2983
3600
|
const result = await downloadFile(asset.browser_download_url, tmpArchive, { expectedSize: asset.size, attempts: 3 });
|
|
2984
3601
|
await extractRelease(tmpArchive, tagDir, platform);
|
|
2985
|
-
await
|
|
3602
|
+
await fsp12.rm(tmpArchive, { force: true });
|
|
2986
3603
|
const binaryPath = await findServerBinary(tagDir, platform);
|
|
2987
|
-
if (platform !== "win32") await
|
|
2988
|
-
await writeRuntimeManifest(
|
|
3604
|
+
if (platform !== "win32") await fsp12.chmod(binaryPath, 493);
|
|
3605
|
+
await writeRuntimeManifest(path11.join(platformDir, "runtime-manifest.json"), {
|
|
2989
3606
|
schemaVersion: 1,
|
|
2990
3607
|
kind: "llama-cpp",
|
|
2991
3608
|
releaseTag: release.tag_name,
|
|
@@ -3001,11 +3618,11 @@ async function removeManagedRuntime(cfg) {
|
|
|
3001
3618
|
const { runtimeDir } = pathsFor(cfg.clefHome);
|
|
3002
3619
|
const dir = llamaRuntimeDir(runtimeDir, process.platform, process.arch);
|
|
3003
3620
|
try {
|
|
3004
|
-
await
|
|
3621
|
+
await fsp12.stat(dir);
|
|
3005
3622
|
} catch {
|
|
3006
3623
|
return false;
|
|
3007
3624
|
}
|
|
3008
|
-
await
|
|
3625
|
+
await fsp12.rm(dir, { recursive: true, force: true });
|
|
3009
3626
|
return true;
|
|
3010
3627
|
}
|
|
3011
3628
|
|
|
@@ -3013,7 +3630,7 @@ async function removeManagedRuntime(cfg) {
|
|
|
3013
3630
|
init_prompt();
|
|
3014
3631
|
|
|
3015
3632
|
// src/version.ts
|
|
3016
|
-
var VERSION = "0.
|
|
3633
|
+
var VERSION = "0.4.0";
|
|
3017
3634
|
|
|
3018
3635
|
// src/cli/install.ts
|
|
3019
3636
|
var OS_NAMES = {
|
|
@@ -3202,7 +3819,7 @@ async function runStatus() {
|
|
|
3202
3819
|
|
|
3203
3820
|
// src/cli/uninstall.ts
|
|
3204
3821
|
init_errors();
|
|
3205
|
-
import
|
|
3822
|
+
import fsp14 from "fs/promises";
|
|
3206
3823
|
init_prompt();
|
|
3207
3824
|
async function runUninstall(opts) {
|
|
3208
3825
|
const config = loadConfig();
|
|
@@ -3241,7 +3858,7 @@ async function runUninstall(opts) {
|
|
|
3241
3858
|
return;
|
|
3242
3859
|
}
|
|
3243
3860
|
if (target) {
|
|
3244
|
-
await
|
|
3861
|
+
await fsp14.rm(target, { recursive: true, force: true });
|
|
3245
3862
|
console.log(`Removed: ${target}`);
|
|
3246
3863
|
}
|
|
3247
3864
|
if (opts.runtime) {
|
|
@@ -3268,6 +3885,13 @@ program.action(async () => {
|
|
|
3268
3885
|
handleCliError(err);
|
|
3269
3886
|
}
|
|
3270
3887
|
});
|
|
3888
|
+
program.command("classify <input>").description('Classify JSONL/CSV records using a YAML profile; uncertain results require review. Use "-" for stdin.').requiredOption("--profile <path>", "YAML classification profile").option("--output <path>", "JSONL output file (default: stdout; existing files are protected)").option("--format <format>", "input format: jsonl or csv (default: file extension, JSONL for stdin)").option("--model <id>", "override the profile model and CLEF_MODEL").option("--resume", "resume a matching file input/output checkpoint").action(async (input, opts) => {
|
|
3889
|
+
try {
|
|
3890
|
+
process.exit(await runClassify(input, opts));
|
|
3891
|
+
} catch (err) {
|
|
3892
|
+
handleCliError(err);
|
|
3893
|
+
}
|
|
3894
|
+
});
|
|
3271
3895
|
program.command("decide").description("One-shot decision (no MCP session): JSON in, strict JSON result out. For hooks, CI and scripts.").option("--state <source>", 'state: file path, "-" for stdin, or inline JSON/text').option("--questions <source>", 'questions map: file path, "-" for stdin, or inline JSON').option("--model <id>", "model to use (default: CLEF_MODEL or clef-flash)").option("--daemon", "route through a running `clef-mcp daemon` (falls back to a cold run when none is reachable)").action(async (opts) => {
|
|
3272
3896
|
try {
|
|
3273
3897
|
await runDecide(opts);
|