clef-mcp 0.3.1 → 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 +127 -11
- package/dist/cli.js +670 -69
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +10 -1
- 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/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 };
|
|
@@ -2350,18 +2359,603 @@ function validateDoc(config, doc) {
|
|
|
2350
2359
|
return parsed;
|
|
2351
2360
|
}
|
|
2352
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
|
+
|
|
2353
2947
|
// src/cli/doctor.ts
|
|
2354
2948
|
init_errors();
|
|
2355
2949
|
import { execFile as execFile3 } from "child_process";
|
|
2356
|
-
import
|
|
2950
|
+
import os4 from "os";
|
|
2357
2951
|
import { promisify } from "util";
|
|
2358
|
-
import
|
|
2359
|
-
import
|
|
2952
|
+
import fsp10 from "fs/promises";
|
|
2953
|
+
import path9 from "path";
|
|
2360
2954
|
|
|
2361
2955
|
// src/models/platform.ts
|
|
2362
2956
|
init_errors();
|
|
2363
2957
|
import { execFile as execFile2 } from "child_process";
|
|
2364
|
-
import
|
|
2958
|
+
import os3 from "os";
|
|
2365
2959
|
function exec(cmd, args, timeoutMs) {
|
|
2366
2960
|
return new Promise((resolve, reject) => {
|
|
2367
2961
|
execFile2(cmd, args, { timeout: timeoutMs, encoding: "utf8" }, (err, stdout) => {
|
|
@@ -2414,7 +3008,7 @@ async function detectPlatform() {
|
|
|
2414
3008
|
const info = {
|
|
2415
3009
|
platform,
|
|
2416
3010
|
arch: process.arch,
|
|
2417
|
-
totalMemBytes:
|
|
3011
|
+
totalMemBytes: os3.totalmem()
|
|
2418
3012
|
};
|
|
2419
3013
|
if (platform === "darwin") info.gpu = await gpuInfoDarwin();
|
|
2420
3014
|
else if (platform === "linux") info.gpu = await gpuInfoLinux();
|
|
@@ -2508,16 +3102,16 @@ async function checkBinaryVersion(binaryPath) {
|
|
|
2508
3102
|
}
|
|
2509
3103
|
}
|
|
2510
3104
|
async function detectMcpConfigs() {
|
|
2511
|
-
const home =
|
|
3105
|
+
const home = os4.homedir();
|
|
2512
3106
|
const hints = [
|
|
2513
|
-
{ client: "Codex", path:
|
|
2514
|
-
{ client: "Claude Code (user)", path:
|
|
2515
|
-
{ client: "Claude Code (project)", path:
|
|
2516
|
-
{ 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 }
|
|
2517
3111
|
];
|
|
2518
3112
|
for (const hint of hints) {
|
|
2519
3113
|
try {
|
|
2520
|
-
const content = await
|
|
3114
|
+
const content = await fsp10.readFile(hint.path, "utf8");
|
|
2521
3115
|
hint.configured = content.includes("clef-mcp");
|
|
2522
3116
|
} catch {
|
|
2523
3117
|
hint.configured = false;
|
|
@@ -2730,14 +3324,14 @@ init_errors();
|
|
|
2730
3324
|
// src/models/llama-install.ts
|
|
2731
3325
|
init_errors();
|
|
2732
3326
|
import { execFile as execFile4 } from "child_process";
|
|
2733
|
-
import
|
|
2734
|
-
import
|
|
3327
|
+
import fsp12 from "fs/promises";
|
|
3328
|
+
import path11 from "path";
|
|
2735
3329
|
|
|
2736
3330
|
// src/models/hf.ts
|
|
2737
3331
|
init_errors();
|
|
2738
|
-
import { createHash as
|
|
2739
|
-
import
|
|
2740
|
-
import
|
|
3332
|
+
import { createHash as createHash4 } from "crypto";
|
|
3333
|
+
import fsp11 from "fs/promises";
|
|
3334
|
+
import path10 from "path";
|
|
2741
3335
|
var HF_BASE_URL = "https://huggingface.co";
|
|
2742
3336
|
function resolveUrl(repo, revision, file) {
|
|
2743
3337
|
return `${HF_BASE_URL}/${repo}/resolve/${revision}/${file}`;
|
|
@@ -2794,8 +3388,8 @@ async function downloadOnce(url, dest, opts) {
|
|
|
2794
3388
|
throw new Error(`HTTP ${res.status}`);
|
|
2795
3389
|
}
|
|
2796
3390
|
const total = opts.expectedSize ?? (res.headers.get("content-length") ? Number(res.headers.get("content-length")) : void 0);
|
|
2797
|
-
const hash =
|
|
2798
|
-
const handle = await
|
|
3391
|
+
const hash = createHash4("sha256");
|
|
3392
|
+
const handle = await fsp11.open(dest, "w");
|
|
2799
3393
|
let bytes = 0;
|
|
2800
3394
|
try {
|
|
2801
3395
|
for await (const chunk of res.body) {
|
|
@@ -2810,7 +3404,7 @@ async function downloadOnce(url, dest, opts) {
|
|
|
2810
3404
|
}
|
|
2811
3405
|
const sha256 = hash.digest("hex");
|
|
2812
3406
|
if (opts.expectedSha256 && sha256 !== opts.expectedSha256) {
|
|
2813
|
-
await
|
|
3407
|
+
await fsp11.rm(dest, { force: true });
|
|
2814
3408
|
throw new ClefError(
|
|
2815
3409
|
ClefErrorCode.CHECKSUM_MISMATCH,
|
|
2816
3410
|
`Checksum mismatch after download: expected ${opts.expectedSha256}, got ${sha256}`,
|
|
@@ -2818,7 +3412,7 @@ async function downloadOnce(url, dest, opts) {
|
|
|
2818
3412
|
);
|
|
2819
3413
|
}
|
|
2820
3414
|
if (opts.expectedSize !== void 0 && bytes !== opts.expectedSize) {
|
|
2821
|
-
await
|
|
3415
|
+
await fsp11.rm(dest, { force: true });
|
|
2822
3416
|
throw new ClefError(
|
|
2823
3417
|
ClefErrorCode.DOWNLOAD_FAILED,
|
|
2824
3418
|
`Downloaded size mismatch: expected ${opts.expectedSize} bytes, got ${bytes}`
|
|
@@ -2836,17 +3430,17 @@ async function installModel(opts) {
|
|
|
2836
3430
|
return { manifest: existing, alreadyInstalled: true };
|
|
2837
3431
|
}
|
|
2838
3432
|
const info = await fetchFileInfo(modelSpec.repo, modelSpec.revision, quantSpec.file);
|
|
2839
|
-
const finalPath =
|
|
2840
|
-
await
|
|
2841
|
-
const tmpPath =
|
|
2842
|
-
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 });
|
|
2843
3437
|
opts.log?.info("downloading model", { repo: modelSpec.repo, file: quantSpec.file });
|
|
2844
3438
|
const result = await downloadFile(info.downloadUrl, tmpPath, {
|
|
2845
3439
|
expectedSha256: info.sha256,
|
|
2846
3440
|
expectedSize: info.size ?? quantSpec.bytes,
|
|
2847
3441
|
onProgress: opts.onProgress
|
|
2848
3442
|
});
|
|
2849
|
-
await
|
|
3443
|
+
await fsp11.rename(tmpPath, finalPath);
|
|
2850
3444
|
const manifest = {
|
|
2851
3445
|
schemaVersion: 1,
|
|
2852
3446
|
model: modelSpec.id,
|
|
@@ -2934,12 +3528,12 @@ async function findServerBinary(dir, platform) {
|
|
|
2934
3528
|
const current = queue.shift();
|
|
2935
3529
|
let entries;
|
|
2936
3530
|
try {
|
|
2937
|
-
entries = await
|
|
3531
|
+
entries = await fsp12.readdir(current, { withFileTypes: true });
|
|
2938
3532
|
} catch {
|
|
2939
3533
|
continue;
|
|
2940
3534
|
}
|
|
2941
3535
|
for (const entry of entries) {
|
|
2942
|
-
const full =
|
|
3536
|
+
const full = path11.join(current, entry.name);
|
|
2943
3537
|
if (entry.isDirectory()) queue.push(full);
|
|
2944
3538
|
else if (entry.name === target) return full;
|
|
2945
3539
|
}
|
|
@@ -2950,14 +3544,14 @@ async function findServerBinary(dir, platform) {
|
|
|
2950
3544
|
);
|
|
2951
3545
|
}
|
|
2952
3546
|
async function extractRelease(archivePath, destDir, platform) {
|
|
2953
|
-
await
|
|
3547
|
+
await fsp12.mkdir(destDir, { recursive: true });
|
|
2954
3548
|
const args = archivePath.endsWith(".zip") ? ["-xf", archivePath] : ["-xzf", archivePath];
|
|
2955
3549
|
try {
|
|
2956
3550
|
await execTar(args, destDir);
|
|
2957
3551
|
} catch (err) {
|
|
2958
3552
|
throw new ClefError(
|
|
2959
3553
|
ClefErrorCode.DOWNLOAD_FAILED,
|
|
2960
|
-
`Could not extract ${
|
|
3554
|
+
`Could not extract ${path11.basename(archivePath)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2961
3555
|
"Extract the archive manually and point CLEF_LLAMA_BIN at llama-server."
|
|
2962
3556
|
);
|
|
2963
3557
|
}
|
|
@@ -2967,7 +3561,7 @@ async function ensureLlamaRuntime(cfg, log) {
|
|
|
2967
3561
|
const resolved = resolveLlamaServerBinary(cfg);
|
|
2968
3562
|
if (resolved.source === "managed") {
|
|
2969
3563
|
const platformDir = llamaRuntimeDir(pathsFor(cfg.clefHome).runtimeDir, process.platform, process.arch);
|
|
2970
|
-
const manifest = readRuntimeManifest(
|
|
3564
|
+
const manifest = readRuntimeManifest(path11.join(platformDir, "runtime-manifest.json"));
|
|
2971
3565
|
return { ...resolved, releaseTag: manifest?.releaseTag ?? "unknown", freshInstall: false };
|
|
2972
3566
|
}
|
|
2973
3567
|
return { ...resolved, releaseTag: resolved.source, freshInstall: false };
|
|
@@ -2999,16 +3593,16 @@ async function downloadManagedLlama(cfg, log) {
|
|
|
2999
3593
|
}
|
|
3000
3594
|
const asset = pickAsset(release, platform, arch);
|
|
3001
3595
|
log?.info("downloading llama.cpp runtime", { tag: release.tag_name, asset: asset.name });
|
|
3002
|
-
const tagDir =
|
|
3003
|
-
await
|
|
3004
|
-
const tmpArchive =
|
|
3005
|
-
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 });
|
|
3006
3600
|
const result = await downloadFile(asset.browser_download_url, tmpArchive, { expectedSize: asset.size, attempts: 3 });
|
|
3007
3601
|
await extractRelease(tmpArchive, tagDir, platform);
|
|
3008
|
-
await
|
|
3602
|
+
await fsp12.rm(tmpArchive, { force: true });
|
|
3009
3603
|
const binaryPath = await findServerBinary(tagDir, platform);
|
|
3010
|
-
if (platform !== "win32") await
|
|
3011
|
-
await writeRuntimeManifest(
|
|
3604
|
+
if (platform !== "win32") await fsp12.chmod(binaryPath, 493);
|
|
3605
|
+
await writeRuntimeManifest(path11.join(platformDir, "runtime-manifest.json"), {
|
|
3012
3606
|
schemaVersion: 1,
|
|
3013
3607
|
kind: "llama-cpp",
|
|
3014
3608
|
releaseTag: release.tag_name,
|
|
@@ -3024,11 +3618,11 @@ async function removeManagedRuntime(cfg) {
|
|
|
3024
3618
|
const { runtimeDir } = pathsFor(cfg.clefHome);
|
|
3025
3619
|
const dir = llamaRuntimeDir(runtimeDir, process.platform, process.arch);
|
|
3026
3620
|
try {
|
|
3027
|
-
await
|
|
3621
|
+
await fsp12.stat(dir);
|
|
3028
3622
|
} catch {
|
|
3029
3623
|
return false;
|
|
3030
3624
|
}
|
|
3031
|
-
await
|
|
3625
|
+
await fsp12.rm(dir, { recursive: true, force: true });
|
|
3032
3626
|
return true;
|
|
3033
3627
|
}
|
|
3034
3628
|
|
|
@@ -3036,7 +3630,7 @@ async function removeManagedRuntime(cfg) {
|
|
|
3036
3630
|
init_prompt();
|
|
3037
3631
|
|
|
3038
3632
|
// src/version.ts
|
|
3039
|
-
var VERSION = "0.
|
|
3633
|
+
var VERSION = "0.4.0";
|
|
3040
3634
|
|
|
3041
3635
|
// src/cli/install.ts
|
|
3042
3636
|
var OS_NAMES = {
|
|
@@ -3225,7 +3819,7 @@ async function runStatus() {
|
|
|
3225
3819
|
|
|
3226
3820
|
// src/cli/uninstall.ts
|
|
3227
3821
|
init_errors();
|
|
3228
|
-
import
|
|
3822
|
+
import fsp14 from "fs/promises";
|
|
3229
3823
|
init_prompt();
|
|
3230
3824
|
async function runUninstall(opts) {
|
|
3231
3825
|
const config = loadConfig();
|
|
@@ -3264,7 +3858,7 @@ async function runUninstall(opts) {
|
|
|
3264
3858
|
return;
|
|
3265
3859
|
}
|
|
3266
3860
|
if (target) {
|
|
3267
|
-
await
|
|
3861
|
+
await fsp14.rm(target, { recursive: true, force: true });
|
|
3268
3862
|
console.log(`Removed: ${target}`);
|
|
3269
3863
|
}
|
|
3270
3864
|
if (opts.runtime) {
|
|
@@ -3291,6 +3885,13 @@ program.action(async () => {
|
|
|
3291
3885
|
handleCliError(err);
|
|
3292
3886
|
}
|
|
3293
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
|
+
});
|
|
3294
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) => {
|
|
3295
3896
|
try {
|
|
3296
3897
|
await runDecide(opts);
|