@0xgeorgemathew/zcode 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -13
- package/bin/zcode.js +119 -19
- package/lib/bootstrap.js +2 -2
- package/lib/brief.js +69 -0
- package/lib/checks.js +3 -2
- package/lib/commands/doctor.js +3 -0
- package/lib/commands/exec.js +100 -35
- package/lib/commands/plan.js +301 -0
- package/lib/commands/sessions.js +60 -11
- package/lib/contract.js +220 -17
- package/lib/executor.js +141 -34
- package/lib/isolation.js +4 -1
- package/lib/models.js +84 -0
- package/lib/plan.js +424 -0
- package/lib/ports.js +45 -0
- package/lib/store.js +50 -0
- package/lib/taskspec.js +107 -0
- package/lib/ui.js +7 -2
- package/lib/workspace.js +24 -7
- package/package.json +1 -1
- package/skills/README.md +1 -1
- package/skills/claude-code/zcode/SKILL.md +104 -74
- package/skills/codex/zcode/SKILL.md +104 -76
package/lib/executor.js
CHANGED
|
@@ -19,9 +19,11 @@ const { resolveRuntime, makeClient } = require('./bootstrap.js');
|
|
|
19
19
|
const { buildEngineHome, rootDir, liveDir } = require('./isolation.js');
|
|
20
20
|
const { openSession, runTurn, TurnError } = require('./appserver.js');
|
|
21
21
|
const { runChecks } = require('./checks.js');
|
|
22
|
+
const { reservePort } = require('./ports.js');
|
|
22
23
|
const {
|
|
23
24
|
workspaceRoot,
|
|
24
25
|
isOwned,
|
|
26
|
+
isZcodePath,
|
|
25
27
|
acquireLease,
|
|
26
28
|
liveLeases,
|
|
27
29
|
snapshot,
|
|
@@ -34,6 +36,9 @@ const {
|
|
|
34
36
|
HANDBACK_REQUEST,
|
|
35
37
|
parseHandback,
|
|
36
38
|
deriveStatus,
|
|
39
|
+
unmetVisual,
|
|
40
|
+
visualGaps,
|
|
41
|
+
buildVisualReportRequest,
|
|
37
42
|
} = require('./contract.js');
|
|
38
43
|
|
|
39
44
|
const RESULT_SCHEMA = 'zcode.result/1';
|
|
@@ -52,7 +57,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
52
57
|
// Settings a session keeps across invocations (stored as invocation.json).
|
|
53
58
|
const SETTING_KEYS = [
|
|
54
59
|
'kind', 'label', 'cwd', 'model', 'effort', 'timeoutMs', 'maxRepairs', 'checkTimeoutMs',
|
|
55
|
-
'checks', 'owns', 'ownsDisplay', 'acceptanceDeclared', 'mcpSpecs', 'mcpInherit', 'noBrowser',
|
|
60
|
+
'checks', 'preflight', 'owns', 'ownsDisplay', 'touch', 'touchDisplay', 'acceptanceDeclared', 'mcpSpecs', 'mcpInherit', 'noBrowser',
|
|
56
61
|
'skillsDirs', 'noSkills', 'profile', 'includeReasoning', 'allowOverlap', 'stream',
|
|
57
62
|
];
|
|
58
63
|
|
|
@@ -159,6 +164,8 @@ function prepareSession(inv, env = process.env) {
|
|
|
159
164
|
workspaceRoot: workspaceRoot(inv.cwd),
|
|
160
165
|
owns: inv.ownsDisplay || [],
|
|
161
166
|
ownsRoot: inv.owns || [],
|
|
167
|
+
touch: inv.touchDisplay || [],
|
|
168
|
+
preflight: Boolean(inv.preflight),
|
|
162
169
|
checks: inv.checks || [],
|
|
163
170
|
model: inv.model,
|
|
164
171
|
effort: inv.effort,
|
|
@@ -210,7 +217,13 @@ async function execute(inv, opts = {}) {
|
|
|
210
217
|
if (releaseSlot) releaseSlot();
|
|
211
218
|
releaseOwner();
|
|
212
219
|
};
|
|
220
|
+
// Resuming a preflight session proceeds to the real task: answers + the
|
|
221
|
+
// full protocol, checks and visual verification from here on.
|
|
222
|
+
const leavingPreflight = resuming && Boolean(inv.preflight);
|
|
223
|
+
if (leavingPreflight) inv.preflight = false;
|
|
224
|
+
const preflight = Boolean(inv.preflight);
|
|
213
225
|
if (resuming) writeSettings(id, inv, env);
|
|
226
|
+
if (leavingPreflight) store.updateSession(id, { preflight: false }, env);
|
|
214
227
|
const isFirstInvocation = !record.engineSessionId;
|
|
215
228
|
const update = (patch) => store.updateSession(id, patch, env);
|
|
216
229
|
|
|
@@ -285,14 +298,19 @@ async function execute(inv, opts = {}) {
|
|
|
285
298
|
}
|
|
286
299
|
|
|
287
300
|
// --- lease ------------------------------------------------------------------
|
|
288
|
-
|
|
301
|
+
// A preflight is read-only: it reserves nothing and conflicts with nobody.
|
|
302
|
+
const lease = preflight
|
|
303
|
+
? { ok: true, conflicts: [], others: [], release() {} }
|
|
304
|
+
: acquireLease({ id, root, owns: inv.owns || [], touch: inv.touch || [], allowOverlap: inv.allowOverlap, env });
|
|
289
305
|
if (!lease.ok) {
|
|
290
306
|
const names = lease.conflicts
|
|
291
307
|
.map((c) => `${c.id} (owns ${c.owns && c.owns.length ? c.owns.join(', ') : 'the whole workspace'})`)
|
|
292
308
|
.join('; ');
|
|
293
|
-
const message =
|
|
294
|
-
`workspace conflict in ${root}:
|
|
295
|
-
|
|
309
|
+
const message = lease.touch
|
|
310
|
+
? `workspace conflict in ${root}: --touch ${(inv.touchDisplay || []).join(', ')} is owned by running session(s) ${names}. ` +
|
|
311
|
+
'Wait for them, touch something else, or pass --allow-overlap.'
|
|
312
|
+
: `workspace conflict in ${root}: overlapping ownership with running session(s) ${names}. ` +
|
|
313
|
+
'Declare disjoint --owns paths, use a separate worktree, wait for them, or pass --allow-overlap.';
|
|
296
314
|
if (inv.adopt) {
|
|
297
315
|
// A detached worker: the caller is polling this id, so leave a result.
|
|
298
316
|
store.writeResult(id, {
|
|
@@ -323,6 +341,9 @@ async function execute(inv, opts = {}) {
|
|
|
323
341
|
let subagentNote = null;
|
|
324
342
|
let built = null;
|
|
325
343
|
let before = null;
|
|
344
|
+
let releasePort = null;
|
|
345
|
+
let sessionEnv = null;
|
|
346
|
+
const visualDeclared = (inv.contract && inv.contract.visual) || [];
|
|
326
347
|
const concurrent = new Map(lease.others.map((o) => [o.id, o.owns || []]));
|
|
327
348
|
const checkTouched = new Set(); // files written by zcode's own check runs
|
|
328
349
|
const invocationStart = new Date(started).toISOString();
|
|
@@ -331,6 +352,23 @@ async function execute(inv, opts = {}) {
|
|
|
331
352
|
if (signal && signal.aborted) throw new TurnError('cancelled', { kind: 'cancelled' });
|
|
332
353
|
if (!releaseSlot) throw new TurnError('run exceeded --timeout-ms while queued', { kind: 'timeout' });
|
|
333
354
|
|
|
355
|
+
// Per-session environment for the executor AND the checks: a port no
|
|
356
|
+
// other live session holds, plus output and scratch dirs of its own.
|
|
357
|
+
const reserved = await reservePort({ env, preferred: record.port });
|
|
358
|
+
if (!reserved) throw new ExecError('no free port for ZCODE_PORT in 41000-48999', { exitCode: 5, kind: 'no_port' });
|
|
359
|
+
releasePort = reserved.release;
|
|
360
|
+
const outDir = path.join(record.cwd, '.zcode', 'out', id);
|
|
361
|
+
const tmpDir = path.join(dir, 'tmp');
|
|
362
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
363
|
+
fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
|
|
364
|
+
sessionEnv = {
|
|
365
|
+
ZCODE_SESSION_ID: id,
|
|
366
|
+
ZCODE_PORT: String(reserved.port),
|
|
367
|
+
ZCODE_OUT: outDir,
|
|
368
|
+
ZCODE_TMP: tmpDir,
|
|
369
|
+
};
|
|
370
|
+
update({ port: reserved.port, out: outDir, tmp: tmpDir });
|
|
371
|
+
|
|
334
372
|
built = buildEngineHome({
|
|
335
373
|
home,
|
|
336
374
|
root: rootDir(env),
|
|
@@ -348,7 +386,7 @@ async function execute(inv, opts = {}) {
|
|
|
348
386
|
// nothing has been sent to the model yet).
|
|
349
387
|
setPhase('starting');
|
|
350
388
|
for (let attempt = 1; ; attempt++) {
|
|
351
|
-
client = makeClient(runtime, { home, cwd: record.cwd });
|
|
389
|
+
client = makeClient(runtime, { home, cwd: record.cwd, sessionEnv });
|
|
352
390
|
client.start();
|
|
353
391
|
update({ enginePid: client.child.pid });
|
|
354
392
|
try {
|
|
@@ -373,10 +411,21 @@ async function execute(inv, opts = {}) {
|
|
|
373
411
|
// First prompt for this invocation.
|
|
374
412
|
let prompt;
|
|
375
413
|
if (inv.kind === 'task') {
|
|
376
|
-
|
|
377
|
-
|
|
414
|
+
const taskPrompt = () => buildTaskPrompt(inv.contract, {
|
|
415
|
+
ownsDisplay: inv.ownsDisplay || [],
|
|
416
|
+
session: { id, port: sessionEnv.ZCODE_PORT, out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP },
|
|
417
|
+
preflight,
|
|
418
|
+
});
|
|
419
|
+
prompt = leavingPreflight
|
|
420
|
+
? `${taskPrompt()}\n\n## Answers to your preflight questions\nThe preflight is over: implement the task now (edits are allowed). The orchestrator's answers are binding:\n${(inv.resume.message || '').trim() || '(none: take the most conservative choice for anything open and list it in handback `decisions`)'}`
|
|
421
|
+
: isFirstInvocation
|
|
422
|
+
? taskPrompt() +
|
|
378
423
|
(resuming && inv.resume.message ? `\n\n## Message from the orchestrator\n${inv.resume.message}` : '')
|
|
379
|
-
: buildResumePrompt(inv.resume ? inv.resume.message : '', {
|
|
424
|
+
: buildResumePrompt(inv.resume ? inv.resume.message : '', {
|
|
425
|
+
kind: 'task',
|
|
426
|
+
// The port is the one thing that may have changed since the first prompt.
|
|
427
|
+
portChange: record.port && String(record.port) !== sessionEnv.ZCODE_PORT ? sessionEnv.ZCODE_PORT : null,
|
|
428
|
+
});
|
|
380
429
|
} else if (inv.kind === 'agent') {
|
|
381
430
|
prompt =
|
|
382
431
|
`You MUST use the Agent tool (profileId: "${inv.profile}") to accomplish the ` +
|
|
@@ -387,6 +436,7 @@ async function execute(inv, opts = {}) {
|
|
|
387
436
|
}
|
|
388
437
|
|
|
389
438
|
let askedForHandback = false;
|
|
439
|
+
let askedForVisual = false;
|
|
390
440
|
let phase = 'executing';
|
|
391
441
|
for (;;) {
|
|
392
442
|
setPhase(phase, phase === 'repairing' ? { attempt: repairs, of: inv.maxRepairs } : {});
|
|
@@ -412,28 +462,44 @@ async function execute(inv, opts = {}) {
|
|
|
412
462
|
phase = 'reporting';
|
|
413
463
|
continue;
|
|
414
464
|
}
|
|
465
|
+
if (preflight) break; // read-only: zcode runs nothing
|
|
415
466
|
if (parsed.handback && parsed.handback.status === 'blocked') break;
|
|
416
|
-
if (!inv.checks.length) break;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
467
|
+
if (!inv.checks.length && !visualDeclared.length) break;
|
|
468
|
+
|
|
469
|
+
if (inv.checks.length) {
|
|
470
|
+
setPhase('checking', { count: inv.checks.length });
|
|
471
|
+
const preChecks = before ? snapshot(root) : null;
|
|
472
|
+
checks = await runChecks(inv.checks, {
|
|
473
|
+
cwd: record.cwd,
|
|
474
|
+
env: { ...env, ...sessionEnv },
|
|
475
|
+
timeoutMs: Math.max(1000, Math.min(inv.checkTimeoutMs, deadline - Date.now())),
|
|
476
|
+
signal,
|
|
477
|
+
onEvent: relay,
|
|
478
|
+
});
|
|
479
|
+
if (preChecks) {
|
|
480
|
+
for (const f of diffSnapshots(preChecks, snapshot(root)) || []) checkTouched.add(f.path);
|
|
481
|
+
}
|
|
482
|
+
if (Date.now() >= deadline && checks.some((c) => c.timedOut)) {
|
|
483
|
+
throw new TurnError('run exceeded --timeout-ms during acceptance checks', { kind: 'timeout' });
|
|
484
|
+
}
|
|
431
485
|
}
|
|
432
486
|
const failures = checks.filter((c) => !c.ok);
|
|
433
|
-
|
|
487
|
+
// Unmet visual criteria share the repair budget with failing checks.
|
|
488
|
+
const gaps = visualGaps(parsed.handback, visualDeclared);
|
|
489
|
+
// Criteria merely left out of the handback get one cheap report-only
|
|
490
|
+
// follow-up that does not consume the repair budget.
|
|
491
|
+
if (!failures.length && gaps.missing.length && !gaps.failed.length && !askedForVisual
|
|
492
|
+
&& !(signal && signal.aborted) && Date.now() < deadline) {
|
|
493
|
+
askedForVisual = true;
|
|
494
|
+
prompt = buildVisualReportRequest(gaps.missing);
|
|
495
|
+
phase = 'reporting';
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const visualUnmet = unmetVisual(parsed.handback, visualDeclared);
|
|
499
|
+
if ((!failures.length && !visualUnmet.length) || repairs >= inv.maxRepairs) break;
|
|
434
500
|
if ((signal && signal.aborted) || Date.now() >= deadline) break;
|
|
435
501
|
repairs++;
|
|
436
|
-
prompt = buildRepairPrompt(failures, { attempt: repairs, maxRepairs: inv.maxRepairs });
|
|
502
|
+
prompt = buildRepairPrompt(failures, { attempt: repairs, maxRepairs: inv.maxRepairs, visual: visualUnmet });
|
|
437
503
|
phase = 'repairing';
|
|
438
504
|
}
|
|
439
505
|
|
|
@@ -451,6 +517,7 @@ async function execute(inv, opts = {}) {
|
|
|
451
517
|
error = err;
|
|
452
518
|
} finally {
|
|
453
519
|
if (client) client.kill();
|
|
520
|
+
if (releasePort) releasePort();
|
|
454
521
|
lease.release();
|
|
455
522
|
releaseClaims();
|
|
456
523
|
}
|
|
@@ -475,21 +542,38 @@ async function execute(inv, opts = {}) {
|
|
|
475
542
|
const after = before ? snapshot(root) : null;
|
|
476
543
|
const diff = before ? diffSnapshots(before, after) : null;
|
|
477
544
|
const owns = inv.owns || [];
|
|
545
|
+
const touch = inv.touch || [];
|
|
478
546
|
const files = (diff || [])
|
|
479
|
-
.filter((f) => !f.path
|
|
547
|
+
.filter((f) => !isZcodePath(f.path))
|
|
480
548
|
.map((f) => {
|
|
481
549
|
const owned = isOwned(f.path, owns);
|
|
482
|
-
|
|
550
|
+
// --touch: edits allowed but reported, never a scope violation.
|
|
551
|
+
const touchedByDesign = !owned && touch.length > 0 && isOwned(f.path, touch);
|
|
552
|
+
const by = owned || touchedByDesign
|
|
483
553
|
? null
|
|
484
554
|
: [...concurrent.entries()].find(([, o]) => o.length && isOwned(f.path, o));
|
|
485
|
-
const fromChecks = !owned && !by && checkTouched.has(f.path);
|
|
486
|
-
return {
|
|
555
|
+
const fromChecks = !owned && !touchedByDesign && !by && checkTouched.has(f.path);
|
|
556
|
+
return {
|
|
557
|
+
...f,
|
|
558
|
+
owned,
|
|
559
|
+
...(touchedByDesign ? { touched: true } : {}),
|
|
560
|
+
...(by ? { concurrentSession: by[0] } : {}),
|
|
561
|
+
...(fromChecks ? { fromChecks: true } : {}),
|
|
562
|
+
};
|
|
487
563
|
});
|
|
564
|
+
const touched = files.filter((f) => f.touched).map((f) => f.path);
|
|
488
565
|
const scopeViolations = files
|
|
489
|
-
.filter((f) => !f.owned && !f.concurrentSession && !f.fromChecks)
|
|
566
|
+
.filter((f) => !f.owned && !f.touched && !f.concurrentSession && !f.fromChecks)
|
|
490
567
|
.map((f) => f.path);
|
|
491
568
|
|
|
492
569
|
const handback = parsed.handback;
|
|
570
|
+
// Preflight is read-only: anything it changed (that no concurrent session
|
|
571
|
+
// owns) is a protocol breach.
|
|
572
|
+
const preflightWrote = preflight
|
|
573
|
+
? files
|
|
574
|
+
.filter((f) => ![...concurrent.values()].some((o) => o.length && isOwned(f.path, o)))
|
|
575
|
+
.map((f) => f.path)
|
|
576
|
+
: [];
|
|
493
577
|
let { status, issues } = deriveStatus({
|
|
494
578
|
kind: inv.kind,
|
|
495
579
|
error: error && !cancelled && error.kind !== 'user_input' ? error : null,
|
|
@@ -498,12 +582,16 @@ async function execute(inv, opts = {}) {
|
|
|
498
582
|
handback,
|
|
499
583
|
handbackError: parsed.error,
|
|
500
584
|
checks,
|
|
501
|
-
scopeViolations,
|
|
585
|
+
scopeViolations: preflight ? [] : scopeViolations,
|
|
586
|
+
preflight,
|
|
587
|
+
preflightWrote,
|
|
588
|
+
visualDeclared,
|
|
502
589
|
});
|
|
503
590
|
|
|
504
591
|
let errorOut = null;
|
|
505
592
|
if (status === 'failed') {
|
|
506
593
|
if (error) errorOut = { kind: error.kind || 'error', message: scrub(error.message) };
|
|
594
|
+
else if (preflightWrote.length) errorOut = { kind: 'preflight_wrote', message: issues[0] };
|
|
507
595
|
else if (checks.some((c) => !c.ok)) errorOut = { kind: 'checks_failed', message: issues[0] };
|
|
508
596
|
} else if (status === 'cancelled') {
|
|
509
597
|
errorOut = { kind: 'cancelled', message: 'cancelled' };
|
|
@@ -521,10 +609,19 @@ async function execute(inv, opts = {}) {
|
|
|
521
609
|
}
|
|
522
610
|
|
|
523
611
|
const notVerified = [];
|
|
524
|
-
if (inv.kind === 'task') {
|
|
612
|
+
if (inv.kind === 'task' && preflight) {
|
|
613
|
+
notVerified.push('preflight: the questions come from reading only; nothing was built, run or checked');
|
|
614
|
+
} else if (inv.kind === 'task') {
|
|
525
615
|
if (!inv.checks.length) notVerified.push('no acceptance checks were declared; nothing was executed by zcode to verify the work');
|
|
526
616
|
if (inv.acceptanceDeclared) notVerified.push('prose acceptance criteria are confirmed only by the executor\'s own report');
|
|
527
617
|
if (subagents && subagents.length) notVerified.push('subagent "success" means the subagent returned; only the integrated result is verified');
|
|
618
|
+
if (handback && handback.units.length) {
|
|
619
|
+
notVerified.push(`unit results are executor claims: ${handback.units.map((u) => `${u.name} ${u.status || '?'}`).join(', ')}; only the integrated result is verified`);
|
|
620
|
+
}
|
|
621
|
+
if (visualDeclared.length) {
|
|
622
|
+
const reported = handback ? handback.visual : [];
|
|
623
|
+
notVerified.push(`visual criteria are the executor's judgement from its own screenshots (${reported.filter((v) => v.met === true).length} of ${reported.length} reported met); zcode ran no pixel check`);
|
|
624
|
+
}
|
|
528
625
|
}
|
|
529
626
|
if (before && !diff) notVerified.push('changed files could not be tracked in this workspace');
|
|
530
627
|
if (subagentNote) issues.push(subagentNote);
|
|
@@ -542,7 +639,9 @@ async function execute(inv, opts = {}) {
|
|
|
542
639
|
id,
|
|
543
640
|
kind: inv.kind,
|
|
544
641
|
label: inv.label || null,
|
|
545
|
-
summary:
|
|
642
|
+
summary: preflight && status === 'completed'
|
|
643
|
+
? 'no open questions'
|
|
644
|
+
: handback && handback.summary ? handback.summary : '',
|
|
546
645
|
response: inv.kind === 'task' ? parsed.report : response,
|
|
547
646
|
issues,
|
|
548
647
|
questions,
|
|
@@ -553,6 +652,14 @@ async function execute(inv, opts = {}) {
|
|
|
553
652
|
files,
|
|
554
653
|
},
|
|
555
654
|
scopeViolations,
|
|
655
|
+
touched,
|
|
656
|
+
...(preflight ? { preflight: true } : {}),
|
|
657
|
+
decisions: handback ? handback.decisions : [],
|
|
658
|
+
visual: handback ? handback.visual : [],
|
|
659
|
+
units: handback ? handback.units : [],
|
|
660
|
+
sessionEnv: sessionEnv
|
|
661
|
+
? { port: Number(sessionEnv.ZCODE_PORT), out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP }
|
|
662
|
+
: null,
|
|
556
663
|
checks: checks.map((c) => ({
|
|
557
664
|
command: c.command,
|
|
558
665
|
ok: c.ok,
|
package/lib/isolation.js
CHANGED
|
@@ -360,7 +360,9 @@ function migrateRoot(env = process.env) {
|
|
|
360
360
|
|
|
361
361
|
// Env for the engine child process (§6.3): HOME=scratch, pinned builtin
|
|
362
362
|
// config, and the uncontrolled overrides deleted. cwd is set by the caller.
|
|
363
|
-
|
|
363
|
+
// `sessionEnv` (ZCODE_SESSION_ID, ZCODE_PORT, …) is applied last so nothing
|
|
364
|
+
// inherited can shadow it; the engine's tools inherit it.
|
|
365
|
+
function engineEnv({ home, builtinProviderConfig, parentEnv = process.env, sessionEnv = {} }) {
|
|
364
366
|
const childEnv = { ...parentEnv };
|
|
365
367
|
const realHome = parentEnv.HOME || os.homedir();
|
|
366
368
|
// The scratch HOME hides the user's tool caches. Point Playwright and npm
|
|
@@ -385,6 +387,7 @@ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
|
|
|
385
387
|
]) {
|
|
386
388
|
delete childEnv[name];
|
|
387
389
|
}
|
|
390
|
+
Object.assign(childEnv, sessionEnv);
|
|
388
391
|
return childEnv;
|
|
389
392
|
}
|
|
390
393
|
|
package/lib/models.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// every engine request itself, so nothing outside this allowlist can reach
|
|
4
4
|
// the wire.
|
|
5
5
|
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
|
|
6
8
|
const DEFAULT_MODEL = 'GLM-5.3-Flash';
|
|
7
9
|
|
|
8
10
|
// Case-insensitive aliases → official ids (exact ids pass through verbatim;
|
|
@@ -43,7 +45,89 @@ function unknownModelMessage(input) {
|
|
|
43
45
|
);
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// --- supported effort levels per model ---------------------------------------
|
|
49
|
+
//
|
|
50
|
+
// The engine rejects an unsupported reasoning level only after it has been
|
|
51
|
+
// spawned and a session opened (GLM-5.3 refused `medium` that way in a real
|
|
52
|
+
// run). The levels live in the engine's builtin provider config
|
|
53
|
+
// (config/provider/zcode-builtin.json, the path `zcode doctor` reports):
|
|
54
|
+
// modelConfigRules.modelRules[] = {modelMatch: <regex>, config:
|
|
55
|
+
// {optionSpecs: {reasoningLevel: {values: […]}}}}. Later rules refine
|
|
56
|
+
// earlier ones (a generic `glm-5` rule lists disabled/enabled; the
|
|
57
|
+
// `glm-5.3(-flash)?` rule after it lists low/high/max), so the LAST matching
|
|
58
|
+
// rule that lists values wins. Unreadable or unmatched → the table below
|
|
59
|
+
// (observed in zcode-builtin.json shipped with ZCode 0.16.x).
|
|
60
|
+
const FALLBACK_LEVELS = {
|
|
61
|
+
'GLM-5.3': ['low', 'high', 'max'],
|
|
62
|
+
'GLM-5.3-Flash': ['low', 'high', 'max'],
|
|
63
|
+
};
|
|
64
|
+
const OFFICIAL_IDS = Object.keys(FALLBACK_LEVELS);
|
|
65
|
+
|
|
66
|
+
const levelCache = new Map(); // path:mtime:size → parsed rules
|
|
67
|
+
|
|
68
|
+
function readRules(file) {
|
|
69
|
+
try {
|
|
70
|
+
const st = fs.statSync(file);
|
|
71
|
+
const key = `${file}:${st.mtimeMs}:${st.size}`;
|
|
72
|
+
if (!levelCache.has(key)) {
|
|
73
|
+
const json = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
74
|
+
const rules = json && json.config && json.config.modelConfigRules && json.config.modelConfigRules.modelRules;
|
|
75
|
+
levelCache.set(key, Array.isArray(rules) ? rules : []);
|
|
76
|
+
}
|
|
77
|
+
return levelCache.get(key);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function builtinConfigPath(env) {
|
|
84
|
+
try {
|
|
85
|
+
const { resolveEngine, resolveBuiltinProviderConfig } = require('./engine.js');
|
|
86
|
+
return resolveBuiltinProviderConfig(resolveEngine(env), env);
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Efforts (a subset of EFFORT_LEVELS, in enum order) the model accepts.
|
|
93
|
+
function supportedEfforts(model, { env = process.env, builtinConfig } = {}) {
|
|
94
|
+
const file = builtinConfig || builtinConfigPath(env);
|
|
95
|
+
let values = null;
|
|
96
|
+
if (file) {
|
|
97
|
+
for (const rule of readRules(file)) {
|
|
98
|
+
let match = false;
|
|
99
|
+
try {
|
|
100
|
+
match = new RegExp(`^(?:${rule.modelMatch})$`, 'i').test(model);
|
|
101
|
+
} catch {
|
|
102
|
+
/* a malformed pattern matches nothing */
|
|
103
|
+
}
|
|
104
|
+
const v = match && rule.config && rule.config.optionSpecs && rule.config.optionSpecs.reasoningLevel
|
|
105
|
+
&& rule.config.optionSpecs.reasoningLevel.values;
|
|
106
|
+
if (Array.isArray(v)) values = v;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const levels = values ? EFFORT_LEVELS.filter((l) => values.includes(l)) : [];
|
|
110
|
+
return levels.length ? levels : FALLBACK_LEVELS[model] || [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// {modelId: [levels]} for every supported model (doctor).
|
|
114
|
+
function modelLevels(opts) {
|
|
115
|
+
return Object.fromEntries(OFFICIAL_IDS.map((id) => [id, supportedEfforts(id, opts)]));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// null when `effort` is usable with `model`, else the one-line refusal.
|
|
119
|
+
// `source` names where the value came from ("--effort", "contract effort").
|
|
120
|
+
function effortProblem(model, effort, { source = '--effort', ...opts } = {}) {
|
|
121
|
+
const levels = supportedEfforts(model, opts);
|
|
122
|
+
if (levels.includes(effort)) return null;
|
|
123
|
+
return `${source} ${effort} is not supported by ${model} (supported: ${levels.join(', ')}).`;
|
|
124
|
+
}
|
|
125
|
+
|
|
46
126
|
module.exports = {
|
|
127
|
+
supportedEfforts,
|
|
128
|
+
modelLevels,
|
|
129
|
+
effortProblem,
|
|
130
|
+
FALLBACK_LEVELS,
|
|
47
131
|
DEFAULT_MODEL,
|
|
48
132
|
normalizeModel,
|
|
49
133
|
unknownModelMessage,
|