opencode-harness-kit 0.1.0 → 0.2.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/GLOBAL.md +1 -1
- package/README.md +48 -5
- package/agents/build.md +1 -1
- package/bin/oc-harness.mjs +51 -3
- package/dist/index.js +122 -60
- package/docs/demo/demo.gif +0 -0
- package/docs/demo/demo.mp4 +0 -0
- package/docs/demo/render.py +146 -0
- package/package.json +6 -2
- package/profiles/index.json +18 -0
- package/profiles/muse-split-free.json +64 -0
- package/profiles/schema.json +29 -0
- package/skills/space-finish/SKILL.md +4 -4
- package/src/config.js +40 -8
- package/src/profile.js +156 -5
- package/src/rules.js +3 -18
package/GLOBAL.md
CHANGED
|
@@ -54,7 +54,7 @@ to avoid this.
|
|
|
54
54
|
|
|
55
55
|
## Never
|
|
56
56
|
|
|
57
|
-
- `git push`, publish, or
|
|
57
|
+
- Ask before `git push`, publish, or opening a pull request. Publishing is a human decision.
|
|
58
58
|
- Commit a secret. If you find one, report it; do not move it.
|
|
59
59
|
- Read or write `.env`, `.npmrc`, `.netrc`, `.git-credentials`. The guard
|
|
60
60
|
blocks these and will keep blocking them.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# opencode-harness-kit
|
|
2
2
|
|
|
3
|
-
[](https://github.com/pham-canh/space-harness/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/opencode-harness-kit)
|
|
5
5
|
|
|
6
6
|
A harness kit for [OpenCode](https://opencode.ai) that is **model-agnostic by
|
|
@@ -15,10 +15,15 @@ your side — `npx` only downloads the published kit. Prefer git? The same three
|
|
|
15
15
|
steps from a checkout (see [Install](#install)):
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
git clone https://github.com/
|
|
18
|
+
git clone https://github.com/pham-canh/space-harness.git
|
|
19
19
|
./bin/oc-harness.mjs init
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
Regenerate it any time with `python3 docs/demo/render.py` (needs ffmpeg); the
|
|
25
|
+
frames are drawn from real CLI output, not typed by hand.
|
|
26
|
+
|
|
22
27
|
The three steps `init` runs, spelled out for when you want to know what
|
|
23
28
|
happened (prefix with `npx` or `./bin/oc-harness.mjs` to taste):
|
|
24
29
|
|
|
@@ -114,17 +119,53 @@ on install. It is not hidden, it is labelled.
|
|
|
114
119
|
| profile | context | top effort | retention |
|
|
115
120
|
|---|---|---|---|
|
|
116
121
|
| `space-bunny-free` (default) | 1,048,576 | `max` | zero-retention |
|
|
122
|
+
| `muse-split-free` | 1,048,576 | `max` | **may train** |
|
|
117
123
|
| `x-preview-f-free` | 1,000,000 | `max` | zero-retention |
|
|
118
124
|
| `longcat-2.5-preview-free` | 1,000,000 | — | zero-retention |
|
|
119
125
|
| `deepseek-v4-flash-free` | 200,000 | `max` | zero-retention |
|
|
120
126
|
| `muse-spark-1.3-contributor-free` | 1,048,576 | `xhigh` | **may train** |
|
|
121
127
|
|
|
128
|
+
### Split profiles
|
|
129
|
+
|
|
130
|
+
A profile normally puts one model behind every role. `roleModels` is the way to
|
|
131
|
+
split that, for when the best free model and the highest-output model are not the
|
|
132
|
+
same model:
|
|
133
|
+
|
|
134
|
+
```jsonc
|
|
135
|
+
"model": "space-bunny-free", // base: every role not named below
|
|
136
|
+
"roleModels": {
|
|
137
|
+
"plan": { "model": "muse-spark-1.3-contributor-free", "levels": ["minimal","low","medium","high","xhigh"] },
|
|
138
|
+
"build": { "model": "muse-spark-1.3-contributor-free", "levels": ["minimal","low","medium","high","xhigh"] },
|
|
139
|
+
"locate": { "model": "space-bunny-free", "levels": ["low","medium","high","xhigh","max"] }
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Three things fall out of this that are easy to get wrong, so they are enforced:
|
|
144
|
+
|
|
145
|
+
- **`levels` is required when the model differs.** Effort is resolved against the
|
|
146
|
+
levels of the model the role actually runs on. `plan` asking for `max` on a
|
|
147
|
+
model that stops at `xhigh` lands on `xhigh`, instead of writing a `max` variant
|
|
148
|
+
the model has no meaning for.
|
|
149
|
+
- **Retention is the least private model in the file, not the base one.** A base
|
|
150
|
+
of `zero-retention` with a `may-train` role is a `may-train` profile. The
|
|
151
|
+
generated header prints that, with the model responsible, and so do
|
|
152
|
+
`oc-harness use` and `oc-harness show`.
|
|
153
|
+
- **Each role's output cap is its own.** A split role inherits the limits of the
|
|
154
|
+
model it names, so a `build` on a 131,072-token model cannot spend the base's
|
|
155
|
+
524,288.
|
|
156
|
+
|
|
157
|
+
`muse-split-free` is the shipped example, and it is deliberately not the default:
|
|
158
|
+
three of its five roles run on Muse Spark, a Meta contributor-tier endpoint whose
|
|
159
|
+
provider may train on your prompts. Install it for public or throwaway
|
|
160
|
+
repositories.
|
|
161
|
+
|
|
122
162
|
## Commands
|
|
123
163
|
|
|
124
164
|
```
|
|
125
165
|
oc-harness list profiles with retention and caveats
|
|
126
166
|
oc-harness use <id> compile a profile into ~/.config/opencode/opencode.jsonc
|
|
127
167
|
oc-harness show <id> resolved effort, derived numbers, fallback queue
|
|
168
|
+
oc-harness use <id> --dryrun print what would be written, write nothing
|
|
128
169
|
oc-harness install symlink the plugin, agents, skills and GLOBAL.md
|
|
129
170
|
oc-harness verify check the install against the profile; non-zero on drift
|
|
130
171
|
oc-harness agents report agents the effort tiering does not reach
|
|
@@ -235,8 +276,10 @@ Your numbers stay on your machine; only the script is in the repo.
|
|
|
235
276
|
it was handed. It splits on shell separators, honours quotes so that a `;`
|
|
236
277
|
inside a string is an argument rather than a second command, strips `env`,
|
|
237
278
|
`sudo` and `git -C` before matching, and follows `sh -c "..."` into its payload.
|
|
238
|
-
So `cd repo &&
|
|
239
|
-
caught, while `git log --grep push` and `
|
|
279
|
+
So `cd repo && rm -rf /`, `sudo rm -rf /` and `bash -c "rm -rf /"` are all
|
|
280
|
+
caught, while `git log --grep push` and `echo push` are not. `git push` is
|
|
281
|
+
not guard-blocked; it falls through to the `*` ask permission so opencode
|
|
282
|
+
prompts for human approval before publishing.
|
|
240
283
|
Writes are scoped to the project directory, with the OS temp directory as the
|
|
241
284
|
one exception GLOBAL.md allows.
|
|
242
285
|
|
|
@@ -295,7 +338,7 @@ identically:
|
|
|
295
338
|
|
|
296
339
|
```bash
|
|
297
340
|
pkg install nodejs git
|
|
298
|
-
git clone https://github.com/
|
|
341
|
+
git clone https://github.com/pham-canh/space-harness.git
|
|
299
342
|
./space-harness/bin/oc-harness.mjs init
|
|
300
343
|
```
|
|
301
344
|
|
package/agents/build.md
CHANGED
|
@@ -50,7 +50,7 @@ You implement one task from a plan. One. Not the whole plan.
|
|
|
50
50
|
cannot complete the task, say so before writing a partial one.
|
|
51
51
|
- Never widen the change. A drive-by refactor in the same file is a separate
|
|
52
52
|
task; do not report it as part of this one.
|
|
53
|
-
-
|
|
53
|
+
- Ask before `git push`, publish, or opening a pull request. Committing is
|
|
54
54
|
allowed but only when the task asked for it.
|
|
55
55
|
- Never fabricate a passing result. A failing test reported honestly is worth
|
|
56
56
|
more than a green one you did not get.
|
package/bin/oc-harness.mjs
CHANGED
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
profilePath,
|
|
28
28
|
pruneThreshold,
|
|
29
29
|
reservedTokens,
|
|
30
|
+
effectiveRetention,
|
|
31
|
+
roleSplitModels,
|
|
30
32
|
resolveDefaultVariant,
|
|
31
33
|
resolveRoles,
|
|
32
34
|
ProfileError,
|
|
@@ -146,7 +148,20 @@ function cmdShow(id) {
|
|
|
146
148
|
row("attachment", p.supports?.attachment ? "yes" : stamp("no"))
|
|
147
149
|
row("reserved", reservedTokens(p).toLocaleString("en-US"))
|
|
148
150
|
row("prune at", pruneThreshold(p).toLocaleString("en-US"))
|
|
149
|
-
|
|
151
|
+
// Effective, not the base model's: a split profile's base can be
|
|
152
|
+
// zero-retention while the roles that matter are not.
|
|
153
|
+
const eff = effectiveRetention(p)
|
|
154
|
+
row("retention", eff.retention === "zero-retention" ? green(eff.retention) : yellow(eff.retention))
|
|
155
|
+
if (eff.retention !== p.retention) {
|
|
156
|
+
console.log(` ${"".padEnd(20)} ${stamp(`base ${p.model} is ${p.retention}, but ${eff.worst} makes the whole profile ${eff.retention}`)}`)
|
|
157
|
+
}
|
|
158
|
+
if (roleSplitModels(p).length) {
|
|
159
|
+
console.log(`\n ${bold("role models")}`)
|
|
160
|
+
for (const s of roleSplitModels(p)) {
|
|
161
|
+
const cap = s.limits ? ` out ${s.limits.output.toLocaleString("en-US")}` : ""
|
|
162
|
+
console.log(` ${s.role.padEnd(20)} ${s.model}${cap}`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
150
165
|
|
|
151
166
|
console.log(`\n ${bold("role effort")}`)
|
|
152
167
|
for (const [role, level] of Object.entries(plan.roles)) {
|
|
@@ -563,7 +578,14 @@ function configDrift(p, actual) {
|
|
|
563
578
|
out.push(`agent.${role} missing`)
|
|
564
579
|
continue
|
|
565
580
|
}
|
|
566
|
-
|
|
581
|
+
// A role's expected model is the one roleModels declares for it, falling
|
|
582
|
+
// back to the base. Comparing every agent against `want` would report
|
|
583
|
+
// drift on every split role forever, which trains the user to ignore the
|
|
584
|
+
// one command that exists to catch a moved `build`.
|
|
585
|
+
const wantRole = `${p.provider}/${p.roleModels?.[role]?.model ?? p.model}`
|
|
586
|
+
if (agent.model !== wantRole) {
|
|
587
|
+
out.push(`agent.${role}.model ${agent.model} -> ${wantRole}`)
|
|
588
|
+
}
|
|
567
589
|
if (agent.variant !== (level ?? undefined)) {
|
|
568
590
|
out.push(`agent.${role}.variant ${agent.variant ?? "(none)"} -> ${level ?? "(none)"}`)
|
|
569
591
|
}
|
|
@@ -620,6 +642,15 @@ async function cmdUse(id) {
|
|
|
620
642
|
return
|
|
621
643
|
}
|
|
622
644
|
|
|
645
|
+
// A flag that is accepted and then ignored is worse than one that errors:
|
|
646
|
+
// `--dryrun` used to write the real config anyway.
|
|
647
|
+
if (has("--dryrun")) {
|
|
648
|
+
console.log(`\n ${stamp("dry run, nothing written")}`)
|
|
649
|
+
console.log(` ${stamp(`would write ${CONFIG_FILE}`)}`)
|
|
650
|
+
summary(p)
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
|
|
623
654
|
mkdirSync(dirname(CONFIG_FILE), { recursive: true })
|
|
624
655
|
if (existsSync(CONFIG_FILE) && !has("--yes")) {
|
|
625
656
|
// The config is the user's file once written. A backup is made either
|
|
@@ -632,11 +663,28 @@ async function cmdUse(id) {
|
|
|
632
663
|
}
|
|
633
664
|
writeFileSync(CONFIG_FILE, out)
|
|
634
665
|
console.log(`\n ${green("✓")} wrote ${CONFIG_FILE}`)
|
|
666
|
+
summary(p)
|
|
667
|
+
console.log(`\n ${stamp("verify with: opencode debug config")}\n`)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** What the profile puts behind the user, including a split and real retention. */
|
|
671
|
+
function summary(p) {
|
|
672
|
+
const eff = effectiveRetention(p)
|
|
673
|
+
const splits = roleSplitModels(p)
|
|
635
674
|
console.log(` ${stamp(`model ${p.provider}/${p.model}`)}`)
|
|
636
675
|
console.log(` ${stamp(`effort ${resolveDefaultVariant(p) ?? "(not tunable)"}`)}`)
|
|
637
676
|
console.log(` ${stamp(`roles ${Object.entries(resolveRoles(p)).map(([k, v]) => `${k}=${v ?? "-"}`).join(" ")}`)}`)
|
|
677
|
+
if (splits.length) {
|
|
678
|
+
// Without this the split is invisible until someone opens the file, which
|
|
679
|
+
// is too late to be told which roles send prompts to a provider that trains.
|
|
680
|
+
console.log(` ${stamp(`split ${splits.map((s) => `${s.role}=${s.model}`).join(" ")}`)}`)
|
|
681
|
+
console.log(` ${stamp(` ${splits.map((s) => s.limits ? `${s.role} out ${s.limits.output.toLocaleString("en-US")}` : null).filter(Boolean).join(" ")}`)}`)
|
|
682
|
+
}
|
|
683
|
+
console.log(` ${stamp(`retention ${eff.retention}${eff.retention === "may-train" ? " <-- prompts may train a model" : ""}`)}`)
|
|
684
|
+
if (splits.length && eff.retention !== p.retention) {
|
|
685
|
+
console.log(` ${stamp(` base ${p.model} is ${p.retention}, but ${eff.worst} makes the whole profile ${eff.retention}`)}`)
|
|
686
|
+
}
|
|
638
687
|
console.log(` ${stamp(`reserved ${reservedTokens(p).toLocaleString("en-US")} tokens`)}`)
|
|
639
|
-
console.log(`\n ${stamp("verify with: opencode debug config")}\n`)
|
|
640
688
|
}
|
|
641
689
|
|
|
642
690
|
/** opencode.jsonc is JSONC, so strip comments before parsing. */
|
package/dist/index.js
CHANGED
|
@@ -140,26 +140,6 @@ function tokenize(segment) {
|
|
|
140
140
|
var ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
141
141
|
var GIT_WRAPPERS = /* @__PURE__ */ new Set(["env", "sudo", "doas", "nohup", "time", "command", "exec", "xargs"]);
|
|
142
142
|
var WRAPPER_VALUE_FLAGS = /* @__PURE__ */ new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"]);
|
|
143
|
-
var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
144
|
-
"-C",
|
|
145
|
-
"-c",
|
|
146
|
-
"--git-dir",
|
|
147
|
-
"--work-tree",
|
|
148
|
-
"--namespace",
|
|
149
|
-
"--super-prefix",
|
|
150
|
-
"--exec-path",
|
|
151
|
-
"--config-env"
|
|
152
|
-
]);
|
|
153
|
-
var GIT_GLOBAL_FLAGS = /* @__PURE__ */ new Set([
|
|
154
|
-
"--bare",
|
|
155
|
-
"--no-pager",
|
|
156
|
-
"--paginate",
|
|
157
|
-
"--no-replace-objects",
|
|
158
|
-
"--no-optional-locks",
|
|
159
|
-
"--literal-pathspecs",
|
|
160
|
-
"--glob-pathspecs",
|
|
161
|
-
"--icase-pathspecs"
|
|
162
|
-
]);
|
|
163
143
|
function skipPrefixes(tokens) {
|
|
164
144
|
let i = 0;
|
|
165
145
|
for (; ; ) {
|
|
@@ -190,26 +170,6 @@ function stripPrefixes(segment) {
|
|
|
190
170
|
if (slash !== -1) rest[0] = rest[0].slice(slash + 1);
|
|
191
171
|
return rest.join(" ");
|
|
192
172
|
}
|
|
193
|
-
function gitSubcommand(segment) {
|
|
194
|
-
const tokens = tokenize(segment);
|
|
195
|
-
let i = skipPrefixes(tokens);
|
|
196
|
-
if (i >= tokens.length) return null;
|
|
197
|
-
if (!/(^|[\\/])git(\.exe)?$/i.test(tokens[i])) return null;
|
|
198
|
-
i++;
|
|
199
|
-
while (i < tokens.length) {
|
|
200
|
-
const flag = tokens[i];
|
|
201
|
-
if (GIT_VALUE_FLAGS.has(flag)) {
|
|
202
|
-
i += 2;
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
if (/^--[A-Za-z][\w-]*=/.test(flag) || GIT_GLOBAL_FLAGS.has(flag)) {
|
|
206
|
-
i++;
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
break;
|
|
210
|
-
}
|
|
211
|
-
return tokens[i] ?? null;
|
|
212
|
-
}
|
|
213
173
|
var SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
|
|
214
174
|
var MAX_SHELL_DEPTH = 2;
|
|
215
175
|
function shellPayload(segment) {
|
|
@@ -240,21 +200,9 @@ var BASH_RULES = [
|
|
|
240
200
|
test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
|
|
241
201
|
why: "downloads and executes a script in one step"
|
|
242
202
|
},
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
// A dry run publishes nothing, so it is not the thing being gated.
|
|
247
|
-
check: (seg) => gitSubcommand(seg) === "push" && tokenize(seg).some((w) => w === "--force" || w === "-f" || w.startsWith("--force")),
|
|
248
|
-
why: "overwrites published history"
|
|
249
|
-
},
|
|
250
|
-
{
|
|
251
|
-
name: "git push",
|
|
252
|
-
scope: "segment",
|
|
253
|
-
// Flags are matched as whole words: a `-f` in `my-feature-f` is a branch
|
|
254
|
-
// name, not a force flag.
|
|
255
|
-
check: (seg) => gitSubcommand(seg) === "push" && !tokenize(seg).some((w) => w === "--dry-run" || w === "-n"),
|
|
256
|
-
why: "publishing is a human decision"
|
|
257
|
-
},
|
|
203
|
+
// `git push` is ask-gated via permissions (`*` ask in the generated
|
|
204
|
+
// config), not guard-blocked. Publishing needs human approval, but the
|
|
205
|
+
// guard lets it through so opencode can prompt first.
|
|
258
206
|
{
|
|
259
207
|
name: "recursive delete",
|
|
260
208
|
scope: "segment",
|
|
@@ -453,6 +401,31 @@ function validate(p, path = "<memory>") {
|
|
|
453
401
|
`${where}: effort.reserveShare must be between 0 and 0.5`
|
|
454
402
|
);
|
|
455
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`);
|
|
417
|
+
}
|
|
418
|
+
if (rm.levels !== void 0 && !Array.isArray(rm.levels)) {
|
|
419
|
+
throw new ProfileError(
|
|
420
|
+
`${where}: roleModels.${role}.levels must be an array`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
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
|
+
}
|
|
456
429
|
}
|
|
457
430
|
var CANON = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
458
431
|
function resolveEffort(levels, requested) {
|
|
@@ -476,27 +449,116 @@ function resolveEffort(levels, requested) {
|
|
|
476
449
|
}
|
|
477
450
|
return best ?? levels[levels.length - 1] ?? null;
|
|
478
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
|
+
}
|
|
479
462
|
function resolveRoles(p) {
|
|
480
|
-
const levels = p.effort?.levels ?? [];
|
|
481
463
|
const out = (
|
|
482
464
|
/** @type {Record<string, string | null>} */
|
|
483
465
|
{}
|
|
484
466
|
);
|
|
485
467
|
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
486
|
-
out[role] = resolveEffort(
|
|
468
|
+
out[role] = resolveEffort(levelsForRole(p, role), want);
|
|
487
469
|
}
|
|
488
470
|
return out;
|
|
489
471
|
}
|
|
490
472
|
function resolveDefaultVariant(p) {
|
|
491
473
|
return resolveEffort(p.effort?.levels ?? [], p.effort?.default ?? null);
|
|
492
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
|
+
}
|
|
493
484
|
function reservedTokens(p) {
|
|
494
|
-
const input = p.limits.input ?? p.limits.context;
|
|
495
485
|
const share = p.effort?.reserveShare ?? 0.12;
|
|
496
|
-
return Math.max(8e3, Math.round(
|
|
486
|
+
return Math.max(8e3, Math.round(inputCap(p) * share));
|
|
497
487
|
}
|
|
498
488
|
function pruneThreshold(p) {
|
|
499
|
-
return (p
|
|
489
|
+
return inputCap(p) - reservedTokens(p);
|
|
490
|
+
}
|
|
491
|
+
function loadRetention() {
|
|
492
|
+
const file = join(ROOT, "retention.yml");
|
|
493
|
+
if (!existsSync(file)) return {};
|
|
494
|
+
const out = (
|
|
495
|
+
/** @type {Record<string, any>} */
|
|
496
|
+
{}
|
|
497
|
+
);
|
|
498
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
499
|
+
let current = null;
|
|
500
|
+
let i = 0;
|
|
501
|
+
while (i < lines.length) {
|
|
502
|
+
const raw = lines[i];
|
|
503
|
+
const line = raw.trim();
|
|
504
|
+
const indent = raw.length - raw.trimStart().length;
|
|
505
|
+
if (!line || line.startsWith("#")) {
|
|
506
|
+
i++;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (indent === 2 && line.endsWith(":")) {
|
|
510
|
+
const key = line.slice(0, -1).trim();
|
|
511
|
+
current = key.includes("/") ? key : `opencode/${key}`;
|
|
512
|
+
out[current] = { retention: "unknown" };
|
|
513
|
+
i++;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (!current) {
|
|
517
|
+
i++;
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const field = line.match(/^(retention|verified|source|note):\s*(.*)$/);
|
|
521
|
+
if (field) {
|
|
522
|
+
const name = field[1];
|
|
523
|
+
const rest = field[2].trim();
|
|
524
|
+
if (/^[>|][-+]?$/.test(rest)) {
|
|
525
|
+
const parts = [];
|
|
526
|
+
let j = i + 1;
|
|
527
|
+
for (; j < lines.length; j++) {
|
|
528
|
+
const l = lines[j];
|
|
529
|
+
if (!l.trim()) {
|
|
530
|
+
parts.push("");
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (l.length - l.trimStart().length <= indent) break;
|
|
534
|
+
parts.push(l.trim());
|
|
535
|
+
}
|
|
536
|
+
out[current][name] = parts.join(" ").replace(/\s+/g, " ").trim();
|
|
537
|
+
i = j;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
out[current][name] = rest.replace(/^["']|["']$/g, "");
|
|
541
|
+
i++;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
i++;
|
|
545
|
+
}
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
function retentionFor(id) {
|
|
549
|
+
const table = loadRetention();
|
|
550
|
+
return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`];
|
|
551
|
+
}
|
|
552
|
+
function roleSplitModels(p) {
|
|
553
|
+
return Object.entries(p.roleModels ?? {}).map(([role, rm]) => {
|
|
554
|
+
let limits = null;
|
|
555
|
+
try {
|
|
556
|
+
limits = loadProfile(rm.model)?.limits ?? null;
|
|
557
|
+
} catch {
|
|
558
|
+
limits = null;
|
|
559
|
+
}
|
|
560
|
+
return { role, model: rm.model, levels: rm.levels ?? [], limits };
|
|
561
|
+
});
|
|
500
562
|
}
|
|
501
563
|
function configFilePath() {
|
|
502
564
|
const dir = process.env.OPENCODE_CONFIG_DIR ?? join(process.env.HOME ?? "", ".config", "opencode");
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render the space-harness demo video from REAL CLI output.
|
|
3
|
+
|
|
4
|
+
Nothing here is typed by hand: the script runs `init` and `verify` against a
|
|
5
|
+
throwaway config dir, captures what the CLI actually prints, and draws that.
|
|
6
|
+
The only cosmetic edit is replacing the random tmp path with
|
|
7
|
+
~/.config/opencode so the frames stay readable.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
16
|
+
|
|
17
|
+
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # docs/demo/render.py -> repo root
|
|
18
|
+
ROOT = REPO
|
|
19
|
+
BIN = ["node", os.path.join(ROOT, "bin", "oc-harness.mjs")]
|
|
20
|
+
W, H = 1280, 720
|
|
21
|
+
FPS = 15
|
|
22
|
+
FONT = ImageFont.truetype("/System/Library/Fonts/Menlo.ttc", 22)
|
|
23
|
+
LINE_H = 34
|
|
24
|
+
TOP = 40
|
|
25
|
+
LEFT = 48
|
|
26
|
+
BG = (13, 17, 23)
|
|
27
|
+
WHITE = (230, 237, 243)
|
|
28
|
+
GREEN = (63, 185, 80)
|
|
29
|
+
DIM = (139, 148, 158)
|
|
30
|
+
YELLOW = (210, 153, 34)
|
|
31
|
+
RED = (248, 81, 73)
|
|
32
|
+
|
|
33
|
+
ANSI = re.compile(r"\x1b\[(\d+)m")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_clip(args, cfg):
|
|
37
|
+
env = dict(os.environ, OPENCODE_CONFIG_DIR=cfg)
|
|
38
|
+
p = subprocess.run(BIN + args, capture_output=True, text=True, env=env, cwd=ROOT)
|
|
39
|
+
assert p.returncode == 0, p.stderr[-2000:]
|
|
40
|
+
out = (p.stdout + p.stderr).replace(cfg, "~/.config/opencode")
|
|
41
|
+
return [ln.rstrip() for ln in out.splitlines()]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def spans(line):
|
|
45
|
+
"""Split an ANSI-coloured line into (text, colour) runs."""
|
|
46
|
+
runs, cur, buf = [], WHITE, ""
|
|
47
|
+
pos = 0
|
|
48
|
+
for m in ANSI.finditer(line):
|
|
49
|
+
if m.start() > pos:
|
|
50
|
+
buf += line[pos : m.start()]
|
|
51
|
+
code = m.group(1)
|
|
52
|
+
if buf:
|
|
53
|
+
runs.append((buf, cur))
|
|
54
|
+
buf = ""
|
|
55
|
+
cur = {"0": WHITE, "1": WHITE, "2": DIM, "31": RED, "32": GREEN, "33": YELLOW}.get(code, WHITE)
|
|
56
|
+
pos = m.end()
|
|
57
|
+
if pos < len(line):
|
|
58
|
+
buf += line[pos:]
|
|
59
|
+
if buf:
|
|
60
|
+
runs.append((buf, cur))
|
|
61
|
+
return runs or [("", WHITE)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def draw_terminal(draw, lines, prompt="$ "):
|
|
65
|
+
draw.rectangle([0, 0, W, H], fill=BG)
|
|
66
|
+
maxl = (H - TOP) // LINE_H
|
|
67
|
+
if len(lines) > maxl:
|
|
68
|
+
head = [lines[0]] if lines[0][0] == "cmd" else []
|
|
69
|
+
lines = head + [ln for ln in lines if ln[0] != "cmd"][-(maxl - len(head)) :]
|
|
70
|
+
y = TOP
|
|
71
|
+
for kind, payload in lines:
|
|
72
|
+
if y > H - LINE_H:
|
|
73
|
+
break
|
|
74
|
+
if kind == "cmd":
|
|
75
|
+
draw.text((LEFT, y), prompt, font=FONT, fill=GREEN)
|
|
76
|
+
x = LEFT + draw.textlength(prompt, font=FONT)
|
|
77
|
+
for text, colour in spans(payload):
|
|
78
|
+
draw.text((x, y), text, font=FONT, fill=colour)
|
|
79
|
+
x += draw.textlength(text, font=FONT)
|
|
80
|
+
elif kind == "card":
|
|
81
|
+
text, colour = payload
|
|
82
|
+
draw.text((LEFT, y), text, font=FONT, fill=colour)
|
|
83
|
+
else:
|
|
84
|
+
x = LEFT
|
|
85
|
+
for text, colour in spans(payload):
|
|
86
|
+
draw.text((x, y), text, font=FONT, fill=colour)
|
|
87
|
+
x += draw.textlength(text, font=FONT)
|
|
88
|
+
y += LINE_H
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def frames_for_command(cmd, output):
|
|
92
|
+
frames = []
|
|
93
|
+
typed = ""
|
|
94
|
+
for ch in cmd:
|
|
95
|
+
typed += ch
|
|
96
|
+
frames.append([("cmd", typed)])
|
|
97
|
+
for i in range(0, len(output), 2):
|
|
98
|
+
frames.append([("cmd", cmd)] + [("out", ln) for ln in output[: i + 2]])
|
|
99
|
+
full = [("cmd", cmd)] + [("out", ln) for ln in output]
|
|
100
|
+
frames.extend([full] * (2 * FPS))
|
|
101
|
+
return frames
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def card(lines, hold=2):
|
|
105
|
+
return [[("card", ln) for ln in lines]] * (hold * FPS)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(out_dir):
|
|
109
|
+
cfg = tempfile.mkdtemp(prefix="demo-")
|
|
110
|
+
init_out = run_clip(["init", "--profile", "space-bunny-free", "--yes"], cfg)
|
|
111
|
+
verify_out = run_clip(["verify"], cfg)
|
|
112
|
+
seq = []
|
|
113
|
+
seq += card([(("space-harness", WHITE)), (("one command from zero to working", DIM))], hold=2)
|
|
114
|
+
seq += frames_for_command("npx -y opencode-harness-kit init", [l for l in init_out if l.strip()])
|
|
115
|
+
seq += frames_for_command("oc-harness verify", [l for l in verify_out if l.strip()])
|
|
116
|
+
seq += card(
|
|
117
|
+
[
|
|
118
|
+
(("done. restart opencode, then ask it something", GREEN)),
|
|
119
|
+
(("github.com/crytobot459/space-harness", WHITE)),
|
|
120
|
+
(("free models. verified setup.", DIM)),
|
|
121
|
+
],
|
|
122
|
+
hold=3,
|
|
123
|
+
)
|
|
124
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
125
|
+
frame_dir = tempfile.mkdtemp(prefix="frames-")
|
|
126
|
+
for i, lines in enumerate(seq):
|
|
127
|
+
flat = []
|
|
128
|
+
for kind, payload in lines:
|
|
129
|
+
flat.append((kind, payload))
|
|
130
|
+
img = Image.new("RGB", (W, H), BG)
|
|
131
|
+
draw_terminal(ImageDraw.Draw(img), flat)
|
|
132
|
+
img.save(os.path.join(frame_dir, f"f{i:04d}.png"))
|
|
133
|
+
mp4 = os.path.join(out_dir, "demo.mp4")
|
|
134
|
+
subprocess.run(
|
|
135
|
+
["ffmpeg", "-y", "-v", "error", "-framerate", str(FPS), "-i", os.path.join(frame_dir, "f%04d.png"),
|
|
136
|
+
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23", mp4], check=True)
|
|
137
|
+
gif = os.path.join(out_dir, "demo.gif")
|
|
138
|
+
subprocess.run(
|
|
139
|
+
["ffmpeg", "-y", "-v", "error", "-framerate", "10", "-i", os.path.join(frame_dir, "f%04d.png"),
|
|
140
|
+
"-vf", "scale=640:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=64[p];[s1][p]paletteuse",
|
|
141
|
+
gif], check=True)
|
|
142
|
+
print(f"wrote {mp4} ({os.path.getsize(mp4)//1024}KB) and {gif} ({os.path.getsize(gif)//1024}KB)")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main(sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "docs", "demo"))
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-harness-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Model-agnostic harness kit for OpenCode: capability profiles, role-based reasoning effort, context pruning, and measured baselines.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/pham-canh/space-harness#readme",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/
|
|
9
|
+
"url": "git+https://github.com/pham-canh/space-harness.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/pham-canh/space-harness/issues"
|
|
9
13
|
},
|
|
10
14
|
"type": "module",
|
|
11
15
|
"main": "./dist/index.js",
|
package/profiles/index.json
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
"$comment": "Profiles verified against live provider metadata. `retention` must come from retention.yml; a model missing there cannot enter this list.",
|
|
3
3
|
"default": "space-bunny-free",
|
|
4
4
|
"profiles": [
|
|
5
|
+
{
|
|
6
|
+
"id": "muse-split-free",
|
|
7
|
+
"file": "profiles/muse-split-free.json",
|
|
8
|
+
"title": "Muse Spark 1.3 (heavy) + Space Bunny Free (bulk output)",
|
|
9
|
+
"retention": "may-train",
|
|
10
|
+
"free": true,
|
|
11
|
+
"topEffort": "max",
|
|
12
|
+
"context": 1048576,
|
|
13
|
+
"default": false,
|
|
14
|
+
"split": {
|
|
15
|
+
"plan": "muse-spark-1.3-contributor-free",
|
|
16
|
+
"build": "muse-spark-1.3-contributor-free",
|
|
17
|
+
"reviewer": "muse-spark-1.3-contributor-free",
|
|
18
|
+
"locate": "space-bunny-free",
|
|
19
|
+
"verify": "space-bunny-free"
|
|
20
|
+
},
|
|
21
|
+
"note": "Retention here is the effective one: three roles run on Muse Spark, so the file is may-train even though the base model is zero-retention."
|
|
22
|
+
},
|
|
5
23
|
{
|
|
6
24
|
"id": "space-bunny-free",
|
|
7
25
|
"file": "profiles/space-bunny-free.json",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Split profile. The heavy reasoning roles run on Muse Spark 1.3 because it is the strongest free model on Zen by a wide margin (Terminal-Bench 2.1 88.8% vs Nemotron 3 Ultra's 56.4%, DeepSWE v1.1 75.4%). The bulk-output roles stay on Space Bunny because its 524,288-token output cap is 4x Muse Spark's 131,072, and scaffolding or migrating many files is where that ceiling is actually felt. `retention` below describes the BASE model only; the config header prints the effective retention, which is may-train because three roles run on Muse Spark.",
|
|
3
|
+
"id": "muse-split-free",
|
|
4
|
+
"title": "Muse Spark 1.3 (heavy) + Space Bunny Free (bulk output)",
|
|
5
|
+
"provider": "opencode",
|
|
6
|
+
"model": "space-bunny-free",
|
|
7
|
+
"family": null,
|
|
8
|
+
"verified": "2026-10-07",
|
|
9
|
+
"retention": "zero-retention",
|
|
10
|
+
"notes": "Opt-in split, not a default. Muse Spark is a Meta contributor-tier free endpoint: its provider may train on your prompts and completions. Three of the five roles are therefore may-train even though the base model is zero-retention. Install it only for public or throwaway repositories. Effort requests below are resolved against each role's own model, so plan and reviewer ask for `max` and land on `xhigh`, which is the top Muse Spark offers.",
|
|
11
|
+
"limits": {
|
|
12
|
+
"context": 1048576,
|
|
13
|
+
"input": 524288,
|
|
14
|
+
"output": 524288
|
|
15
|
+
},
|
|
16
|
+
"supports": {
|
|
17
|
+
"reasoning": true,
|
|
18
|
+
"temperature": false,
|
|
19
|
+
"toolCall": true,
|
|
20
|
+
"structuredOutput": false,
|
|
21
|
+
"attachment": true
|
|
22
|
+
},
|
|
23
|
+
"effort": {
|
|
24
|
+
"levels": ["low", "medium", "high", "xhigh", "max"],
|
|
25
|
+
"default": "max",
|
|
26
|
+
"reserveShare": 0.12
|
|
27
|
+
},
|
|
28
|
+
"roles": {
|
|
29
|
+
"plan": "max",
|
|
30
|
+
"build": "high",
|
|
31
|
+
"reviewer": "max",
|
|
32
|
+
"locate": "low",
|
|
33
|
+
"verify": "low"
|
|
34
|
+
},
|
|
35
|
+
"roleModels": {
|
|
36
|
+
"plan": {
|
|
37
|
+
"model": "muse-spark-1.3-contributor-free",
|
|
38
|
+
"levels": ["minimal", "low", "medium", "high", "xhigh"]
|
|
39
|
+
},
|
|
40
|
+
"build": {
|
|
41
|
+
"model": "muse-spark-1.3-contributor-free",
|
|
42
|
+
"levels": ["minimal", "low", "medium", "high", "xhigh"]
|
|
43
|
+
},
|
|
44
|
+
"reviewer": {
|
|
45
|
+
"model": "muse-spark-1.3-contributor-free",
|
|
46
|
+
"levels": ["minimal", "low", "medium", "high", "xhigh"]
|
|
47
|
+
},
|
|
48
|
+
"locate": {
|
|
49
|
+
"model": "space-bunny-free",
|
|
50
|
+
"levels": ["low", "medium", "high", "xhigh", "max"]
|
|
51
|
+
},
|
|
52
|
+
"verify": {
|
|
53
|
+
"model": "space-bunny-free",
|
|
54
|
+
"levels": ["low", "medium", "high", "xhigh", "max"]
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"prune": {
|
|
58
|
+
"enabled": true,
|
|
59
|
+
"keepRecentToolCalls": 6,
|
|
60
|
+
"keepRecentTurns": 3,
|
|
61
|
+
"keepUserMessages": true
|
|
62
|
+
},
|
|
63
|
+
"fallback": ["x-preview-f-free", "longcat-2.5-preview-free", "deepseek-v4-flash-free"]
|
|
64
|
+
}
|
package/profiles/schema.json
CHANGED
|
@@ -90,6 +90,18 @@
|
|
|
90
90
|
"verify": { "$ref": "#/definitions/effortRequest" }
|
|
91
91
|
}
|
|
92
92
|
},
|
|
93
|
+
"roleModels": {
|
|
94
|
+
"type": "object",
|
|
95
|
+
"description": "Model per agent role, for a profile that splits the heavy reasoning roles onto a stronger model while the bulk-output roles stay on a high-output one. `model` remains the base: roles not named here, the top-level `model`, and `small_model` all resolve to it.",
|
|
96
|
+
"additionalProperties": false,
|
|
97
|
+
"properties": {
|
|
98
|
+
"plan": { "$ref": "#/definitions/roleModel" },
|
|
99
|
+
"build": { "$ref": "#/definitions/roleModel" },
|
|
100
|
+
"reviewer": { "$ref": "#/definitions/roleModel" },
|
|
101
|
+
"locate": { "$ref": "#/definitions/roleModel" },
|
|
102
|
+
"verify": { "$ref": "#/definitions/roleModel" }
|
|
103
|
+
}
|
|
104
|
+
},
|
|
93
105
|
"prune": {
|
|
94
106
|
"type": "object",
|
|
95
107
|
"additionalProperties": false,
|
|
@@ -122,6 +134,23 @@
|
|
|
122
134
|
}
|
|
123
135
|
},
|
|
124
136
|
"definitions": {
|
|
137
|
+
"roleModel": {
|
|
138
|
+
"type": "object",
|
|
139
|
+
"description": "A model for one role, plus the effort levels that model actually offers. Required when the model differs from the base, because a level the model does not have must never be written onto the agent.",
|
|
140
|
+
"additionalProperties": false,
|
|
141
|
+
"required": ["model"],
|
|
142
|
+
"properties": {
|
|
143
|
+
"model": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"description": "Model id without the provider prefix. Must resolve to a profile in this directory, so its limits and retention are known."
|
|
146
|
+
},
|
|
147
|
+
"levels": {
|
|
148
|
+
"type": "array",
|
|
149
|
+
"items": { "type": "string" },
|
|
150
|
+
"description": "Effort levels this role's model offers. Omitted means the role emits no variant at all, which is the right reading for a model with no tunable effort."
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
125
154
|
"effortRequest": {
|
|
126
155
|
"oneOf": [
|
|
127
156
|
{ "type": "string", "description": "Desired level, resolved to the nearest available one." },
|
|
@@ -47,10 +47,10 @@ over an unreviewed or blocked change is the failure this skill exists to stop.
|
|
|
47
47
|
|
|
48
48
|
## The publish boundary
|
|
49
49
|
|
|
50
|
-
Publishing
|
|
51
|
-
|
|
52
|
-
seems to imply it.
|
|
53
|
-
is yours to hold, not only the hook's.
|
|
50
|
+
Publishing needs human approval. Ask before `git push`, opening a pull request,
|
|
51
|
+
publishing a package, or changing repository visibility, even if the task
|
|
52
|
+
seems to imply it. Permissions default to ask so opencode prompts first, but
|
|
53
|
+
the boundary is yours to hold, not only the hook's.
|
|
54
54
|
|
|
55
55
|
Leave the tree in a state a human can review: on a branch, with the change
|
|
56
56
|
visible in `git diff`.
|
package/src/config.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// defaults, experimental flags - is fixed here. Everything model-specific is
|
|
5
5
|
// derived from the profile, so switching models changes only the profile file.
|
|
6
6
|
|
|
7
|
-
import { pruneThreshold, reservedTokens, resolveRoles, resolveDefaultVariant } from "./profile.js"
|
|
7
|
+
import { pruneThreshold, reservedTokens, inputCap, effectiveRetention, resolveRoleModel, resolveRoles, resolveDefaultVariant, roleSplitModels } from "./profile.js"
|
|
8
8
|
|
|
9
9
|
import { join } from "node:path"
|
|
10
10
|
import { homedir } from "node:os"
|
|
@@ -87,8 +87,8 @@ export const BASH_ALLOWLIST = [
|
|
|
87
87
|
|
|
88
88
|
/** Hard blocks. The guard plugin enforces the same set in code. */
|
|
89
89
|
export const BASH_DENYLIST = [
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
// `git push` is intentionally absent: it falls through to the `*` ask rule
|
|
91
|
+
// so opencode prompts for human approval before publishing.
|
|
92
92
|
"rm -rf *",
|
|
93
93
|
"rm -fr *",
|
|
94
94
|
"curl * | sh",
|
|
@@ -143,11 +143,16 @@ export function generateConfig(p, opts = {}) {
|
|
|
143
143
|
for (const [role, level] of Object.entries(roles)) {
|
|
144
144
|
if (level === undefined) continue
|
|
145
145
|
const existing = { ...(agent[role] ?? {}) }
|
|
146
|
+
const rm = resolveRoleModel(p, role)
|
|
146
147
|
// The model is pinned, never inherited. Preserving a model already written
|
|
147
148
|
// on the agent is how `build` ended up on a `may-train` model while the rest
|
|
148
149
|
// of the file claimed the profile's zero-retention one: the compiler was
|
|
149
150
|
// asked to re-run over a hand-edited config and kept the hand-edit.
|
|
150
|
-
|
|
151
|
+
//
|
|
152
|
+
// `roleModels` is the one sanctioned way a role differs, because it is
|
|
153
|
+
// declared in the profile next to the model it points at and validated
|
|
154
|
+
// against retention.yml. Still an assignment, never a preserved value.
|
|
155
|
+
existing.model = rm ? `${p.provider}/${rm.model}` : model
|
|
151
156
|
if (level === null) delete existing.variant
|
|
152
157
|
else existing.variant = level
|
|
153
158
|
if (AGENT_PERMISSIONS[role]) {
|
|
@@ -280,22 +285,49 @@ function render(cfg, p, opts) {
|
|
|
280
285
|
const levels = p.effort?.levels ?? []
|
|
281
286
|
const roles = resolveRoles(p)
|
|
282
287
|
|
|
288
|
+
const effective = effectiveRetention(p)
|
|
289
|
+
// The input cap that actually governs context management, which on a split
|
|
290
|
+
// profile is the widest role model rather than the base. The header must name
|
|
291
|
+
// it, or `reserved` and `prune` read as arithmetic against a number that is
|
|
292
|
+
// not the one they used.
|
|
293
|
+
const effectiveInput = inputCap(p)
|
|
294
|
+
const splits = Object.keys(p.roleModels ?? {})
|
|
283
295
|
const head = [
|
|
284
296
|
`${c}Generated by opencode-harness-kit. Edit profiles/${p.id}.json instead`,
|
|
285
297
|
`${c}of this file, then re-run: oc-harness use ${p.id}`,
|
|
286
298
|
`${c}`,
|
|
287
299
|
`${c}profile ${p.id}`,
|
|
288
300
|
`${c}model ${p.provider}/${p.model}`,
|
|
301
|
+
...(splits.length
|
|
302
|
+
? [
|
|
303
|
+
`${c}split ${splits.map((r) => `${r}=${p.roleModels?.[r]?.model}`).join(" ")}`,
|
|
304
|
+
`${c} (roles not listed above run on the base model above)`,
|
|
305
|
+
]
|
|
306
|
+
: []),
|
|
289
307
|
`${c}context ${p.limits.context.toLocaleString("en-US")} tokens`,
|
|
290
|
-
`${c}input cap ${input.toLocaleString("en-US")} tokens`,
|
|
308
|
+
`${c}input cap ${input.toLocaleString("en-US")} tokens${effectiveInput !== input ? ` (base model; ${effectiveInput.toLocaleString("en-US")} governs split roles)` : ""}`,
|
|
291
309
|
`${c}output cap ${p.limits.output.toLocaleString("en-US")} tokens`,
|
|
292
310
|
`${c}reasoning ${levels.length ? levels.join(" < ") : "(not tunable)"}`,
|
|
293
|
-
|
|
311
|
+
// The header must name the retention that actually applies, which with a
|
|
312
|
+
// split is the least private model in the file, not the base one. Printing
|
|
313
|
+
// `p.retention` here is the same class of bug as the inherited `build`
|
|
314
|
+
// model: the file claims a privacy property the agents do not have.
|
|
315
|
+
`${c}retention ${effective.retention}${effective.retention === "may-train" ? " <-- prompts may train a model" : ""}`,
|
|
316
|
+
...(effective.retention !== p.retention
|
|
317
|
+
? [
|
|
318
|
+
`${c} base ${p.provider}/${p.model} is ${p.retention}, but ${effective.worst} makes the whole file ${effective.retention}`,
|
|
319
|
+
]
|
|
320
|
+
: []),
|
|
294
321
|
`${c}`,
|
|
295
322
|
`${c}Derived numbers, do not hand-edit:`,
|
|
296
|
-
`${c} compaction.reserved ${reservedTokens(p).toLocaleString("en-US")} (${Math.round((p.effort?.reserveShare ?? 0.12) * 100)}% of the input cap, held back for reasoning growth)`,
|
|
297
|
-
`${c} prune threshold ${pruneThreshold(p).toLocaleString("en-US")} (input cap minus reserve)`,
|
|
323
|
+
`${c} compaction.reserved ${reservedTokens(p).toLocaleString("en-US")} (${Math.round((p.effort?.reserveShare ?? 0.12) * 100)}% of the governing input cap${effectiveInput !== input ? `, ${effectiveInput.toLocaleString("en-US")} for the widest split role` : ""}, held back for reasoning growth)`,
|
|
324
|
+
`${c} prune threshold ${pruneThreshold(p).toLocaleString("en-US")} (governing input cap minus reserve)`,
|
|
298
325
|
`${c} role effort ${Object.entries(roles).map(([k, v]) => `${k}=${v ?? "unset"}`).join(" ")}`,
|
|
326
|
+
...(roleSplitModels(p).length
|
|
327
|
+
? [
|
|
328
|
+
`${c} role models ${roleSplitModels(p).map((r) => `${r.role}=${r.model}${r.limits ? ` (out ${r.limits.output.toLocaleString("en-US")})` : ""}`).join(" ")}`,
|
|
329
|
+
]
|
|
330
|
+
: []),
|
|
299
331
|
`${c} agent steps ${Object.entries(AGENT_STEPS).filter(([r]) => roles[r]).map(([r, n]) => `${r}=${n}`).join(" ")}`,
|
|
300
332
|
]
|
|
301
333
|
|
package/src/profile.js
CHANGED
|
@@ -17,6 +17,7 @@ export const ROOT = resolve(HERE, "..")
|
|
|
17
17
|
/**
|
|
18
18
|
* @typedef {"zero-retention" | "may-train" | "unknown"} Retention
|
|
19
19
|
* @typedef {string | null} EffortRequest
|
|
20
|
+
* @typedef {{ model: string, levels?: string[] }} RoleModel
|
|
20
21
|
* @typedef {object} Profile
|
|
21
22
|
* @property {string} id
|
|
22
23
|
* @property {string} [title]
|
|
@@ -31,6 +32,7 @@ export const ROOT = resolve(HERE, "..")
|
|
|
31
32
|
* structuredOutput?: boolean, attachment?: boolean }} [supports]
|
|
32
33
|
* @property {{ levels?: string[], default?: string | null, reserveShare?: number }} [effort]
|
|
33
34
|
* @property {Record<string, EffortRequest>} [roles]
|
|
35
|
+
* @property {Record<string, RoleModel>} [roleModels]
|
|
34
36
|
* @property {{ enabled?: boolean, keepRecentToolCalls?: number,
|
|
35
37
|
* keepRecentTurns?: number, keepUserMessages?: boolean }} [prune]
|
|
36
38
|
* @property {string[]} [fallback]
|
|
@@ -135,6 +137,35 @@ export function validate(p, path = "<memory>") {
|
|
|
135
137
|
`${where}: effort.reserveShare must be between 0 and 0.5`,
|
|
136
138
|
)
|
|
137
139
|
}
|
|
140
|
+
for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
|
|
141
|
+
if (!SPLITTABLE_ROLES.includes(role)) {
|
|
142
|
+
throw new ProfileError(
|
|
143
|
+
`${where}: roleModels.${role} is not one of ${SPLITTABLE_ROLES.join(", ")}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
// resolveRoles walks `roles`, so a split for a role nobody asks for never
|
|
147
|
+
// reaches an agent. That reads as "split installed" while doing nothing.
|
|
148
|
+
if (!(role in (p.roles ?? {}))) {
|
|
149
|
+
throw new ProfileError(
|
|
150
|
+
`${where}: roleModels.${role} has no matching entry in roles, so it would never apply`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
if (!rm?.model || typeof rm.model !== "string") {
|
|
154
|
+
throw new ProfileError(`${where}: roleModels.${role} needs a model`)
|
|
155
|
+
}
|
|
156
|
+
if (rm.levels !== undefined && !Array.isArray(rm.levels)) {
|
|
157
|
+
throw new ProfileError(
|
|
158
|
+
`${where}: roleModels.${role}.levels must be an array`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
// retention.yml is the only place retention is decided, so a role model
|
|
162
|
+
// missing from it is a profile we cannot honestly write a header for.
|
|
163
|
+
if (!retentionFor(rm.model)) {
|
|
164
|
+
throw new ProfileError(
|
|
165
|
+
`${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
138
169
|
}
|
|
139
170
|
|
|
140
171
|
// Canonical ordering for reasoning effort, used to snap a requested level onto
|
|
@@ -178,15 +209,45 @@ export function resolveEffort(levels, requested) {
|
|
|
178
209
|
return best ?? levels[levels.length - 1] ?? null
|
|
179
210
|
}
|
|
180
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Model and effort levels for one role, or null when the role stays on the
|
|
214
|
+
* profile's base model.
|
|
215
|
+
*
|
|
216
|
+
* @param {Profile} p
|
|
217
|
+
* @param {string} role
|
|
218
|
+
* @returns {{ model: string, levels: string[] } | null}
|
|
219
|
+
*/
|
|
220
|
+
export function resolveRoleModel(p, role) {
|
|
221
|
+
const rm = p.roleModels?.[role]
|
|
222
|
+
if (!rm) return null
|
|
223
|
+
return { model: rm.model, levels: rm.levels ?? [] }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Effort levels a role's own model offers. Falls back to the profile's when the
|
|
228
|
+
* role is not split out, so an unsplit profile behaves exactly as before.
|
|
229
|
+
*
|
|
230
|
+
* @param {Profile} p
|
|
231
|
+
* @param {string} role
|
|
232
|
+
* @returns {string[]}
|
|
233
|
+
*/
|
|
234
|
+
function levelsForRole(p, role) {
|
|
235
|
+
const rm = resolveRoleModel(p, role)
|
|
236
|
+
if (rm) return rm.levels
|
|
237
|
+
return p.effort?.levels ?? []
|
|
238
|
+
}
|
|
239
|
+
|
|
181
240
|
/**
|
|
182
241
|
* @param {Profile} p
|
|
183
242
|
* @returns {Record<string, string | null>}
|
|
184
243
|
*/
|
|
185
244
|
export function resolveRoles(p) {
|
|
186
|
-
const levels = p.effort?.levels ?? []
|
|
187
245
|
const out = /** @type {Record<string, string | null>} */ ({})
|
|
188
246
|
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
189
|
-
|
|
247
|
+
// Resolve against the levels of the model this role actually runs on. A
|
|
248
|
+
// role asking for `max` on a model topping out at `xhigh` gets `xhigh`,
|
|
249
|
+
// which is only correct if the profile knows the role's real model.
|
|
250
|
+
out[role] = resolveEffort(levelsForRole(p, role), want)
|
|
190
251
|
}
|
|
191
252
|
return out
|
|
192
253
|
}
|
|
@@ -201,10 +262,39 @@ export function resolveDefaultVariant(p) {
|
|
|
201
262
|
* tool output. Scaled from the model, never hardcoded.
|
|
202
263
|
* @param {Profile} p @returns {number}
|
|
203
264
|
*/
|
|
265
|
+
// The roles a profile may split onto another model. Kept in step with
|
|
266
|
+
// `roleModels` in profiles/schema.json, which nothing validates at runtime.
|
|
267
|
+
const SPLITTABLE_ROLES = ["plan", "build", "reviewer", "locate", "verify"]
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The model whose limits govern context management: the one with the widest
|
|
271
|
+
* input cap, not necessarily the base model.
|
|
272
|
+
*
|
|
273
|
+
* A split profile derives `reserved` and `prune` from a single `limits`, but
|
|
274
|
+
* the roles do not share it. Sizing for the narrowest input cap silently
|
|
275
|
+
* throttles every wider role to under half its real window, which is the whole
|
|
276
|
+
* point of running them on a bigger model. Sizing for the widest means no role
|
|
277
|
+
* can be under-reserved, which is the direction that has to be safe.
|
|
278
|
+
*
|
|
279
|
+
* @param {Profile} p
|
|
280
|
+
* @returns {number}
|
|
281
|
+
*/
|
|
282
|
+
export function inputCap(p) {
|
|
283
|
+
let cap = p.limits.input ?? p.limits.context
|
|
284
|
+
for (const s of roleSplitModels(p)) {
|
|
285
|
+
const other = s.limits?.input ?? s.limits?.context
|
|
286
|
+
if (typeof other === "number" && other > cap) cap = other
|
|
287
|
+
}
|
|
288
|
+
return cap
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @param {Profile} p
|
|
293
|
+
* @returns {number}
|
|
294
|
+
*/
|
|
204
295
|
export function reservedTokens(p) {
|
|
205
|
-
const input = p.limits.input ?? p.limits.context
|
|
206
296
|
const share = p.effort?.reserveShare ?? 0.12
|
|
207
|
-
return Math.max(8000, Math.round(
|
|
297
|
+
return Math.max(8000, Math.round(inputCap(p) * share))
|
|
208
298
|
}
|
|
209
299
|
|
|
210
300
|
/**
|
|
@@ -212,7 +302,7 @@ export function reservedTokens(p) {
|
|
|
212
302
|
* @param {Profile} p @returns {number}
|
|
213
303
|
*/
|
|
214
304
|
export function pruneThreshold(p) {
|
|
215
|
-
return (p
|
|
305
|
+
return inputCap(p) - reservedTokens(p)
|
|
216
306
|
}
|
|
217
307
|
|
|
218
308
|
/**
|
|
@@ -296,6 +386,67 @@ export function retentionFor(id) {
|
|
|
296
386
|
return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`]
|
|
297
387
|
}
|
|
298
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Every model a profile can put behind the user, base plus any split roles.
|
|
391
|
+
*
|
|
392
|
+
* @param {Profile} p
|
|
393
|
+
* @returns {string[]}
|
|
394
|
+
*/
|
|
395
|
+
export function modelsInPlay(p) {
|
|
396
|
+
const out = [p.model]
|
|
397
|
+
for (const rm of Object.values(p.roleModels ?? {})) {
|
|
398
|
+
if (!out.includes(rm.model)) out.push(rm.model)
|
|
399
|
+
}
|
|
400
|
+
return out
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The split roles, each with the limits of the model that role actually runs
|
|
405
|
+
* on. A role on a different model has a different output cap, and the header
|
|
406
|
+
* has to say so: reading "output cap 524,288" and assuming `build` can spend
|
|
407
|
+
* all of it is wrong whenever build is split onto a smaller model.
|
|
408
|
+
*
|
|
409
|
+
* @param {Profile} p
|
|
410
|
+
* @returns {{ role: string, model: string, levels: string[], limits: Profile["limits"] | null }[]}
|
|
411
|
+
*/
|
|
412
|
+
export function roleSplitModels(p) {
|
|
413
|
+
return Object.entries(p.roleModels ?? {}).map(([role, rm]) => {
|
|
414
|
+
let limits = null
|
|
415
|
+
try {
|
|
416
|
+
limits = loadProfile(rm.model)?.limits ?? null
|
|
417
|
+
} catch {
|
|
418
|
+
limits = null
|
|
419
|
+
}
|
|
420
|
+
return { role, model: rm.model, levels: rm.levels ?? [], limits }
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Ordered safest-first: a profile is only as private as its least private model.
|
|
425
|
+
const RETENTION_SEVERITY = ["zero-retention", "unknown", "may-train"]
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The retention that actually applies once every role is considered, which is
|
|
429
|
+
* what a config header has to print. `retention.yml` is the source of truth.
|
|
430
|
+
*
|
|
431
|
+
* @param {Profile} p
|
|
432
|
+
* @returns {{ retention: string, worst: string | null }}
|
|
433
|
+
*/
|
|
434
|
+
export function effectiveRetention(p) {
|
|
435
|
+
let worst = null
|
|
436
|
+
let retention = "zero-retention"
|
|
437
|
+
for (const id of modelsInPlay(p)) {
|
|
438
|
+
const declared = retentionFor(id)?.retention ?? "unknown"
|
|
439
|
+
if (declared === "may-train") {
|
|
440
|
+
return { retention: "may-train", worst: id }
|
|
441
|
+
}
|
|
442
|
+
if (!worst || RETENTION_SEVERITY.indexOf(declared) > RETENTION_SEVERITY.indexOf(worst)) {
|
|
443
|
+
worst = declared
|
|
444
|
+
}
|
|
445
|
+
retention = worst
|
|
446
|
+
}
|
|
447
|
+
return { retention, worst }
|
|
448
|
+
}
|
|
449
|
+
|
|
299
450
|
/** Where opencode keeps its config, honouring the same env var it does. */
|
|
300
451
|
export function configFilePath() {
|
|
301
452
|
const dir =
|
package/src/rules.js
CHANGED
|
@@ -349,24 +349,9 @@ export const BASH_RULES = [
|
|
|
349
349
|
test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
|
|
350
350
|
why: "downloads and executes a script in one step",
|
|
351
351
|
},
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
// A dry run publishes nothing, so it is not the thing being gated.
|
|
356
|
-
check: (seg) =>
|
|
357
|
-
gitSubcommand(seg) === "push" &&
|
|
358
|
-
tokenize(seg).some((w) => w === "--force" || w === "-f" || w.startsWith("--force")),
|
|
359
|
-
why: "overwrites published history",
|
|
360
|
-
},
|
|
361
|
-
{
|
|
362
|
-
name: "git push",
|
|
363
|
-
scope: "segment",
|
|
364
|
-
// Flags are matched as whole words: a `-f` in `my-feature-f` is a branch
|
|
365
|
-
// name, not a force flag.
|
|
366
|
-
check: (seg) =>
|
|
367
|
-
gitSubcommand(seg) === "push" && !tokenize(seg).some((w) => w === "--dry-run" || w === "-n"),
|
|
368
|
-
why: "publishing is a human decision",
|
|
369
|
-
},
|
|
352
|
+
// `git push` is ask-gated via permissions (`*` ask in the generated
|
|
353
|
+
// config), not guard-blocked. Publishing needs human approval, but the
|
|
354
|
+
// guard lets it through so opencode can prompt first.
|
|
370
355
|
{
|
|
371
356
|
name: "recursive delete",
|
|
372
357
|
scope: "segment",
|