opencode-harness-kit 0.4.3 → 0.4.4
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 +527 -18
- package/CONTRIBUTING.md +242 -0
- package/GLOBAL.md +34 -5
- package/MIGRATION.md +151 -0
- package/README.md +307 -37
- package/SECURITY.md +30 -0
- package/bin/baseline.mjs +15 -7
- package/bin/git-hooks/pre-commit +41 -9
- package/bin/oc-harness.mjs +1265 -82
- package/dist/config.d.ts +127 -0
- package/dist/denylist.d.ts +33 -0
- package/dist/effort.d.ts +64 -0
- package/dist/guard.d.ts +23 -0
- package/dist/health.d.ts +43 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +532 -149
- package/dist/plugin.d.ts +15 -0
- package/dist/profile.d.ts +399 -0
- package/dist/project.d.ts +8 -0
- package/dist/prune.d.ts +6 -0
- package/dist/router.d.ts +3 -0
- package/dist/rules.d.ts +109 -0
- package/dist/safe.d.ts +7 -0
- package/dist/telemetry-store.d.ts +12 -0
- package/dist/telemetry.d.ts +3 -0
- package/docs/EXIT-CODES.md +71 -0
- package/docs/KNOWN-LIMITS.md +45 -21
- package/docs/RESEARCH.md +9 -4
- package/docs/ROUTING.md +53 -8
- package/eval/RESULTS.md +443 -0
- package/eval/ab.mjs +743 -0
- package/eval/attribute.mjs +170 -0
- package/eval/flags.mjs +88 -0
- package/eval/measure-install.mjs +323 -0
- package/eval/measure-prune.mjs +339 -0
- package/eval/metrics.js +79 -0
- package/eval/premises.mjs +200 -0
- package/eval/prepare.mjs +233 -0
- package/eval/run.mjs +376 -0
- package/eval/tasks-self.json +81 -0
- package/eval/tasks-ts.json +50 -0
- package/eval/tasks.json +83 -0
- package/eval/thresholds.mjs +134 -0
- package/eval/verify.mjs +287 -0
- package/package.json +44 -17
- package/profiles/fleet-all.json +6 -1
- package/profiles/fleet-safe.json +7 -3
- package/profiles/schema.json +182 -51
- package/prompts/build.md +4 -2
- package/skills/space-build/SKILL.md +4 -2
- package/skills/space-finish/SKILL.md +6 -2
- package/skills/space-plan/reference/task-template.md +13 -5
- package/src/config.js +182 -32
- package/src/denylist.js +95 -0
- package/src/driver/run.mjs +81 -16
- package/src/effort.js +15 -4
- package/src/guard.ts +165 -13
- package/src/health.js +80 -0
- package/src/index.ts +15 -4
- package/src/lane-usage.js +72 -18
- package/src/plugin-type-check.ts +38 -0
- package/src/plugin.ts +39 -0
- package/src/profile.js +225 -26
- package/src/project.js +47 -0
- package/src/prune.ts +110 -31
- package/src/router.ts +32 -63
- package/src/rules.js +99 -7
- package/src/safe.ts +27 -0
- package/src/sqlite.js +144 -0
- package/src/tasks.js +112 -29
- package/src/telemetry-store.ts +62 -0
- package/src/telemetry.ts +60 -57
- package/src/trust.js +70 -9
package/dist/index.js
CHANGED
|
@@ -1,10 +1,64 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
3
|
-
import { join as
|
|
3
|
+
import { join as join8 } from "node:path";
|
|
4
|
+
|
|
5
|
+
// src/guard.ts
|
|
6
|
+
import { homedir as homedir2, tmpdir as tmpdir2 } from "node:os";
|
|
7
|
+
import { join as join3, resolve as resolve2 } from "node:path";
|
|
4
8
|
|
|
5
9
|
// src/rules.js
|
|
6
10
|
import { homedir, tmpdir } from "node:os";
|
|
7
11
|
import { resolve as resolvePath, sep } from "node:path";
|
|
12
|
+
|
|
13
|
+
// src/denylist.js
|
|
14
|
+
var BASH_DENYLIST = [
|
|
15
|
+
// `git push` is intentionally absent: it falls through to the `*` ask rule
|
|
16
|
+
// so opencode prompts for human approval before publishing.
|
|
17
|
+
"rm -rf *",
|
|
18
|
+
"rm -fr *",
|
|
19
|
+
"curl * | sh",
|
|
20
|
+
"curl * | bash",
|
|
21
|
+
"chmod 777 *",
|
|
22
|
+
"chmod 777*",
|
|
23
|
+
"gh pr create*",
|
|
24
|
+
"gh pr merge*",
|
|
25
|
+
"gh pr close*",
|
|
26
|
+
"gh release*",
|
|
27
|
+
"gh repo delete*",
|
|
28
|
+
// Publishing to a registry. The allowlist had a blanket `npm*`, so
|
|
29
|
+
// `npm publish` was ALLOWED - no prompt, no question - while GLOBAL.md says
|
|
30
|
+
// to ask before publishing. The mechanical layer contradicted the judgement
|
|
31
|
+
// layer, which is the exact failure this kit is built to catch.
|
|
32
|
+
//
|
|
33
|
+
// The registry is the one destination where a mistake cannot be undone by
|
|
34
|
+
// deleting a file.
|
|
35
|
+
"npm publish*",
|
|
36
|
+
"npm unpublish*",
|
|
37
|
+
"npm deprecate*",
|
|
38
|
+
"npm login*",
|
|
39
|
+
"npm adduser*",
|
|
40
|
+
"npm token *",
|
|
41
|
+
"npm owner *",
|
|
42
|
+
"yarn publish*",
|
|
43
|
+
"pnpm publish*",
|
|
44
|
+
"bun publish*"
|
|
45
|
+
];
|
|
46
|
+
function matchesPattern(command, pattern) {
|
|
47
|
+
const re = new RegExp(
|
|
48
|
+
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") + "$"
|
|
49
|
+
);
|
|
50
|
+
return re.test(command);
|
|
51
|
+
}
|
|
52
|
+
function deniedBy(command) {
|
|
53
|
+
const cmd = String(command ?? "").trim();
|
|
54
|
+
if (!cmd) return null;
|
|
55
|
+
for (const pattern of BASH_DENYLIST) {
|
|
56
|
+
if (matchesPattern(cmd, pattern)) return pattern;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/rules.js
|
|
8
62
|
var CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
|
|
9
63
|
function expandHome(p) {
|
|
10
64
|
if (p === "~") return homedir();
|
|
@@ -84,9 +138,17 @@ function segments(command) {
|
|
|
84
138
|
if (command[i + 1] === "|") i++;
|
|
85
139
|
continue;
|
|
86
140
|
}
|
|
87
|
-
if (ch === "&"
|
|
141
|
+
if (ch === "&") {
|
|
142
|
+
if (command[i - 1] === ">" || command[i + 1] === ">") {
|
|
143
|
+
cur += ch;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (command[i + 1] === "&") {
|
|
147
|
+
flush();
|
|
148
|
+
i++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
88
151
|
flush();
|
|
89
|
-
i++;
|
|
90
152
|
continue;
|
|
91
153
|
}
|
|
92
154
|
cur += ch;
|
|
@@ -138,8 +200,38 @@ function tokenize(segment) {
|
|
|
138
200
|
return tokens;
|
|
139
201
|
}
|
|
140
202
|
var ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
141
|
-
var GIT_WRAPPERS = /* @__PURE__ */ new Set([
|
|
203
|
+
var GIT_WRAPPERS = /* @__PURE__ */ new Set([
|
|
204
|
+
"env",
|
|
205
|
+
"sudo",
|
|
206
|
+
"doas",
|
|
207
|
+
"nohup",
|
|
208
|
+
"time",
|
|
209
|
+
"command",
|
|
210
|
+
"exec",
|
|
211
|
+
"xargs",
|
|
212
|
+
"npx"
|
|
213
|
+
]);
|
|
142
214
|
var WRAPPER_VALUE_FLAGS = /* @__PURE__ */ new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"]);
|
|
215
|
+
var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
216
|
+
"-C",
|
|
217
|
+
"-c",
|
|
218
|
+
"--git-dir",
|
|
219
|
+
"--work-tree",
|
|
220
|
+
"--namespace",
|
|
221
|
+
"--super-prefix",
|
|
222
|
+
"--exec-path",
|
|
223
|
+
"--config-env"
|
|
224
|
+
]);
|
|
225
|
+
var GIT_GLOBAL_FLAGS = /* @__PURE__ */ new Set([
|
|
226
|
+
"--bare",
|
|
227
|
+
"--no-pager",
|
|
228
|
+
"--paginate",
|
|
229
|
+
"--no-replace-objects",
|
|
230
|
+
"--no-optional-locks",
|
|
231
|
+
"--literal-pathspecs",
|
|
232
|
+
"--glob-pathspecs",
|
|
233
|
+
"--icase-pathspecs"
|
|
234
|
+
]);
|
|
143
235
|
function skipPrefixes(tokens) {
|
|
144
236
|
let i = 0;
|
|
145
237
|
for (; ; ) {
|
|
@@ -170,17 +262,40 @@ function stripPrefixes(segment) {
|
|
|
170
262
|
if (slash !== -1) rest[0] = rest[0].slice(slash + 1);
|
|
171
263
|
return rest.join(" ");
|
|
172
264
|
}
|
|
265
|
+
function gitSubcommand(segment) {
|
|
266
|
+
const tokens = tokenize(segment);
|
|
267
|
+
let i = skipPrefixes(tokens);
|
|
268
|
+
if (i >= tokens.length) return null;
|
|
269
|
+
if (!/(^|[\\/])git(\.exe)?$/i.test(tokens[i])) return null;
|
|
270
|
+
i++;
|
|
271
|
+
while (i < tokens.length) {
|
|
272
|
+
const flag = tokens[i];
|
|
273
|
+
if (GIT_VALUE_FLAGS.has(flag)) {
|
|
274
|
+
i += 2;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (/^--[A-Za-z][\w-]*=/.test(flag) || GIT_GLOBAL_FLAGS.has(flag)) {
|
|
278
|
+
i++;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
return tokens[i] ?? null;
|
|
284
|
+
}
|
|
173
285
|
var SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
|
|
174
286
|
var MAX_SHELL_DEPTH = 2;
|
|
175
287
|
function shellPayload(segment) {
|
|
176
288
|
const tokens = tokenize(segment);
|
|
177
289
|
if (tokens.length < 2) return null;
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
290
|
+
const i = skipPrefixes(tokens);
|
|
291
|
+
if (i >= tokens.length) return null;
|
|
292
|
+
if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null;
|
|
293
|
+
if (!SHELL_BIN.test(tokens[i])) return null;
|
|
294
|
+
const c = tokens.findIndex((t, n) => n > i && /^-[a-zA-Z]*c$/.test(t));
|
|
181
295
|
if (c === -1 || c === tokens.length - 1) return null;
|
|
182
296
|
return tokens.slice(c + 1).join(" ") || null;
|
|
183
297
|
}
|
|
298
|
+
var ASK_GATED = [/^\s*git\s+push\b/];
|
|
184
299
|
var BASH_RULES = [
|
|
185
300
|
{
|
|
186
301
|
name: "secret exfiltration via shell",
|
|
@@ -218,7 +333,18 @@ var BASH_RULES = [
|
|
|
218
333
|
{
|
|
219
334
|
name: "history rewrite",
|
|
220
335
|
scope: "segment",
|
|
221
|
-
|
|
336
|
+
// The subcommand is resolved rather than read off the front of the text,
|
|
337
|
+
// because git takes options before it: `git -C /repo reset --hard` is the
|
|
338
|
+
// same command as `git reset --hard`, and a rule anchored at `^git` is
|
|
339
|
+
// defeated by every one of them. `gitSubcommand` already knows which
|
|
340
|
+
// options take a value and which ones are global.
|
|
341
|
+
check: (text) => {
|
|
342
|
+
const sub = gitSubcommand(text);
|
|
343
|
+
if (sub === "reset") return /(^|\s)--hard(\s|$)/.test(text);
|
|
344
|
+
if (sub === "clean") return /(^|\s)-[a-zA-Z]*f/.test(text);
|
|
345
|
+
if (sub === "rebase") return /(^|\s)--force/.test(text);
|
|
346
|
+
return false;
|
|
347
|
+
},
|
|
222
348
|
why: "destroys work that only exists locally"
|
|
223
349
|
}
|
|
224
350
|
];
|
|
@@ -249,6 +375,24 @@ function matches(rule, text) {
|
|
|
249
375
|
if (!rule.test?.test(text)) return false;
|
|
250
376
|
return !rule.unless?.test(text);
|
|
251
377
|
}
|
|
378
|
+
function checkDenied(command, depth = 0) {
|
|
379
|
+
if (!command || !command.trim()) return null;
|
|
380
|
+
const parts = segments(command);
|
|
381
|
+
const normalized = parts.map((seg) => stripPrefixes(seg));
|
|
382
|
+
for (let i = 0; i < parts.length; i++) {
|
|
383
|
+
const pattern = deniedBy(normalized[i]);
|
|
384
|
+
if (pattern) return { pattern, segment: parts[i] };
|
|
385
|
+
}
|
|
386
|
+
if (depth < MAX_SHELL_DEPTH) {
|
|
387
|
+
for (const seg of parts) {
|
|
388
|
+
const payload = shellPayload(seg);
|
|
389
|
+
if (!payload) continue;
|
|
390
|
+
const hit = checkDenied(payload, depth + 1);
|
|
391
|
+
if (hit) return hit;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
252
396
|
function checkBash(command, rules = BASH_RULES, depth = 0) {
|
|
253
397
|
if (!command || !command.trim()) return null;
|
|
254
398
|
const parts = segments(command);
|
|
@@ -288,8 +432,82 @@ function checkWrite(target, rules = WRITE_RULES, root = "") {
|
|
|
288
432
|
return null;
|
|
289
433
|
}
|
|
290
434
|
|
|
435
|
+
// src/telemetry-store.ts
|
|
436
|
+
import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from "node:fs";
|
|
437
|
+
import { join } from "node:path";
|
|
438
|
+
var MAX_BYTES = 4 * 1024 * 1024;
|
|
439
|
+
function logPath(dir) {
|
|
440
|
+
return join(dir, "telemetry.jsonl");
|
|
441
|
+
}
|
|
442
|
+
function rotate(file) {
|
|
443
|
+
try {
|
|
444
|
+
if (existsSync(file) && statSync(file).size > MAX_BYTES) {
|
|
445
|
+
renameSync(file, `${file}.1`);
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function write(dir, record) {
|
|
451
|
+
try {
|
|
452
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
453
|
+
const file = logPath(dir);
|
|
454
|
+
rotate(file);
|
|
455
|
+
appendFileSync(file, JSON.stringify({ t: (/* @__PURE__ */ new Date()).toISOString(), ...record }) + "\n");
|
|
456
|
+
} catch {
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// src/project.js
|
|
461
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
462
|
+
import { dirname, join as join2, resolve } from "node:path";
|
|
463
|
+
function projectRoot(directory, worktree) {
|
|
464
|
+
const start = resolve(directory || worktree || ".");
|
|
465
|
+
let dir = start;
|
|
466
|
+
for (let i = 0; i < 64; i++) {
|
|
467
|
+
if (existsSync2(join2(dir, ".git"))) return dir;
|
|
468
|
+
const parent = dirname(dir);
|
|
469
|
+
if (parent === dir) break;
|
|
470
|
+
dir = parent;
|
|
471
|
+
}
|
|
472
|
+
return start;
|
|
473
|
+
}
|
|
474
|
+
|
|
291
475
|
// src/guard.ts
|
|
292
476
|
var MARKER = "[opencode-harness-kit] blocked";
|
|
477
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "apply_patch", "patch"]);
|
|
478
|
+
function pathsInPatch(text) {
|
|
479
|
+
const out = [];
|
|
480
|
+
const push = (p) => {
|
|
481
|
+
const v = p.trim();
|
|
482
|
+
if (!v) return;
|
|
483
|
+
if (v === "/dev/null") return;
|
|
484
|
+
if (!out.includes(v)) out.push(v);
|
|
485
|
+
};
|
|
486
|
+
for (const m of text.matchAll(/^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/gm)) {
|
|
487
|
+
push(m[1]);
|
|
488
|
+
}
|
|
489
|
+
for (const m of text.matchAll(/^\+\+\+\s+(?:b\/)?(.+)$/gm)) {
|
|
490
|
+
push(m[1]);
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
function writeTargets(tool, args) {
|
|
495
|
+
if (!args || typeof args !== "object") return [];
|
|
496
|
+
if (tool === "apply_patch") {
|
|
497
|
+
return pathsInPatch(String(args.patchText ?? args.patch ?? ""));
|
|
498
|
+
}
|
|
499
|
+
const out = [];
|
|
500
|
+
const one = (v) => {
|
|
501
|
+
if (typeof v === "string" && v) out.push(v);
|
|
502
|
+
};
|
|
503
|
+
one(args.filePath);
|
|
504
|
+
one(args.path);
|
|
505
|
+
for (const c of Array.isArray(args.changes) ? args.changes : []) {
|
|
506
|
+
one(c?.filePath);
|
|
507
|
+
one(c?.path);
|
|
508
|
+
}
|
|
509
|
+
return out;
|
|
510
|
+
}
|
|
293
511
|
function deny(why, detail) {
|
|
294
512
|
throw new Error(
|
|
295
513
|
`${MARKER}: ${why}
|
|
@@ -301,24 +519,72 @@ function truncate(s, n = 200) {
|
|
|
301
519
|
const one = s.replace(/\s+/g, " ").trim();
|
|
302
520
|
return one.length > n ? `${one.slice(0, n)}...` : one;
|
|
303
521
|
}
|
|
304
|
-
var GuardPlugin = async ({ worktree }) => {
|
|
522
|
+
var GuardPlugin = async ({ worktree, directory }) => {
|
|
523
|
+
const project = projectRoot(directory, worktree);
|
|
524
|
+
const telemetryDir = join3(project, ".space");
|
|
525
|
+
const refuse = (tool, rule, why, detail) => {
|
|
526
|
+
write(telemetryDir, { kind: "guard", tool, rule });
|
|
527
|
+
deny(why, detail);
|
|
528
|
+
};
|
|
305
529
|
return {
|
|
306
530
|
"tool.execute.before": async (input, output) => {
|
|
307
531
|
try {
|
|
308
532
|
if (input.tool === "bash") {
|
|
533
|
+
const raw = output.args?.workdir;
|
|
534
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
535
|
+
const target = raw.startsWith("~") ? join3(homedir2(), raw.slice(1)) : resolve2(project, raw);
|
|
536
|
+
if (!insideRoot(target, project) && !insideRoot(target, tmpdir2())) {
|
|
537
|
+
refuse(
|
|
538
|
+
input.tool,
|
|
539
|
+
"command outside the project",
|
|
540
|
+
"command runs outside the project",
|
|
541
|
+
`the working directory is ${truncate(target)}, and this project's files are the ones you were asked to change`
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
309
545
|
const hit = checkBash(String(output.args?.command ?? ""));
|
|
310
|
-
if (hit)
|
|
546
|
+
if (hit) {
|
|
547
|
+
refuse(input.tool, hit.name, hit.name, `${hit.why}
|
|
311
548
|
command: ${truncate(hit.segment)}`);
|
|
549
|
+
}
|
|
550
|
+
const command = String(output.args?.command ?? "");
|
|
551
|
+
const denied = checkDenied(command);
|
|
552
|
+
if (denied) {
|
|
553
|
+
refuse(
|
|
554
|
+
input.tool,
|
|
555
|
+
`denied command (${denied.pattern})`,
|
|
556
|
+
"denied command",
|
|
557
|
+
`this command matches \`${denied.pattern}\`, which the kit never allows.
|
|
558
|
+
command: ${truncate(denied.segment)}`
|
|
559
|
+
);
|
|
560
|
+
}
|
|
312
561
|
return;
|
|
313
562
|
}
|
|
314
|
-
if (
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
563
|
+
if (WRITE_TOOLS.has(input.tool)) {
|
|
564
|
+
const targets = writeTargets(input.tool, output.args);
|
|
565
|
+
if (!targets.length) {
|
|
566
|
+
refuse(
|
|
567
|
+
input.tool,
|
|
568
|
+
"unknown write target",
|
|
569
|
+
"cannot tell what this write touches",
|
|
570
|
+
`the ${input.tool} tool's arguments name no file, so the scope of the write is unknown`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
for (const target of targets) {
|
|
574
|
+
const hit = checkWrite(target, void 0, project);
|
|
575
|
+
if (hit) {
|
|
576
|
+
refuse(input.tool, hit.name, hit.name, `${hit.why}
|
|
318
577
|
file: ${truncate(hit.segment)}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
319
580
|
}
|
|
320
581
|
} catch (err) {
|
|
321
582
|
if (err instanceof Error && err.message.startsWith(MARKER)) throw err;
|
|
583
|
+
write(telemetryDir, {
|
|
584
|
+
kind: "guard",
|
|
585
|
+
tool: input.tool,
|
|
586
|
+
rule: "guard failed closed"
|
|
587
|
+
});
|
|
322
588
|
throw new Error(
|
|
323
589
|
`${MARKER} (guard failed closed): ${err instanceof Error ? err.message : String(err)}`
|
|
324
590
|
);
|
|
@@ -328,21 +594,22 @@ var GuardPlugin = async ({ worktree }) => {
|
|
|
328
594
|
};
|
|
329
595
|
|
|
330
596
|
// src/profile.js
|
|
331
|
-
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
332
|
-
import { dirname, join, resolve } from "node:path";
|
|
597
|
+
import { readFileSync, existsSync as existsSync3, readdirSync } from "node:fs";
|
|
598
|
+
import { dirname as dirname2, join as join4, resolve as resolve3 } from "node:path";
|
|
599
|
+
import { homedir as homedir3 } from "node:os";
|
|
333
600
|
import { fileURLToPath } from "node:url";
|
|
334
|
-
var HERE =
|
|
601
|
+
var HERE = dirname2(fileURLToPath(import.meta.url));
|
|
335
602
|
function findRoot(start) {
|
|
336
603
|
let dir = start;
|
|
337
|
-
for (let i = 0; i <
|
|
338
|
-
if (
|
|
339
|
-
const parent =
|
|
604
|
+
for (let i = 0; i < 12; i++) {
|
|
605
|
+
if (existsSync3(join4(dir, "profiles"))) return dir;
|
|
606
|
+
const parent = dirname2(dir);
|
|
340
607
|
if (parent === dir) break;
|
|
341
608
|
dir = parent;
|
|
342
609
|
}
|
|
343
|
-
return
|
|
610
|
+
return resolve3(start, "..");
|
|
344
611
|
}
|
|
345
|
-
var ROOT = findRoot(
|
|
612
|
+
var ROOT = findRoot(resolve3(HERE, ".."));
|
|
346
613
|
var ProfileError = class extends Error {
|
|
347
614
|
};
|
|
348
615
|
var LANE_NAMES = [
|
|
@@ -357,14 +624,20 @@ var LANE_NAMES = [
|
|
|
357
624
|
"sweep",
|
|
358
625
|
"longform"
|
|
359
626
|
];
|
|
360
|
-
var RETENTION_SEVERITY = ["zero-retention", "retained", "
|
|
627
|
+
var RETENTION_SEVERITY = ["zero-retention", "retained", "may-train", "unknown"];
|
|
628
|
+
function retentionSeverity(level) {
|
|
629
|
+
return RETENTION_SEVERITY.indexOf(
|
|
630
|
+
/** @type {Retention} */
|
|
631
|
+
level
|
|
632
|
+
);
|
|
633
|
+
}
|
|
361
634
|
function profileDirs() {
|
|
362
|
-
return [
|
|
635
|
+
return [process.env.OPENCODE_HARNESS_PROFILES ?? join4(ROOT, "profiles")];
|
|
363
636
|
}
|
|
364
637
|
function profilePath(id) {
|
|
365
638
|
for (const dir of profileDirs()) {
|
|
366
|
-
const full =
|
|
367
|
-
if (
|
|
639
|
+
const full = join4(dir, `${id}.json`);
|
|
640
|
+
if (existsSync3(full)) return full;
|
|
368
641
|
}
|
|
369
642
|
return null;
|
|
370
643
|
}
|
|
@@ -405,8 +678,8 @@ function normalize(p) {
|
|
|
405
678
|
var laneCache = null;
|
|
406
679
|
function loadLanes() {
|
|
407
680
|
if (laneCache) return laneCache;
|
|
408
|
-
const file =
|
|
409
|
-
if (!
|
|
681
|
+
const file = join4(ROOT, "lanes.json");
|
|
682
|
+
if (!existsSync3(file)) {
|
|
410
683
|
throw new ProfileError(`lane table missing: ${file}`);
|
|
411
684
|
}
|
|
412
685
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -425,15 +698,25 @@ function loadLanes() {
|
|
|
425
698
|
function laneMeta(name) {
|
|
426
699
|
return loadLanes()[name] ?? null;
|
|
427
700
|
}
|
|
701
|
+
var retentionCache = null;
|
|
428
702
|
function loadRetention() {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
703
|
+
if (retentionCache) return retentionCache;
|
|
704
|
+
const file = join4(ROOT, "retention.yml");
|
|
705
|
+
if (!existsSync3(file)) {
|
|
706
|
+
retentionCache = { models: {}, defaults: {} };
|
|
707
|
+
return retentionCache;
|
|
708
|
+
}
|
|
709
|
+
const models = (
|
|
432
710
|
/** @type {Record<string, any>} */
|
|
433
711
|
{}
|
|
434
712
|
);
|
|
713
|
+
const defaults = (
|
|
714
|
+
/** @type {Record<string, string>} */
|
|
715
|
+
{}
|
|
716
|
+
);
|
|
435
717
|
const lines = readFileSync(file, "utf8").split("\n");
|
|
436
718
|
let current = null;
|
|
719
|
+
let inDefaults = false;
|
|
437
720
|
let i = 0;
|
|
438
721
|
while (i < lines.length) {
|
|
439
722
|
const raw = lines[i];
|
|
@@ -443,10 +726,28 @@ function loadRetention() {
|
|
|
443
726
|
i++;
|
|
444
727
|
continue;
|
|
445
728
|
}
|
|
729
|
+
if (indent === 0 && line === "defaults:") {
|
|
730
|
+
inDefaults = true;
|
|
731
|
+
current = null;
|
|
732
|
+
i++;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (indent === 0 && line === "models:") {
|
|
736
|
+
inDefaults = false;
|
|
737
|
+
current = null;
|
|
738
|
+
i++;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (inDefaults) {
|
|
742
|
+
const pair = line.match(/^([a-z-]+)\s*:\s*(\S+)\s*$/);
|
|
743
|
+
if (pair) defaults[pair[1]] = pair[2];
|
|
744
|
+
i++;
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
446
747
|
if (indent === 2 && line.endsWith(":")) {
|
|
447
748
|
const key = line.slice(0, -1).trim();
|
|
448
749
|
current = key.includes("/") ? key : `opencode/${key}`;
|
|
449
|
-
|
|
750
|
+
models[current] = { retention: "unknown" };
|
|
450
751
|
i++;
|
|
451
752
|
continue;
|
|
452
753
|
}
|
|
@@ -470,34 +771,41 @@ function loadRetention() {
|
|
|
470
771
|
if (l.length - l.trimStart().length <= indent) break;
|
|
471
772
|
parts.push(l.trim());
|
|
472
773
|
}
|
|
473
|
-
|
|
774
|
+
models[current][name] = parts.join(" ").replace(/\s+/g, " ").trim();
|
|
474
775
|
i = j;
|
|
475
776
|
continue;
|
|
476
777
|
}
|
|
477
|
-
|
|
778
|
+
models[current][name] = rest.replace(/^["']|["']$/g, "");
|
|
478
779
|
i++;
|
|
479
780
|
continue;
|
|
480
781
|
}
|
|
481
782
|
i++;
|
|
482
783
|
}
|
|
483
|
-
|
|
784
|
+
retentionCache = { models, defaults };
|
|
785
|
+
return retentionCache;
|
|
484
786
|
}
|
|
485
787
|
function retentionFor(id) {
|
|
486
|
-
const table = loadRetention();
|
|
788
|
+
const table = loadRetention().models;
|
|
487
789
|
return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`];
|
|
488
790
|
}
|
|
489
|
-
function
|
|
490
|
-
const
|
|
791
|
+
function retentionOf(table, model) {
|
|
792
|
+
const fallback = table?.defaults?.unknown ?? "unknown";
|
|
793
|
+
const models = table?.models ?? {};
|
|
794
|
+
const record = models[model] ?? models[model.includes("/") ? model : `opencode/${model}`];
|
|
795
|
+
const declared = record?.retention ?? fallback;
|
|
491
796
|
return (
|
|
492
797
|
/** @type {Retention} */
|
|
493
798
|
["zero-retention", "retained", "may-train", "unknown"].includes(declared) ? declared : "unknown"
|
|
494
799
|
);
|
|
495
800
|
}
|
|
801
|
+
function modelRetention(model) {
|
|
802
|
+
return retentionOf(loadRetention(), model);
|
|
803
|
+
}
|
|
496
804
|
function retentionAllowed(model, ceiling) {
|
|
497
805
|
const level = modelRetention(model);
|
|
498
806
|
if (level === "unknown") return false;
|
|
499
|
-
const max =
|
|
500
|
-
const have =
|
|
807
|
+
const max = retentionSeverity(ceiling);
|
|
808
|
+
const have = retentionSeverity(level);
|
|
501
809
|
if (max < 0) return false;
|
|
502
810
|
return have <= max;
|
|
503
811
|
}
|
|
@@ -518,6 +826,9 @@ function resolveLaneModel(p, role, opts = {}) {
|
|
|
518
826
|
if (!retentionAllowed(candidate, ceiling)) continue;
|
|
519
827
|
return { model: candidate, levels: levelsOfModel(candidate) };
|
|
520
828
|
}
|
|
829
|
+
if (p.model && !unhealthy.has(p.model) && retentionAllowed(p.model, ceiling)) {
|
|
830
|
+
return { model: p.model, levels: levelsOfModel(p.model) };
|
|
831
|
+
}
|
|
521
832
|
const fallback = resolveLaneChain(p, role)[0] ?? p.model;
|
|
522
833
|
return { model: fallback, levels: levelsOfModel(fallback) };
|
|
523
834
|
}
|
|
@@ -551,6 +862,7 @@ function resolveEffort(levels, requested) {
|
|
|
551
862
|
if (!requested) return null;
|
|
552
863
|
if (!levels || levels.length === 0) return null;
|
|
553
864
|
if (levels.includes(requested)) return requested;
|
|
865
|
+
if (requested === "none") return null;
|
|
554
866
|
const want = CANON.indexOf(requested);
|
|
555
867
|
if (want < 0) {
|
|
556
868
|
return levels[levels.length - 1] ?? null;
|
|
@@ -611,6 +923,29 @@ function validate(p, path = "<memory>") {
|
|
|
611
923
|
);
|
|
612
924
|
}
|
|
613
925
|
}
|
|
926
|
+
if (p.prune) {
|
|
927
|
+
const prune = p.prune;
|
|
928
|
+
if (prune.keepRecentToolCalls !== void 0) {
|
|
929
|
+
if (!Number.isInteger(prune.keepRecentToolCalls) || prune.keepRecentToolCalls < 1) {
|
|
930
|
+
throw new ProfileError(
|
|
931
|
+
`${where}: prune.keepRecentToolCalls must be an integer of at least 1`
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (prune.keepRecentTurns !== void 0) {
|
|
936
|
+
if (!Number.isInteger(prune.keepRecentTurns) || prune.keepRecentTurns < 1) {
|
|
937
|
+
throw new ProfileError(
|
|
938
|
+
`${where}: prune.keepRecentTurns must be an integer of at least 1`
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (prune.keepUserMessages !== void 0 && typeof prune.keepUserMessages !== "boolean") {
|
|
943
|
+
throw new ProfileError(`${where}: prune.keepUserMessages must be a boolean`);
|
|
944
|
+
}
|
|
945
|
+
if (prune.enabled !== void 0 && typeof prune.enabled !== "boolean") {
|
|
946
|
+
throw new ProfileError(`${where}: prune.enabled must be a boolean`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
614
949
|
for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
|
|
615
950
|
if (!LANE_NAMES.includes(role)) {
|
|
616
951
|
throw new ProfileError(
|
|
@@ -625,9 +960,9 @@ function validate(p, path = "<memory>") {
|
|
|
625
960
|
if (!rm?.model || typeof rm.model !== "string") {
|
|
626
961
|
throw new ProfileError(`${where}: roleModels.${role} needs a model`);
|
|
627
962
|
}
|
|
628
|
-
if (rm.levels !== void 0
|
|
963
|
+
if (rm.levels !== void 0) {
|
|
629
964
|
throw new ProfileError(
|
|
630
|
-
`${where}: roleModels.${role}.levels
|
|
965
|
+
`${where}: roleModels.${role}.levels is not read by anything - the lane takes its levels from the model it runs on. Remove it.`
|
|
631
966
|
);
|
|
632
967
|
}
|
|
633
968
|
if (rm.chain !== void 0) {
|
|
@@ -647,6 +982,11 @@ function validate(p, path = "<memory>") {
|
|
|
647
982
|
`${where}: roleModels.${role}.chain names ${rung}, which has no retention.yml entry`
|
|
648
983
|
);
|
|
649
984
|
}
|
|
985
|
+
if (!profileExists(rung)) {
|
|
986
|
+
throw new ProfileError(
|
|
987
|
+
`${where}: roleModels.${role}.chain names ${rung}, which has no profile in this directory`
|
|
988
|
+
);
|
|
989
|
+
}
|
|
650
990
|
}
|
|
651
991
|
}
|
|
652
992
|
if (!retentionFor(rm.model)) {
|
|
@@ -654,8 +994,38 @@ function validate(p, path = "<memory>") {
|
|
|
654
994
|
`${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`
|
|
655
995
|
);
|
|
656
996
|
}
|
|
997
|
+
if (!profileExists(rm.model)) {
|
|
998
|
+
throw new ProfileError(
|
|
999
|
+
`${where}: roleModels.${role} names ${rm.model}, which has no profile in this directory`
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (p.utility) {
|
|
1004
|
+
if (typeof p.utility.model !== "string" || !p.utility.model) {
|
|
1005
|
+
throw new ProfileError(`${where}: utility.model must be a model id`);
|
|
1006
|
+
}
|
|
1007
|
+
const reachable = /* @__PURE__ */ new Set([p.model]);
|
|
1008
|
+
for (const rm of Object.values(p.roleModels ?? {})) {
|
|
1009
|
+
if (rm?.model) reachable.add(rm.model);
|
|
1010
|
+
for (const rung of rm?.chain ?? []) reachable.add(rung);
|
|
1011
|
+
}
|
|
1012
|
+
if (!reachable.has(p.utility.model)) {
|
|
1013
|
+
throw new ProfileError(
|
|
1014
|
+
`${where}: utility.model is ${p.utility.model}, which this profile does not resolve to (it has ${[...reachable].join(", ")})`
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
for (const id of p.fallback ?? []) {
|
|
1019
|
+
if (!profileExists(id)) {
|
|
1020
|
+
throw new ProfileError(
|
|
1021
|
+
`${where}: fallback names ${id}, which has no profile in this directory`
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
657
1024
|
}
|
|
658
1025
|
}
|
|
1026
|
+
function profileExists(id) {
|
|
1027
|
+
return profilePath(id) !== null;
|
|
1028
|
+
}
|
|
659
1029
|
function inputCap(p, opts = {}) {
|
|
660
1030
|
let cap = p.limits.input ?? p.limits.context;
|
|
661
1031
|
for (const s of roleSplitModels(p, opts)) {
|
|
@@ -684,10 +1054,10 @@ function roleSplitModels(p, opts = {}) {
|
|
|
684
1054
|
});
|
|
685
1055
|
}
|
|
686
1056
|
function configDir() {
|
|
687
|
-
return process.env.OPENCODE_CONFIG_DIR ??
|
|
1057
|
+
return process.env.OPENCODE_CONFIG_DIR ?? join4(homedir3(), ".config", "opencode");
|
|
688
1058
|
}
|
|
689
1059
|
function configFilePath() {
|
|
690
|
-
return
|
|
1060
|
+
return join4(configDir(), "opencode.jsonc");
|
|
691
1061
|
}
|
|
692
1062
|
function parseJsonc(text) {
|
|
693
1063
|
let out = "";
|
|
@@ -762,7 +1132,7 @@ function profileIdFromConfig() {
|
|
|
762
1132
|
const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
|
|
763
1133
|
if (fromEnv) return fromEnv.trim() || null;
|
|
764
1134
|
const file = configFilePath();
|
|
765
|
-
if (!
|
|
1135
|
+
if (!existsSync3(file)) return null;
|
|
766
1136
|
try {
|
|
767
1137
|
const parsed = parseJsonc(readFileSync(file, "utf8"));
|
|
768
1138
|
const harness = parsed?.harness;
|
|
@@ -777,18 +1147,16 @@ function profileIdFromConfig() {
|
|
|
777
1147
|
}
|
|
778
1148
|
|
|
779
1149
|
// src/prune.ts
|
|
780
|
-
import { readFileSync as readFileSync2, existsSync as
|
|
781
|
-
import { join as
|
|
1150
|
+
import { readFileSync as readFileSync2, existsSync as existsSync4 } from "node:fs";
|
|
1151
|
+
import { join as join5 } from "node:path";
|
|
782
1152
|
function activeProfileId(worktree) {
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
join2(ROOT, "profiles", ".active")
|
|
787
|
-
];
|
|
1153
|
+
const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
|
|
1154
|
+
if (fromEnv && fromEnv.trim()) return fromEnv.trim();
|
|
1155
|
+
const candidates = [join5(worktree, ".space", "active")];
|
|
788
1156
|
for (const f of candidates) {
|
|
789
1157
|
if (!f) continue;
|
|
790
1158
|
try {
|
|
791
|
-
if (
|
|
1159
|
+
if (existsSync4(f)) {
|
|
792
1160
|
const v = readFileSync2(f, "utf8").trim();
|
|
793
1161
|
if (v) return v;
|
|
794
1162
|
}
|
|
@@ -797,6 +1165,7 @@ function activeProfileId(worktree) {
|
|
|
797
1165
|
}
|
|
798
1166
|
return null;
|
|
799
1167
|
}
|
|
1168
|
+
var REASONING_STUB = "[reasoning] dropped from context - it was from an earlier turn and is stale.";
|
|
800
1169
|
function sizeOf(part) {
|
|
801
1170
|
if (typeof part?.text === "string") return part.text.length;
|
|
802
1171
|
if (typeof part?.state?.output === "string") return part.state.output.length;
|
|
@@ -811,7 +1180,7 @@ function stub(part) {
|
|
|
811
1180
|
const status = state.status ?? "done";
|
|
812
1181
|
let tail = "";
|
|
813
1182
|
const out = String(state.output ?? "");
|
|
814
|
-
const m = out.match(
|
|
1183
|
+
const m = out.match(/\b(?:\d+\s+(?:passed|failed)|passed|failed|error|ok)\b/gi);
|
|
815
1184
|
if (m && m.length) tail = `, ${m[m.length - 1]}`;
|
|
816
1185
|
return `[${tool}] ${cmd} -> ${status}${tail} | full output was pruned from context; re-run the command if you need it`;
|
|
817
1186
|
}
|
|
@@ -821,24 +1190,40 @@ function stub(part) {
|
|
|
821
1190
|
}
|
|
822
1191
|
return `[${tool}] result pruned from context (${state.status ?? "done"}); re-run if you need it`;
|
|
823
1192
|
}
|
|
824
|
-
var PrunePlugin = async ({ worktree,
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1193
|
+
var PrunePlugin = async ({ worktree, directory }) => {
|
|
1194
|
+
const project = projectRoot(directory, worktree);
|
|
1195
|
+
const DISABLED = {
|
|
1196
|
+
profile: null,
|
|
1197
|
+
keepCalls: 0,
|
|
1198
|
+
keepTurns: 0,
|
|
1199
|
+
keepUsers: true,
|
|
1200
|
+
threshold: 0
|
|
1201
|
+
};
|
|
1202
|
+
let cached = null;
|
|
1203
|
+
const resolve4 = () => {
|
|
1204
|
+
if (cached) return cached;
|
|
1205
|
+
let profile = null;
|
|
1206
|
+
try {
|
|
1207
|
+
const id = activeProfileId(project);
|
|
1208
|
+
if (id) profile = loadProfile(id);
|
|
1209
|
+
} catch {
|
|
1210
|
+
profile = null;
|
|
1211
|
+
}
|
|
1212
|
+
if (!profile?.prune?.enabled) return DISABLED;
|
|
1213
|
+
cached = {
|
|
1214
|
+
profile,
|
|
1215
|
+
keepCalls: profile.prune.keepRecentToolCalls ?? 6,
|
|
1216
|
+
keepTurns: profile.prune.keepRecentTurns ?? 3,
|
|
1217
|
+
keepUsers: profile.prune.keepUserMessages ?? true,
|
|
1218
|
+
threshold: pruneThreshold(profile)
|
|
1219
|
+
};
|
|
1220
|
+
return cached;
|
|
1221
|
+
};
|
|
839
1222
|
return {
|
|
840
1223
|
"experimental.chat.messages.transform": async (_input, output) => {
|
|
841
1224
|
try {
|
|
1225
|
+
const { profile, keepCalls, keepTurns, keepUsers, threshold } = resolve4();
|
|
1226
|
+
if (!profile) return;
|
|
842
1227
|
const messages = output.messages;
|
|
843
1228
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
844
1229
|
const floor = Math.max(0, messages.length - 1 - keepTurns);
|
|
@@ -883,10 +1268,12 @@ var PrunePlugin = async ({ worktree, $ }) => {
|
|
|
883
1268
|
stats.bytesAfter += size;
|
|
884
1269
|
stats.kept++;
|
|
885
1270
|
break;
|
|
886
|
-
case "reasoning":
|
|
887
|
-
|
|
1271
|
+
case "reasoning": {
|
|
1272
|
+
part.text = REASONING_STUB;
|
|
1273
|
+
stats.bytesAfter += sizeOf(part);
|
|
888
1274
|
stats.stubbed++;
|
|
889
1275
|
break;
|
|
1276
|
+
}
|
|
890
1277
|
case "tool": {
|
|
891
1278
|
const state = part.state;
|
|
892
1279
|
if (state?.status !== "completed") {
|
|
@@ -918,7 +1305,7 @@ var PrunePlugin = async ({ worktree, $ }) => {
|
|
|
918
1305
|
}
|
|
919
1306
|
if (stats.stubbed > 0) {
|
|
920
1307
|
const saved = Math.max(0, stats.bytesBefore - stats.bytesAfter);
|
|
921
|
-
await logPrune(profile.id, {
|
|
1308
|
+
await logPrune(project, profile.id, {
|
|
922
1309
|
...stats,
|
|
923
1310
|
saved,
|
|
924
1311
|
threshold
|
|
@@ -932,19 +1319,19 @@ var PrunePlugin = async ({ worktree, $ }) => {
|
|
|
932
1319
|
}
|
|
933
1320
|
};
|
|
934
1321
|
};
|
|
935
|
-
async function logPrune(profileId, stats) {
|
|
936
|
-
const dir =
|
|
1322
|
+
async function logPrune(worktree, profileId, stats) {
|
|
1323
|
+
const dir = join5(worktree, ".space");
|
|
937
1324
|
try {
|
|
938
1325
|
const { mkdir, appendFile } = await import("node:fs/promises");
|
|
939
1326
|
await mkdir(dir, { recursive: true });
|
|
940
1327
|
await appendFile(
|
|
941
|
-
|
|
1328
|
+
join5(dir, "prune.jsonl"),
|
|
942
1329
|
JSON.stringify({
|
|
943
1330
|
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
944
1331
|
profile: profileId,
|
|
945
1332
|
stubbed: stats.stubbed,
|
|
946
1333
|
kept: stats.kept,
|
|
947
|
-
|
|
1334
|
+
savedChars: stats.saved,
|
|
948
1335
|
threshold: stats.threshold
|
|
949
1336
|
}) + "\n"
|
|
950
1337
|
);
|
|
@@ -952,34 +1339,56 @@ async function logPrune(profileId, stats) {
|
|
|
952
1339
|
}
|
|
953
1340
|
}
|
|
954
1341
|
|
|
955
|
-
// src/
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1342
|
+
// src/safe.ts
|
|
1343
|
+
function safeStringify(value) {
|
|
1344
|
+
try {
|
|
1345
|
+
return JSON.stringify(value) ?? String(value);
|
|
1346
|
+
} catch {
|
|
1347
|
+
try {
|
|
1348
|
+
return String(value);
|
|
1349
|
+
} catch {
|
|
1350
|
+
return "[unstringifiable]";
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
960
1353
|
}
|
|
961
|
-
|
|
1354
|
+
|
|
1355
|
+
// src/config.js
|
|
1356
|
+
function utilityModel(p) {
|
|
1357
|
+
const u = p?.utility;
|
|
1358
|
+
if (!u || typeof u.model !== "string" || !u.model) return null;
|
|
1359
|
+
return `${p.provider ?? "opencode"}/${u.model}`;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// src/health.js
|
|
1363
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync } from "node:fs";
|
|
1364
|
+
import { join as join6 } from "node:path";
|
|
1365
|
+
function healthPath(dir) {
|
|
1366
|
+
return join6(dir, ".space", "health.json");
|
|
1367
|
+
}
|
|
1368
|
+
function readHealth(dir) {
|
|
962
1369
|
try {
|
|
963
|
-
return JSON.parse(readFileSync3(healthPath(
|
|
1370
|
+
return JSON.parse(readFileSync3(healthPath(dir), "utf8"));
|
|
964
1371
|
} catch {
|
|
965
1372
|
return { unhealthy: {} };
|
|
966
1373
|
}
|
|
967
1374
|
}
|
|
968
|
-
function writeHealth(
|
|
1375
|
+
function writeHealth(dir, health) {
|
|
969
1376
|
try {
|
|
970
|
-
|
|
971
|
-
writeFileSync(healthPath(
|
|
1377
|
+
if (!existsSync5(join6(dir, ".space"))) mkdirSync2(join6(dir, ".space"), { recursive: true });
|
|
1378
|
+
writeFileSync(healthPath(dir), JSON.stringify(health, null, 2));
|
|
972
1379
|
} catch {
|
|
973
1380
|
}
|
|
974
1381
|
}
|
|
975
|
-
function unhealthyModels(
|
|
976
|
-
const health = readHealth(
|
|
1382
|
+
function unhealthyModels(dir) {
|
|
1383
|
+
const health = readHealth(dir);
|
|
977
1384
|
const out = /* @__PURE__ */ new Set();
|
|
978
1385
|
for (const key of Object.keys(health.unhealthy ?? {})) {
|
|
979
1386
|
out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key);
|
|
980
1387
|
}
|
|
981
1388
|
return out;
|
|
982
1389
|
}
|
|
1390
|
+
|
|
1391
|
+
// src/router.ts
|
|
983
1392
|
var SPENT = [
|
|
984
1393
|
/FreeUsageLimitError/i,
|
|
985
1394
|
/rate[_ -]?limit/i,
|
|
@@ -994,7 +1403,8 @@ function classify(text) {
|
|
|
994
1403
|
return null;
|
|
995
1404
|
}
|
|
996
1405
|
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.";
|
|
997
|
-
var RouterPlugin = async ({ worktree }) => {
|
|
1406
|
+
var RouterPlugin = async ({ worktree, directory }) => {
|
|
1407
|
+
const project = projectRoot(directory, worktree);
|
|
998
1408
|
let failedSessions = 0;
|
|
999
1409
|
let warned = false;
|
|
1000
1410
|
return {
|
|
@@ -1002,8 +1412,8 @@ var RouterPlugin = async ({ worktree }) => {
|
|
|
1002
1412
|
config: async (config) => {
|
|
1003
1413
|
try {
|
|
1004
1414
|
const cfg = config;
|
|
1005
|
-
const health = readHealth(
|
|
1006
|
-
const unhealthy = unhealthyModels(
|
|
1415
|
+
const health = readHealth(project);
|
|
1416
|
+
const unhealthy = unhealthyModels(project);
|
|
1007
1417
|
const activeId = cfg.harness?.profile ?? profileIdFromConfig();
|
|
1008
1418
|
if (!activeId) return;
|
|
1009
1419
|
const id = activeId;
|
|
@@ -1017,7 +1427,6 @@ var RouterPlugin = async ({ worktree }) => {
|
|
|
1017
1427
|
if (health.unhealthy?.[baseKey]) {
|
|
1018
1428
|
const queue = [id, ...profile.fallback ?? []];
|
|
1019
1429
|
for (const candidate of queue) {
|
|
1020
|
-
if (health.unhealthy?.[`opencode/${candidate}`]) continue;
|
|
1021
1430
|
let fallback;
|
|
1022
1431
|
try {
|
|
1023
1432
|
fallback = loadProfile(candidate);
|
|
@@ -1036,7 +1445,7 @@ var RouterPlugin = async ({ worktree }) => {
|
|
|
1036
1445
|
threshold: fleet ? pruneThreshold(profile) : void 0
|
|
1037
1446
|
});
|
|
1038
1447
|
health.active = candidate;
|
|
1039
|
-
writeHealth(
|
|
1448
|
+
writeHealth(project, health);
|
|
1040
1449
|
break;
|
|
1041
1450
|
}
|
|
1042
1451
|
}
|
|
@@ -1063,16 +1472,16 @@ var RouterPlugin = async ({ worktree }) => {
|
|
|
1063
1472
|
const e = event;
|
|
1064
1473
|
const type = e?.type;
|
|
1065
1474
|
if (!type) return;
|
|
1066
|
-
const text =
|
|
1475
|
+
const text = safeStringify(e);
|
|
1067
1476
|
const reason = classify(text);
|
|
1068
1477
|
if (!reason) return;
|
|
1069
1478
|
const modelID = e.properties?.modelID ?? e.properties?.model ?? e.properties?.modelID;
|
|
1070
1479
|
const key = modelID ? `opencode/${modelID}` : void 0;
|
|
1071
1480
|
if (!key) return;
|
|
1072
|
-
const health = readHealth(
|
|
1481
|
+
const health = readHealth(project);
|
|
1073
1482
|
if (health.unhealthy[key]) return;
|
|
1074
1483
|
health.unhealthy[key] = { at: (/* @__PURE__ */ new Date()).toISOString(), reason };
|
|
1075
|
-
writeHealth(
|
|
1484
|
+
writeHealth(project, health);
|
|
1076
1485
|
failedSessions++;
|
|
1077
1486
|
},
|
|
1078
1487
|
"chat.message": async (input, output) => {
|
|
@@ -1087,7 +1496,7 @@ var RouterPlugin = async ({ worktree }) => {
|
|
|
1087
1496
|
${WARN_TEXT}`;
|
|
1088
1497
|
return;
|
|
1089
1498
|
}
|
|
1090
|
-
writeHealth(
|
|
1499
|
+
writeHealth(project, readHealth(project));
|
|
1091
1500
|
}
|
|
1092
1501
|
};
|
|
1093
1502
|
};
|
|
@@ -1098,7 +1507,7 @@ function applySessionModel(cfg, profile, opts = {}) {
|
|
|
1098
1507
|
try {
|
|
1099
1508
|
const model = `${profile.provider}/${profile.model}`;
|
|
1100
1509
|
cfg.model = model;
|
|
1101
|
-
cfg.small_model = model;
|
|
1510
|
+
cfg.small_model = utilityModel(profile) ?? model;
|
|
1102
1511
|
const variant = profile.effort?.default;
|
|
1103
1512
|
if (profile.effort?.levels?.length && variant) cfg.variant = variant;
|
|
1104
1513
|
else delete cfg.variant;
|
|
@@ -1127,31 +1536,9 @@ function applySessionModel(cfg, profile, opts = {}) {
|
|
|
1127
1536
|
}
|
|
1128
1537
|
|
|
1129
1538
|
// src/telemetry.ts
|
|
1130
|
-
import {
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
function logPath(dir) {
|
|
1134
|
-
return join4(dir, "telemetry.jsonl");
|
|
1135
|
-
}
|
|
1136
|
-
function rotate(file) {
|
|
1137
|
-
try {
|
|
1138
|
-
if (existsSync4(file) && statSync(file).size > MAX_BYTES) {
|
|
1139
|
-
renameSync(file, `${file}.1`);
|
|
1140
|
-
}
|
|
1141
|
-
} catch {
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
function write(dir, record) {
|
|
1145
|
-
try {
|
|
1146
|
-
if (!existsSync4(dir)) mkdirSync2(dir, { recursive: true });
|
|
1147
|
-
const file = logPath(dir);
|
|
1148
|
-
rotate(file);
|
|
1149
|
-
appendFileSync(file, JSON.stringify({ t: (/* @__PURE__ */ new Date()).toISOString(), ...record }) + "\n");
|
|
1150
|
-
} catch {
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
var TelemetryPlugin = async ({ worktree }) => {
|
|
1154
|
-
const dir = join4(worktree, ".space");
|
|
1539
|
+
import { join as join7 } from "node:path";
|
|
1540
|
+
var TelemetryPlugin = async ({ worktree, directory }) => {
|
|
1541
|
+
const dir = join7(projectRoot(directory, worktree), ".space");
|
|
1155
1542
|
const started = Date.now();
|
|
1156
1543
|
const opened = /* @__PURE__ */ new Set();
|
|
1157
1544
|
const closed = /* @__PURE__ */ new Set();
|
|
@@ -1159,7 +1546,7 @@ var TelemetryPlugin = async ({ worktree }) => {
|
|
|
1159
1546
|
event: async ({ event }) => {
|
|
1160
1547
|
const type = event?.type;
|
|
1161
1548
|
if (typeof type !== "string") return;
|
|
1162
|
-
if (/error|rate|limit|quota/i.test(type)) {
|
|
1549
|
+
if (/(^|[._-])(error|errors|rate|limit|limits|quota)([._-]|$)/i.test(type)) {
|
|
1163
1550
|
write(dir, { kind: "provider-event", type, detail: truncate2(safeStringify(event)) });
|
|
1164
1551
|
return;
|
|
1165
1552
|
}
|
|
@@ -1178,14 +1565,9 @@ var TelemetryPlugin = async ({ worktree }) => {
|
|
|
1178
1565
|
}
|
|
1179
1566
|
},
|
|
1180
1567
|
"tool.execute.after": async (input, output) => {
|
|
1181
|
-
const text = typeof output?.output === "string" ? output.output : "";
|
|
1182
|
-
if (text.includes("[opencode-harness-kit] blocked")) {
|
|
1183
|
-
write(dir, { kind: "guard", tool: input.tool, rule: extractRule(text) });
|
|
1184
|
-
return;
|
|
1185
|
-
}
|
|
1186
1568
|
const command = String(input.args?.command ?? "");
|
|
1187
|
-
if (input.tool === "bash" &&
|
|
1188
|
-
write(dir, { kind: "guard-
|
|
1569
|
+
if (input.tool === "bash" && ASK_GATED.some((p) => p.test(command))) {
|
|
1570
|
+
write(dir, { kind: "guard-allowed", tool: input.tool, command: truncate2(command) });
|
|
1189
1571
|
}
|
|
1190
1572
|
},
|
|
1191
1573
|
dispose: async () => {
|
|
@@ -1207,28 +1589,22 @@ function laneOf(event) {
|
|
|
1207
1589
|
const completed = typeof time.completed === "number" ? time.completed : void 0;
|
|
1208
1590
|
const ms = created !== void 0 && completed !== void 0 ? completed - created : void 0;
|
|
1209
1591
|
if (!id || !agent) return null;
|
|
1592
|
+
const tokens = info.tokens ?? {};
|
|
1593
|
+
const ctx = typeof tokens.input === "number" ? tokens.input + (typeof tokens.cache?.read === "number" ? tokens.cache.read : 0) : void 0;
|
|
1594
|
+
const reasoning = typeof tokens.reasoning === "number" ? tokens.reasoning : void 0;
|
|
1210
1595
|
return {
|
|
1211
1596
|
id,
|
|
1212
1597
|
agent,
|
|
1213
1598
|
model: typeof modelId === "string" ? modelId : void 0,
|
|
1214
1599
|
provider: typeof providerId === "string" ? providerId : void 0,
|
|
1215
|
-
ms: typeof ms === "number" && ms >= 0 ? ms : void 0
|
|
1600
|
+
ms: typeof ms === "number" && ms >= 0 ? ms : void 0,
|
|
1601
|
+
ctx: typeof ctx === "number" ? ctx : void 0,
|
|
1602
|
+
reasoning
|
|
1216
1603
|
};
|
|
1217
1604
|
}
|
|
1218
|
-
function extractRule(output) {
|
|
1219
|
-
const m = output.match(/blocked:\s*([^\n]+)/);
|
|
1220
|
-
return m ? m[1].trim() : "unknown";
|
|
1221
|
-
}
|
|
1222
1605
|
function truncate2(s, n = 300) {
|
|
1223
1606
|
return s.length > n ? `${s.slice(0, n)}...` : s;
|
|
1224
1607
|
}
|
|
1225
|
-
function safeStringify(value) {
|
|
1226
|
-
try {
|
|
1227
|
-
return JSON.stringify(value) ?? String(value);
|
|
1228
|
-
} catch {
|
|
1229
|
-
return String(value);
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
1608
|
|
|
1233
1609
|
// src/effort.js
|
|
1234
1610
|
function planEffort(profileId, opts = {}) {
|
|
@@ -1242,8 +1618,15 @@ function planEffort(profileId, opts = {}) {
|
|
|
1242
1618
|
);
|
|
1243
1619
|
}
|
|
1244
1620
|
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
1621
|
+
if (!want) continue;
|
|
1245
1622
|
const got = roles[role];
|
|
1246
|
-
if (
|
|
1623
|
+
if (got === null) {
|
|
1624
|
+
notes.push(
|
|
1625
|
+
`lane ${role} asked for ${want}; its model does not publish that level, so no variant is set.`
|
|
1626
|
+
);
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
if (want !== got) {
|
|
1247
1630
|
notes.push(`lane ${role} asked for ${want}; its model tops out at ${got}.`);
|
|
1248
1631
|
}
|
|
1249
1632
|
}
|
|
@@ -1280,8 +1663,7 @@ function applyEffort(config, result, opts = {}) {
|
|
|
1280
1663
|
}
|
|
1281
1664
|
cfg.harness = {
|
|
1282
1665
|
...cfg.harness ?? {},
|
|
1283
|
-
profile: result.profileId
|
|
1284
|
-
...opts.fallbackProfileId ? { fallback: opts.fallbackProfileId } : {}
|
|
1666
|
+
profile: result.profileId
|
|
1285
1667
|
};
|
|
1286
1668
|
return config;
|
|
1287
1669
|
}
|
|
@@ -1311,14 +1693,15 @@ var HarnessPlugin = async (ctx, options) => {
|
|
|
1311
1693
|
if (routerConfig) await routerConfig(config);
|
|
1312
1694
|
const id = opts.profile ?? config?.harness?.profile ?? profileIdFromConfig();
|
|
1313
1695
|
if (!id) return;
|
|
1696
|
+
const project = projectRoot(ctx.directory, ctx.worktree);
|
|
1314
1697
|
try {
|
|
1315
1698
|
const sessionModel = typeof config?.harness?.sessionModel === "string" ? config.harness.sessionModel : void 0;
|
|
1316
|
-
applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(
|
|
1699
|
+
applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(project) }), {
|
|
1317
1700
|
sessionModel
|
|
1318
1701
|
});
|
|
1319
|
-
const dir =
|
|
1702
|
+
const dir = join8(project, ".space");
|
|
1320
1703
|
mkdirSync3(dir, { recursive: true });
|
|
1321
|
-
writeFileSync2(
|
|
1704
|
+
writeFileSync2(join8(dir, "active"), `${id}
|
|
1322
1705
|
`);
|
|
1323
1706
|
} catch {
|
|
1324
1707
|
}
|