opencode-harness-kit 0.2.0 → 0.3.3
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/CHANGELOG.md +301 -0
- package/GLOBAL.md +50 -13
- package/MIGRATION.md +140 -0
- package/README.md +276 -122
- package/bin/oc-harness.mjs +571 -135
- package/dist/index.js +453 -159
- package/docs/KNOWN-LIMITS.md +189 -4
- package/docs/RESEARCH.md +80 -5
- package/docs/ROUTING.md +254 -0
- package/lanes.json +136 -0
- package/lanes.schema.json +55 -0
- package/package.json +13 -11
- package/profiles/beam-free.json +51 -0
- package/profiles/big-pickle.json +45 -0
- package/profiles/deepseek-v4.1-flash-free.json +49 -0
- package/profiles/fleet-all.json +139 -0
- package/profiles/fleet-safe.json +139 -0
- package/profiles/glm-5.3-flash-free.json +49 -0
- package/profiles/index.json +120 -37
- package/profiles/longcat-2.5-preview-free.json +14 -10
- package/profiles/mimo-v2.6-flash-free.json +44 -0
- package/profiles/minimax-m3.1-flash-free.json +51 -0
- package/profiles/nemotron-3-ultra-free.json +44 -0
- package/profiles/nemotron-3.5-lightning-free.json +44 -0
- package/profiles/nemotron-3.5-super-vl-free.json +52 -0
- package/profiles/schema.json +30 -11
- package/{agents → prompts}/build.md +2 -35
- package/prompts/doc.md +42 -0
- package/{agents → prompts}/locate.md +1 -16
- package/prompts/longform.md +34 -0
- package/{agents → prompts}/plan.md +2 -25
- package/{agents → prompts}/reviewer.md +2 -22
- package/prompts/schema.md +30 -0
- package/prompts/sweep.md +29 -0
- package/{agents → prompts}/verify.md +6 -29
- package/prompts/vision.md +33 -0
- package/retention.yml +75 -57
- package/skills/space-build/SKILL.md +16 -0
- package/skills/space-finish/SKILL.md +0 -10
- package/skills/space-review/SKILL.md +8 -0
- package/src/config.js +94 -69
- package/src/driver/run.mjs +237 -0
- package/src/effort.js +59 -22
- package/src/index.ts +15 -2
- package/src/lane-usage.js +142 -0
- package/src/profile.js +615 -221
- package/src/router.ts +148 -39
- package/src/tasks.js +248 -0
- package/src/telemetry.ts +98 -0
- package/docs/demo/demo.gif +0 -0
- package/docs/demo/demo.mp4 +0 -0
- package/docs/demo/render.py +0 -146
- package/examples/muse-spark-1.3-contributor-free.json +0 -41
- package/profiles/deepseek-v4-flash-free.json +0 -41
- package/profiles/muse-split-free.json +0 -64
- package/profiles/space-bunny-free.json +0 -42
- package/profiles/x-preview-f-free.json +0 -41
package/dist/index.js
CHANGED
|
@@ -332,11 +332,34 @@ import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
|
332
332
|
import { dirname, join, resolve } from "node:path";
|
|
333
333
|
import { fileURLToPath } from "node:url";
|
|
334
334
|
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
335
|
-
|
|
335
|
+
function findRoot(start) {
|
|
336
|
+
let dir = start;
|
|
337
|
+
for (let i = 0; i < 6; i++) {
|
|
338
|
+
if (existsSync(join(dir, "profiles"))) return dir;
|
|
339
|
+
const parent = dirname(dir);
|
|
340
|
+
if (parent === dir) break;
|
|
341
|
+
dir = parent;
|
|
342
|
+
}
|
|
343
|
+
return resolve(start, "..");
|
|
344
|
+
}
|
|
345
|
+
var ROOT = findRoot(resolve(HERE, ".."));
|
|
336
346
|
var ProfileError = class extends Error {
|
|
337
347
|
};
|
|
348
|
+
var LANE_NAMES = [
|
|
349
|
+
"build",
|
|
350
|
+
"plan",
|
|
351
|
+
"reviewer",
|
|
352
|
+
"locate",
|
|
353
|
+
"verify",
|
|
354
|
+
"vision",
|
|
355
|
+
"doc",
|
|
356
|
+
"schema",
|
|
357
|
+
"sweep",
|
|
358
|
+
"longform"
|
|
359
|
+
];
|
|
360
|
+
var RETENTION_SEVERITY = ["zero-retention", "retained", "unknown", "may-train"];
|
|
338
361
|
function profileDirs() {
|
|
339
|
-
return [join(ROOT, "profiles")
|
|
362
|
+
return [join(ROOT, "profiles")];
|
|
340
363
|
}
|
|
341
364
|
function profilePath(id) {
|
|
342
365
|
for (const dir of profileDirs()) {
|
|
@@ -369,6 +392,7 @@ function normalize(p) {
|
|
|
369
392
|
default: p.effort?.default ?? null,
|
|
370
393
|
reserveShare: p.effort?.reserveShare ?? 0.12
|
|
371
394
|
},
|
|
395
|
+
maxRetention: p.maxRetention ?? p.retention,
|
|
372
396
|
prune: {
|
|
373
397
|
enabled: p.prune?.enabled ?? false,
|
|
374
398
|
keepRecentToolCalls: p.prune?.keepRecentToolCalls ?? 6,
|
|
@@ -378,115 +402,28 @@ function normalize(p) {
|
|
|
378
402
|
fallback: p.fallback ?? []
|
|
379
403
|
};
|
|
380
404
|
}
|
|
381
|
-
var
|
|
382
|
-
function
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (!
|
|
386
|
-
|
|
387
|
-
if (!VALID_RETENTION.includes(p.retention)) {
|
|
388
|
-
throw new ProfileError(
|
|
389
|
-
`${where}: retention must be one of ${VALID_RETENTION.join(", ")}`
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
if (!p.limits?.context || p.limits.context <= 0) {
|
|
393
|
-
throw new ProfileError(`${where}: limits.context must be a positive integer`);
|
|
405
|
+
var laneCache = null;
|
|
406
|
+
function loadLanes() {
|
|
407
|
+
if (laneCache) return laneCache;
|
|
408
|
+
const file = join(ROOT, "lanes.json");
|
|
409
|
+
if (!existsSync(file)) {
|
|
410
|
+
throw new ProfileError(`lane table missing: ${file}`);
|
|
394
411
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
throw new ProfileError(
|
|
401
|
-
`${where}: effort.reserveShare must be between 0 and 0.5`
|
|
402
|
-
);
|
|
403
|
-
}
|
|
404
|
-
for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
|
|
405
|
-
if (!SPLITTABLE_ROLES.includes(role)) {
|
|
406
|
-
throw new ProfileError(
|
|
407
|
-
`${where}: roleModels.${role} is not one of ${SPLITTABLE_ROLES.join(", ")}`
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
if (!(role in (p.roles ?? {}))) {
|
|
411
|
-
throw new ProfileError(
|
|
412
|
-
`${where}: roleModels.${role} has no matching entry in roles, so it would never apply`
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
if (!rm?.model || typeof rm.model !== "string") {
|
|
416
|
-
throw new ProfileError(`${where}: roleModels.${role} needs a model`);
|
|
412
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
413
|
+
const lanes = parsed.lanes ?? {};
|
|
414
|
+
for (const [name, lane] of Object.entries(lanes)) {
|
|
415
|
+
if (!lane.description) {
|
|
416
|
+
throw new ProfileError(`lanes.json: lane "${name}" has no description`);
|
|
417
417
|
}
|
|
418
|
-
if (
|
|
419
|
-
throw new ProfileError(
|
|
420
|
-
`${where}: roleModels.${role}.levels must be an array`
|
|
421
|
-
);
|
|
418
|
+
if (!lane.prompt) {
|
|
419
|
+
throw new ProfileError(`lanes.json: lane "${name}" has no prompt file`);
|
|
422
420
|
}
|
|
423
|
-
if (!retentionFor(rm.model)) {
|
|
424
|
-
throw new ProfileError(
|
|
425
|
-
`${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`
|
|
426
|
-
);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
var CANON = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
431
|
-
function resolveEffort(levels, requested) {
|
|
432
|
-
if (!requested) return null;
|
|
433
|
-
if (!levels || levels.length === 0) return null;
|
|
434
|
-
if (levels.includes(requested)) return requested;
|
|
435
|
-
const want = CANON.indexOf(requested);
|
|
436
|
-
if (want < 0) {
|
|
437
|
-
return levels[levels.length - 1] ?? null;
|
|
438
|
-
}
|
|
439
|
-
let best = null;
|
|
440
|
-
let bestDist = Infinity;
|
|
441
|
-
for (const level of levels) {
|
|
442
|
-
const have = CANON.indexOf(level);
|
|
443
|
-
if (have < 0) continue;
|
|
444
|
-
const d = Math.abs(have - want);
|
|
445
|
-
if (d < bestDist) {
|
|
446
|
-
bestDist = d;
|
|
447
|
-
best = level;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
return best ?? levels[levels.length - 1] ?? null;
|
|
451
|
-
}
|
|
452
|
-
function resolveRoleModel(p, role) {
|
|
453
|
-
const rm = p.roleModels?.[role];
|
|
454
|
-
if (!rm) return null;
|
|
455
|
-
return { model: rm.model, levels: rm.levels ?? [] };
|
|
456
|
-
}
|
|
457
|
-
function levelsForRole(p, role) {
|
|
458
|
-
const rm = resolveRoleModel(p, role);
|
|
459
|
-
if (rm) return rm.levels;
|
|
460
|
-
return p.effort?.levels ?? [];
|
|
461
|
-
}
|
|
462
|
-
function resolveRoles(p) {
|
|
463
|
-
const out = (
|
|
464
|
-
/** @type {Record<string, string | null>} */
|
|
465
|
-
{}
|
|
466
|
-
);
|
|
467
|
-
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
468
|
-
out[role] = resolveEffort(levelsForRole(p, role), want);
|
|
469
421
|
}
|
|
470
|
-
|
|
422
|
+
laneCache = lanes;
|
|
423
|
+
return lanes;
|
|
471
424
|
}
|
|
472
|
-
function
|
|
473
|
-
return
|
|
474
|
-
}
|
|
475
|
-
var SPLITTABLE_ROLES = ["plan", "build", "reviewer", "locate", "verify"];
|
|
476
|
-
function inputCap(p) {
|
|
477
|
-
let cap = p.limits.input ?? p.limits.context;
|
|
478
|
-
for (const s of roleSplitModels(p)) {
|
|
479
|
-
const other = s.limits?.input ?? s.limits?.context;
|
|
480
|
-
if (typeof other === "number" && other > cap) cap = other;
|
|
481
|
-
}
|
|
482
|
-
return cap;
|
|
483
|
-
}
|
|
484
|
-
function reservedTokens(p) {
|
|
485
|
-
const share = p.effort?.reserveShare ?? 0.12;
|
|
486
|
-
return Math.max(8e3, Math.round(inputCap(p) * share));
|
|
487
|
-
}
|
|
488
|
-
function pruneThreshold(p) {
|
|
489
|
-
return inputCap(p) - reservedTokens(p);
|
|
425
|
+
function laneMeta(name) {
|
|
426
|
+
return loadLanes()[name] ?? null;
|
|
490
427
|
}
|
|
491
428
|
function loadRetention() {
|
|
492
429
|
const file = join(ROOT, "retention.yml");
|
|
@@ -549,20 +486,277 @@ function retentionFor(id) {
|
|
|
549
486
|
const table = loadRetention();
|
|
550
487
|
return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`];
|
|
551
488
|
}
|
|
552
|
-
function
|
|
553
|
-
|
|
489
|
+
function modelRetention(model) {
|
|
490
|
+
const declared = retentionFor(model)?.retention ?? "unknown";
|
|
491
|
+
return (
|
|
492
|
+
/** @type {Retention} */
|
|
493
|
+
["zero-retention", "retained", "may-train", "unknown"].includes(declared) ? declared : "unknown"
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
function retentionAllowed(model, ceiling) {
|
|
497
|
+
const level = modelRetention(model);
|
|
498
|
+
if (level === "unknown") return false;
|
|
499
|
+
const max = RETENTION_SEVERITY.indexOf(ceiling);
|
|
500
|
+
const have = RETENTION_SEVERITY.indexOf(level);
|
|
501
|
+
if (max < 0) return false;
|
|
502
|
+
return have <= max;
|
|
503
|
+
}
|
|
504
|
+
function resolveLaneChain(p, role) {
|
|
505
|
+
const rm = p.roleModels?.[role];
|
|
506
|
+
if (!rm) return [p.model];
|
|
507
|
+
const chain = rm.chain?.length ? rm.chain : [rm.model];
|
|
508
|
+
return chain;
|
|
509
|
+
}
|
|
510
|
+
function resolveLaneModel(p, role, opts = {}) {
|
|
511
|
+
const unhealthy = opts.unhealthy ?? /* @__PURE__ */ new Set();
|
|
512
|
+
const ceiling = (
|
|
513
|
+
/** @type {Retention} */
|
|
514
|
+
p.maxRetention ?? p.retention
|
|
515
|
+
);
|
|
516
|
+
for (const candidate of resolveLaneChain(p, role)) {
|
|
517
|
+
if (unhealthy.has(candidate)) continue;
|
|
518
|
+
if (!retentionAllowed(candidate, ceiling)) continue;
|
|
519
|
+
return { model: candidate, levels: levelsOfModel(candidate) };
|
|
520
|
+
}
|
|
521
|
+
const fallback = resolveLaneChain(p, role)[0] ?? p.model;
|
|
522
|
+
return { model: fallback, levels: levelsOfModel(fallback) };
|
|
523
|
+
}
|
|
524
|
+
function levelsOfModel(model) {
|
|
525
|
+
try {
|
|
526
|
+
return loadProfile(model).effort?.levels ?? [];
|
|
527
|
+
} catch {
|
|
528
|
+
return [];
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function levelsForRole(p, role, opts) {
|
|
532
|
+
const rm = p.roleModels?.[role];
|
|
533
|
+
if (rm) return resolveLaneModel(p, role, opts).levels;
|
|
534
|
+
return p.effort?.levels ?? [];
|
|
535
|
+
}
|
|
536
|
+
function resolveRoles(p, opts = {}) {
|
|
537
|
+
const out = (
|
|
538
|
+
/** @type {Record<string, string | null>} */
|
|
539
|
+
{}
|
|
540
|
+
);
|
|
541
|
+
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
542
|
+
out[role] = resolveEffort(levelsForRole(p, role, opts), want);
|
|
543
|
+
}
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
function resolveDefaultVariant(p) {
|
|
547
|
+
return resolveEffort(p.effort?.levels ?? [], p.effort?.default ?? null);
|
|
548
|
+
}
|
|
549
|
+
var CANON = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
550
|
+
function resolveEffort(levels, requested) {
|
|
551
|
+
if (!requested) return null;
|
|
552
|
+
if (!levels || levels.length === 0) return null;
|
|
553
|
+
if (levels.includes(requested)) return requested;
|
|
554
|
+
const want = CANON.indexOf(requested);
|
|
555
|
+
if (want < 0) {
|
|
556
|
+
return levels[levels.length - 1] ?? null;
|
|
557
|
+
}
|
|
558
|
+
let best = null;
|
|
559
|
+
let bestDist = Infinity;
|
|
560
|
+
for (const level of levels) {
|
|
561
|
+
const have = CANON.indexOf(level);
|
|
562
|
+
if (have < 0) continue;
|
|
563
|
+
const d = Math.abs(have - want);
|
|
564
|
+
if (d < bestDist) {
|
|
565
|
+
bestDist = d;
|
|
566
|
+
best = level;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return best ?? levels[levels.length - 1] ?? null;
|
|
570
|
+
}
|
|
571
|
+
var VALID_RETENTION = ["zero-retention", "retained", "may-train", "unknown"];
|
|
572
|
+
function validate(p, path = "<memory>") {
|
|
573
|
+
const where = p?.id ?? path;
|
|
574
|
+
if (!p?.id) throw new ProfileError(`${path}: missing "id"`);
|
|
575
|
+
if (!p.provider) throw new ProfileError(`${where}: missing "provider"`);
|
|
576
|
+
if (!p.model) throw new ProfileError(`${where}: missing "model"`);
|
|
577
|
+
if (!VALID_RETENTION.includes(p.retention)) {
|
|
578
|
+
throw new ProfileError(
|
|
579
|
+
`${where}: retention must be one of ${VALID_RETENTION.join(", ")}`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
if (p.maxRetention && !VALID_RETENTION.includes(p.maxRetention)) {
|
|
583
|
+
throw new ProfileError(
|
|
584
|
+
`${where}: maxRetention must be one of ${VALID_RETENTION.join(", ")}`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
if (p.maxRetention === "unknown") {
|
|
588
|
+
throw new ProfileError(`${where}: maxRetention cannot be unknown`);
|
|
589
|
+
}
|
|
590
|
+
if (!p.limits?.context || p.limits.context <= 0) {
|
|
591
|
+
throw new ProfileError(`${where}: limits.context must be a positive integer`);
|
|
592
|
+
}
|
|
593
|
+
if (!p.limits?.output || p.limits.output <= 0) {
|
|
594
|
+
throw new ProfileError(`${where}: limits.output must be a positive integer`);
|
|
595
|
+
}
|
|
596
|
+
const share = p.effort?.reserveShare;
|
|
597
|
+
if (share !== void 0 && (share < 0 || share > 0.5)) {
|
|
598
|
+
throw new ProfileError(
|
|
599
|
+
`${where}: effort.reserveShare must be between 0 and 0.5`
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
for (const role of Object.keys(p.roles ?? {})) {
|
|
603
|
+
if (!LANE_NAMES.includes(role)) {
|
|
604
|
+
throw new ProfileError(
|
|
605
|
+
`${where}: roles.${role} is not one of ${LANE_NAMES.join(", ")}`
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
if (!laneMeta(role)) {
|
|
609
|
+
throw new ProfileError(
|
|
610
|
+
`${where}: roles.${role} has no lane definition in lanes.json`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
|
|
615
|
+
if (!LANE_NAMES.includes(role)) {
|
|
616
|
+
throw new ProfileError(
|
|
617
|
+
`${where}: roleModels.${role} is not one of ${LANE_NAMES.join(", ")}`
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (!(role in (p.roles ?? {}))) {
|
|
621
|
+
throw new ProfileError(
|
|
622
|
+
`${where}: roleModels.${role} has no matching entry in roles, so it would never apply`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
if (!rm?.model || typeof rm.model !== "string") {
|
|
626
|
+
throw new ProfileError(`${where}: roleModels.${role} needs a model`);
|
|
627
|
+
}
|
|
628
|
+
if (rm.levels !== void 0 && !Array.isArray(rm.levels)) {
|
|
629
|
+
throw new ProfileError(
|
|
630
|
+
`${where}: roleModels.${role}.levels must be an array`
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
if (rm.chain !== void 0) {
|
|
634
|
+
if (!Array.isArray(rm.chain) || rm.chain.length === 0) {
|
|
635
|
+
throw new ProfileError(
|
|
636
|
+
`${where}: roleModels.${role}.chain must be a non-empty array`
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
if (rm.chain[0] !== rm.model) {
|
|
640
|
+
throw new ProfileError(
|
|
641
|
+
`${where}: roleModels.${role}.chain must start with its model (${rm.chain[0]} vs ${rm.model})`
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
for (const rung of rm.chain) {
|
|
645
|
+
if (!retentionFor(rung)) {
|
|
646
|
+
throw new ProfileError(
|
|
647
|
+
`${where}: roleModels.${role}.chain names ${rung}, which has no retention.yml entry`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (!retentionFor(rm.model)) {
|
|
653
|
+
throw new ProfileError(
|
|
654
|
+
`${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function inputCap(p, opts = {}) {
|
|
660
|
+
let cap = p.limits.input ?? p.limits.context;
|
|
661
|
+
for (const s of roleSplitModels(p, opts)) {
|
|
662
|
+
const other = s.limits?.input ?? s.limits?.context;
|
|
663
|
+
if (typeof other === "number" && other < cap) cap = other;
|
|
664
|
+
}
|
|
665
|
+
return cap;
|
|
666
|
+
}
|
|
667
|
+
function reservedTokens(p, opts = {}) {
|
|
668
|
+
const share = p.effort?.reserveShare ?? 0.12;
|
|
669
|
+
return Math.max(8e3, Math.round(inputCap(p, opts) * share));
|
|
670
|
+
}
|
|
671
|
+
function pruneThreshold(p, opts = {}) {
|
|
672
|
+
return inputCap(p, opts) - reservedTokens(p, opts);
|
|
673
|
+
}
|
|
674
|
+
function roleSplitModels(p, opts = {}) {
|
|
675
|
+
return Object.entries(p.roleModels ?? {}).map(([role]) => {
|
|
676
|
+
const resolved = resolveLaneModel(p, role, opts);
|
|
554
677
|
let limits = null;
|
|
555
678
|
try {
|
|
556
|
-
limits = loadProfile(
|
|
679
|
+
limits = loadProfile(resolved.model)?.limits ?? null;
|
|
557
680
|
} catch {
|
|
558
681
|
limits = null;
|
|
559
682
|
}
|
|
560
|
-
return { role, model:
|
|
683
|
+
return { role, model: resolved.model, levels: resolved.levels, limits };
|
|
561
684
|
});
|
|
562
685
|
}
|
|
686
|
+
function configDir() {
|
|
687
|
+
return process.env.OPENCODE_CONFIG_DIR ?? join(process.env.HOME ?? "", ".config", "opencode");
|
|
688
|
+
}
|
|
563
689
|
function configFilePath() {
|
|
564
|
-
|
|
565
|
-
|
|
690
|
+
return join(configDir(), "opencode.jsonc");
|
|
691
|
+
}
|
|
692
|
+
function parseJsonc(text) {
|
|
693
|
+
let out = "";
|
|
694
|
+
let i = 0;
|
|
695
|
+
let inString = false;
|
|
696
|
+
let escaped = false;
|
|
697
|
+
let pendingComma = false;
|
|
698
|
+
const n = text.length;
|
|
699
|
+
while (i < n) {
|
|
700
|
+
const ch = text[i];
|
|
701
|
+
if (inString) {
|
|
702
|
+
out += ch;
|
|
703
|
+
if (escaped) escaped = false;
|
|
704
|
+
else if (ch === "\\") escaped = true;
|
|
705
|
+
else if (ch === '"') inString = false;
|
|
706
|
+
i++;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (ch === '"') {
|
|
710
|
+
if (pendingComma) {
|
|
711
|
+
out += ",";
|
|
712
|
+
pendingComma = false;
|
|
713
|
+
}
|
|
714
|
+
out += ch;
|
|
715
|
+
inString = true;
|
|
716
|
+
i++;
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
720
|
+
while (i < n && text[i] !== "\n") i++;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
724
|
+
i += 2;
|
|
725
|
+
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
726
|
+
i += 2;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (ch === ",") {
|
|
730
|
+
if (pendingComma) return null;
|
|
731
|
+
pendingComma = true;
|
|
732
|
+
i++;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
|
|
736
|
+
out += ch;
|
|
737
|
+
i++;
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (ch === "}" || ch === "]") {
|
|
741
|
+
pendingComma = false;
|
|
742
|
+
out += ch;
|
|
743
|
+
i++;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (pendingComma) {
|
|
747
|
+
out += ",";
|
|
748
|
+
pendingComma = false;
|
|
749
|
+
}
|
|
750
|
+
out += ch;
|
|
751
|
+
i++;
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
const parsed = JSON.parse(out);
|
|
755
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
756
|
+
} catch {
|
|
757
|
+
if (!out.trim()) return {};
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
566
760
|
}
|
|
567
761
|
function profileIdFromConfig() {
|
|
568
762
|
const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
|
|
@@ -570,10 +764,12 @@ function profileIdFromConfig() {
|
|
|
570
764
|
const file = configFilePath();
|
|
571
765
|
if (!existsSync(file)) return null;
|
|
572
766
|
try {
|
|
573
|
-
const
|
|
574
|
-
const
|
|
575
|
-
const
|
|
576
|
-
|
|
767
|
+
const parsed = parseJsonc(readFileSync(file, "utf8"));
|
|
768
|
+
const harness = parsed?.harness;
|
|
769
|
+
const id = harness && typeof harness === "object" ? (
|
|
770
|
+
/** @type {{profile?: unknown}} */
|
|
771
|
+
harness.profile
|
|
772
|
+
) : null;
|
|
577
773
|
return typeof id === "string" && id ? id : null;
|
|
578
774
|
} catch {
|
|
579
775
|
return null;
|
|
@@ -776,6 +972,14 @@ function writeHealth(worktree, h) {
|
|
|
776
972
|
} catch {
|
|
777
973
|
}
|
|
778
974
|
}
|
|
975
|
+
function unhealthyModels(worktree) {
|
|
976
|
+
const health = readHealth(worktree);
|
|
977
|
+
const out = /* @__PURE__ */ new Set();
|
|
978
|
+
for (const key of Object.keys(health.unhealthy ?? {})) {
|
|
979
|
+
out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key);
|
|
980
|
+
}
|
|
981
|
+
return out;
|
|
982
|
+
}
|
|
779
983
|
var SPENT = [
|
|
780
984
|
/FreeUsageLimitError/i,
|
|
781
985
|
/rate[_ -]?limit/i,
|
|
@@ -789,7 +993,7 @@ function classify(text) {
|
|
|
789
993
|
for (const p of SPENT) if (p.test(text)) return p.source;
|
|
790
994
|
return null;
|
|
791
995
|
}
|
|
792
|
-
var WARN_TEXT = "[opencode-harness-kit] This session's model route is rate limited or withdrawn. No plugin can swap a live session's model, so stop retrying and ask the human to start a new session. Run `oc-harness list` to see the
|
|
996
|
+
var WARN_TEXT = "[opencode-harness-kit] This session's model route is rate limited or withdrawn. No plugin can swap a live session's model, so stop retrying and ask the human to start a new session. Run `oc-harness list` to see the lanes and their chains, or `oc-harness doctor` to check whether the profile still matches live metadata.";
|
|
793
997
|
var RouterPlugin = async ({ worktree }) => {
|
|
794
998
|
let failedSessions = 0;
|
|
795
999
|
let warned = false;
|
|
@@ -799,27 +1003,57 @@ var RouterPlugin = async ({ worktree }) => {
|
|
|
799
1003
|
try {
|
|
800
1004
|
const cfg = config;
|
|
801
1005
|
const health = readHealth(worktree);
|
|
1006
|
+
const unhealthy = unhealthyModels(worktree);
|
|
802
1007
|
const activeId = cfg.harness?.profile ?? profileIdFromConfig();
|
|
803
|
-
if (activeId)
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1008
|
+
if (!activeId) return;
|
|
1009
|
+
const id = activeId;
|
|
1010
|
+
let profile;
|
|
1011
|
+
try {
|
|
1012
|
+
profile = loadProfile(id);
|
|
1013
|
+
} catch {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const baseKey = `opencode/${profile.model}`;
|
|
1017
|
+
if (health.unhealthy?.[baseKey]) {
|
|
1018
|
+
const queue = [id, ...profile.fallback ?? []];
|
|
1019
|
+
for (const candidate of queue) {
|
|
1020
|
+
if (health.unhealthy?.[`opencode/${candidate}`]) continue;
|
|
1021
|
+
let fallback;
|
|
808
1022
|
try {
|
|
809
|
-
|
|
810
|
-
for (const candidate of queue) {
|
|
811
|
-
if (!health.unhealthy[`opencode/${candidate}`]) {
|
|
812
|
-
chosen = candidate;
|
|
813
|
-
break;
|
|
814
|
-
}
|
|
815
|
-
}
|
|
1023
|
+
fallback = loadProfile(candidate);
|
|
816
1024
|
} catch {
|
|
1025
|
+
continue;
|
|
817
1026
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1027
|
+
const key = `opencode/${fallback.model}`;
|
|
1028
|
+
if (health.unhealthy?.[key]) continue;
|
|
1029
|
+
const fleet = isFleet(profile);
|
|
1030
|
+
applySessionModel(cfg, fallback, {
|
|
1031
|
+
keepLanes: fleet,
|
|
1032
|
+
// The lanes did not change, so the numbers derived from them did
|
|
1033
|
+
// not change either.
|
|
1034
|
+
profileId: fleet ? profile.id : fallback.id,
|
|
1035
|
+
reserved: fleet ? reservedTokens(profile) : void 0,
|
|
1036
|
+
threshold: fleet ? pruneThreshold(profile) : void 0
|
|
1037
|
+
});
|
|
1038
|
+
health.active = candidate;
|
|
1039
|
+
writeHealth(worktree, health);
|
|
1040
|
+
break;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const roles = resolveRoles(profile, { unhealthy });
|
|
1044
|
+
for (const [lane, level] of Object.entries(roles)) {
|
|
1045
|
+
if (level === void 0) continue;
|
|
1046
|
+
const agent = cfg.agent?.[lane];
|
|
1047
|
+
if (!agent) continue;
|
|
1048
|
+
const resolved = resolveLaneModel(profile, lane, { unhealthy });
|
|
1049
|
+
if (resolved.model === profile.model && !profile.roleModels?.[lane]) continue;
|
|
1050
|
+
const wantModel = `opencode/${resolved.model}`;
|
|
1051
|
+
if (agent.model !== wantModel) {
|
|
1052
|
+
agent.model = wantModel;
|
|
1053
|
+
const requested = profile.roles?.[lane] ?? null;
|
|
1054
|
+
const next = resolveEffort(resolved.levels, requested);
|
|
1055
|
+
if (next === null) delete agent.variant;
|
|
1056
|
+
else agent.variant = next;
|
|
823
1057
|
}
|
|
824
1058
|
}
|
|
825
1059
|
} catch {
|
|
@@ -857,28 +1091,37 @@ ${WARN_TEXT}`;
|
|
|
857
1091
|
}
|
|
858
1092
|
};
|
|
859
1093
|
};
|
|
860
|
-
function
|
|
1094
|
+
function isFleet(profile) {
|
|
1095
|
+
return Object.keys(profile?.roleModels ?? {}).length > 0;
|
|
1096
|
+
}
|
|
1097
|
+
function applySessionModel(cfg, profile, opts = {}) {
|
|
861
1098
|
try {
|
|
862
|
-
const
|
|
863
|
-
const model = `${p.provider}/${p.model}`;
|
|
1099
|
+
const model = `${profile.provider}/${profile.model}`;
|
|
864
1100
|
cfg.model = model;
|
|
865
1101
|
cfg.small_model = model;
|
|
866
|
-
const variant =
|
|
867
|
-
if (
|
|
1102
|
+
const variant = profile.effort?.default;
|
|
1103
|
+
if (profile.effort?.levels?.length && variant) cfg.variant = variant;
|
|
868
1104
|
else delete cfg.variant;
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1105
|
+
if (!opts.keepLanes) {
|
|
1106
|
+
for (const [lane, want] of Object.entries(profile.roles ?? {})) {
|
|
1107
|
+
const agent = cfg.agent ?? (cfg.agent = {});
|
|
1108
|
+
const existing = agent[lane] ?? (agent[lane] = {});
|
|
1109
|
+
existing.model = model;
|
|
1110
|
+
const level = resolveEffort(profile.effort?.levels ?? [], want);
|
|
1111
|
+
if (level) existing.variant = level;
|
|
1112
|
+
else delete existing.variant;
|
|
1113
|
+
}
|
|
876
1114
|
}
|
|
877
1115
|
if (cfg.compaction) {
|
|
878
1116
|
cfg.compaction.prune = true;
|
|
879
|
-
cfg.compaction.reserved = reservedTokens(
|
|
1117
|
+
cfg.compaction.reserved = opts.reserved ?? reservedTokens(profile);
|
|
880
1118
|
}
|
|
881
|
-
cfg.harness = {
|
|
1119
|
+
cfg.harness = {
|
|
1120
|
+
...cfg.harness ?? {},
|
|
1121
|
+
profile: opts.profileId ?? profile.id,
|
|
1122
|
+
threshold: opts.threshold ?? pruneThreshold(profile),
|
|
1123
|
+
sessionModel: model
|
|
1124
|
+
};
|
|
882
1125
|
} catch {
|
|
883
1126
|
}
|
|
884
1127
|
}
|
|
@@ -910,12 +1153,28 @@ function write(dir, record) {
|
|
|
910
1153
|
var TelemetryPlugin = async ({ worktree }) => {
|
|
911
1154
|
const dir = join4(worktree, ".space");
|
|
912
1155
|
const started = Date.now();
|
|
1156
|
+
const opened = /* @__PURE__ */ new Set();
|
|
1157
|
+
const closed = /* @__PURE__ */ new Set();
|
|
913
1158
|
return {
|
|
914
1159
|
event: async ({ event }) => {
|
|
915
1160
|
const type = event?.type;
|
|
916
1161
|
if (typeof type !== "string") return;
|
|
917
1162
|
if (/error|rate|limit|quota/i.test(type)) {
|
|
918
1163
|
write(dir, { kind: "provider-event", type, detail: truncate2(safeStringify(event)) });
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (type === "message.updated") {
|
|
1167
|
+
const lane = laneOf(event);
|
|
1168
|
+
if (!lane || !lane.id) return;
|
|
1169
|
+
if (lane.ms !== void 0) {
|
|
1170
|
+
if (closed.has(lane.id)) return;
|
|
1171
|
+
closed.add(lane.id);
|
|
1172
|
+
write(dir, { kind: "lane", ...lane });
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
if (opened.has(lane.id)) return;
|
|
1176
|
+
opened.add(lane.id);
|
|
1177
|
+
write(dir, { kind: "lane", ...lane });
|
|
919
1178
|
}
|
|
920
1179
|
},
|
|
921
1180
|
"tool.execute.after": async (input, output) => {
|
|
@@ -934,6 +1193,28 @@ var TelemetryPlugin = async ({ worktree }) => {
|
|
|
934
1193
|
}
|
|
935
1194
|
};
|
|
936
1195
|
};
|
|
1196
|
+
function laneOf(event) {
|
|
1197
|
+
const e = event;
|
|
1198
|
+
const info = e?.properties?.info ?? e?.properties;
|
|
1199
|
+
if (!info || typeof info !== "object") return null;
|
|
1200
|
+
if (info.role && info.role !== "assistant") return null;
|
|
1201
|
+
const id = typeof info.id === "string" ? info.id : void 0;
|
|
1202
|
+
const agent = typeof info.agent === "string" ? info.agent : void 0;
|
|
1203
|
+
const modelId = info.model?.modelID ?? info.modelID;
|
|
1204
|
+
const providerId = info.model?.providerID ?? info.providerID;
|
|
1205
|
+
const time = info.time ?? {};
|
|
1206
|
+
const created = typeof time.created === "number" ? time.created : void 0;
|
|
1207
|
+
const completed = typeof time.completed === "number" ? time.completed : void 0;
|
|
1208
|
+
const ms = created !== void 0 && completed !== void 0 ? completed - created : void 0;
|
|
1209
|
+
if (!id || !agent) return null;
|
|
1210
|
+
return {
|
|
1211
|
+
id,
|
|
1212
|
+
agent,
|
|
1213
|
+
model: typeof modelId === "string" ? modelId : void 0,
|
|
1214
|
+
provider: typeof providerId === "string" ? providerId : void 0,
|
|
1215
|
+
ms: typeof ms === "number" && ms >= 0 ? ms : void 0
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
937
1218
|
function extractRule(output) {
|
|
938
1219
|
const m = output.match(/blocked:\s*([^\n]+)/);
|
|
939
1220
|
return m ? m[1].trim() : "unknown";
|
|
@@ -950,28 +1231,32 @@ function safeStringify(value) {
|
|
|
950
1231
|
}
|
|
951
1232
|
|
|
952
1233
|
// src/effort.js
|
|
953
|
-
|
|
954
|
-
function planEffort(profileId) {
|
|
1234
|
+
function planEffort(profileId, opts = {}) {
|
|
955
1235
|
const p = loadProfile(profileId);
|
|
956
1236
|
const levels = p.effort?.levels ?? [];
|
|
957
|
-
const roles = resolveRoles(p);
|
|
1237
|
+
const roles = resolveRoles(p, opts);
|
|
958
1238
|
const notes = [];
|
|
959
1239
|
if (levels.length === 0) {
|
|
960
1240
|
notes.push(
|
|
961
|
-
`${p.model} publishes no tunable reasoning effort; variant will be omitted for every
|
|
1241
|
+
`${p.model} publishes no tunable reasoning effort; variant will be omitted for every lane.`
|
|
962
1242
|
);
|
|
963
1243
|
}
|
|
964
1244
|
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
965
1245
|
const got = roles[role];
|
|
966
1246
|
if (want && got && want !== got) {
|
|
967
|
-
notes.push(`
|
|
1247
|
+
notes.push(`lane ${role} asked for ${want}; its model tops out at ${got}.`);
|
|
968
1248
|
}
|
|
969
1249
|
}
|
|
1250
|
+
const laneModels = {};
|
|
1251
|
+
for (const role of Object.keys(p.roles ?? {})) {
|
|
1252
|
+
laneModels[role] = resolveLaneModel(p, role, opts).model;
|
|
1253
|
+
}
|
|
970
1254
|
return {
|
|
971
1255
|
profileId,
|
|
972
1256
|
model: `${p.provider}/${p.model}`,
|
|
973
1257
|
defaultVariant: resolveDefaultVariant(p),
|
|
974
1258
|
roles,
|
|
1259
|
+
laneModels,
|
|
975
1260
|
notes
|
|
976
1261
|
};
|
|
977
1262
|
}
|
|
@@ -980,18 +1265,24 @@ function applyEffort(config, result, opts = {}) {
|
|
|
980
1265
|
/** @type {Record<string, any>} */
|
|
981
1266
|
config
|
|
982
1267
|
);
|
|
983
|
-
|
|
1268
|
+
const sessionModel = typeof opts.sessionModel === "string" && opts.sessionModel ? opts.sessionModel : result.model;
|
|
1269
|
+
cfg.model = sessionModel;
|
|
984
1270
|
cfg.agent = cfg.agent ?? {};
|
|
985
|
-
for (const role of
|
|
986
|
-
const level = result.roles[role];
|
|
1271
|
+
for (const [role, level] of Object.entries(result.roles)) {
|
|
987
1272
|
if (level === void 0) continue;
|
|
988
1273
|
const existing = cfg.agent[role] ?? {};
|
|
989
|
-
if (!existing.model)
|
|
1274
|
+
if (!existing.model) {
|
|
1275
|
+
existing.model = result.laneModels?.[role] ? `${result.model.split("/")[0]}/${result.laneModels[role]}` : result.model;
|
|
1276
|
+
}
|
|
990
1277
|
if (level === null) delete existing.variant;
|
|
991
1278
|
else existing.variant = level;
|
|
992
1279
|
cfg.agent[role] = existing;
|
|
993
1280
|
}
|
|
994
|
-
cfg.harness =
|
|
1281
|
+
cfg.harness = {
|
|
1282
|
+
...cfg.harness ?? {},
|
|
1283
|
+
profile: result.profileId,
|
|
1284
|
+
...opts.fallbackProfileId ? { fallback: opts.fallbackProfileId } : {}
|
|
1285
|
+
};
|
|
995
1286
|
return config;
|
|
996
1287
|
}
|
|
997
1288
|
|
|
@@ -1021,7 +1312,10 @@ var HarnessPlugin = async (ctx, options) => {
|
|
|
1021
1312
|
const id = opts.profile ?? config?.harness?.profile ?? profileIdFromConfig();
|
|
1022
1313
|
if (!id) return;
|
|
1023
1314
|
try {
|
|
1024
|
-
|
|
1315
|
+
const sessionModel = typeof config?.harness?.sessionModel === "string" ? config.harness.sessionModel : void 0;
|
|
1316
|
+
applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(ctx.worktree) }), {
|
|
1317
|
+
sessionModel
|
|
1318
|
+
});
|
|
1025
1319
|
const dir = join5(ctx.worktree, ".space");
|
|
1026
1320
|
mkdirSync3(dir, { recursive: true });
|
|
1027
1321
|
writeFileSync2(join5(dir, "active"), `${id}
|