opencode-harness-kit 0.4.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/README.md +36 -5
- package/dist/index.js +34 -2
- package/dist/profile.d.ts +3 -1
- package/docs/KNOWN-LIMITS.md +34 -3
- package/eval/RESULTS.md +166 -245
- package/eval/ab.mjs +51 -5
- package/eval/premises.mjs +52 -0
- package/eval/prepare.mjs +34 -4
- package/eval/tasks-long.json +55 -0
- package/eval/tasks-process.json +40 -0
- package/eval/tasks-ts.json +40 -34
- package/eval/verify.mjs +117 -1
- package/package.json +1 -1
- package/profiles/schema.json +5 -0
- package/src/profile.js +9 -1
- package/src/prune.ts +59 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,77 @@ is not happening.
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## [0.5.0] — 2026-10-21
|
|
16
|
+
|
|
17
|
+
A MINOR release, and the reason is the pruner: the rule that decides what
|
|
18
|
+
survives changed, so the default behaviour of the kit changed. A patch
|
|
19
|
+
would have been a lie about what is in the box.
|
|
20
|
+
|
|
21
|
+
Published. The tag is what cut it: `git tag v0.5.0 && git push origin main
|
|
22
|
+
v0.5.0`, and the publish job watches for exactly that.
|
|
23
|
+
|
|
24
|
+
This release is the one where the kit measured itself properly, and what it
|
|
25
|
+
found was a defect in the kit: the pruner was making the agent read the
|
|
26
|
+
same files again.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **A long task for the A/B**, and a grader that can check one. The short
|
|
31
|
+
suite is two-line questions answered in one or two reads, and on those the
|
|
32
|
+
kit is a cost - over five times the input tokens and a tie on correctness.
|
|
33
|
+
That is the wrong task for the kit's central claim, which is that the
|
|
34
|
+
pruner pays for itself on a LONG session: a short session never grows the
|
|
35
|
+
history far enough for pruning to matter. The new task touches twelve files
|
|
36
|
+
and asks a question about the code that consumes what it changed, so the
|
|
37
|
+
conversation is long because the work is long.
|
|
38
|
+
|
|
39
|
+
Grading it needed a new kind of check. A grader that looks at one file
|
|
40
|
+
cannot say whether the other eleven were touched, so the check is a
|
|
41
|
+
COMPARISON with the pristine subject: every file in the run copy must be
|
|
42
|
+
the original with exactly one entry appended, and the file for the model
|
|
43
|
+
being appended must be untouched - a model cannot fall back to itself.
|
|
44
|
+
|
|
45
|
+
- **The process, measured.** The kit claims a reviewer that reviews and does
|
|
46
|
+
not edit. A task that invites the agent to fix what it finds, graded on
|
|
47
|
+
the finding being cited AND the file being untouched, gives 2/2 against
|
|
48
|
+
2/2: the kit's reviewer lane and the default agent behaved the same. The
|
|
49
|
+
lane is not what made them behave, and it is soft - a task that says "fix
|
|
50
|
+
it" overrides it. Both are written down, because a claim that is not
|
|
51
|
+
measured is a claim.
|
|
52
|
+
|
|
53
|
+
- **A public TypeScript subject.** The pilot ran against a private
|
|
54
|
+
repository, which means nobody else could repeat it. The subject is now
|
|
55
|
+
`typesense/typesense-js` at a pinned commit: Apache-2.0, TypeScript source
|
|
56
|
+
under `src/Typesense/` and the COMPILED JavaScript committed next to it in
|
|
57
|
+
`lib/` and again in the unminified bundle. Every answer occurs three times,
|
|
58
|
+
and the bundle keeps the original module banners, so a search for a source
|
|
59
|
+
path lands in the build output. The harness clones it itself:
|
|
60
|
+
`eval/prepare.mjs --clone <url> --ref <sha>`.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- **The pruner keeps the newest copy of each DISTINCT output.** It used to
|
|
65
|
+
keep the last six tool outputs and stub everything older, which is a window
|
|
66
|
+
on RECENCY: after six more calls, the only copy of a file the agent had
|
|
67
|
+
read was gone, and the agent read it again. The long task measured it -
|
|
68
|
+
seventeen re-reads, five times the input tokens, and the same final
|
|
69
|
+
context. The newest copy of each distinct output now survives however old
|
|
70
|
+
it is, and only the repeats are stubbed. Same subject, same task, same
|
|
71
|
+
model: **-69% input tokens and -60% wall time**, at the cost of a bigger
|
|
72
|
+
context. It is a setting, `prune.keepDistinctOutputs`, so the old
|
|
73
|
+
behaviour is one profile edit away.
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- **The nightly A/B failed every night** because the repository has no
|
|
78
|
+
provider secret. The job is skipped when the key is missing, because a
|
|
79
|
+
schedule that is always red is a schedule nobody reads.
|
|
80
|
+
|
|
81
|
+
- **`oc-harness uninstall` and the `restore` command** now keep the dated
|
|
82
|
+
backups in one place, and the prompt that asks "overwrite the config?"
|
|
83
|
+
says where the backup goes. It said ".bak", and the only thing that wrote
|
|
84
|
+
a `.bak` was `restore`.
|
|
85
|
+
|
|
15
86
|
## [0.4.6] — 2026-10-21
|
|
16
87
|
|
|
17
88
|
Published. The tag is what cut it: `git tag v0.4.6 && git push origin main
|
package/README.md
CHANGED
|
@@ -539,6 +539,16 @@ the same messages with and without. On one install, on 2026-10-20:
|
|
|
539
539
|
sessions, because it is measured over the ones this machine happened to
|
|
540
540
|
have - so run the command for the number on yours.
|
|
541
541
|
|
|
542
|
+
**What is kept is the newest copy of each DISTINCT output.** The window on
|
|
543
|
+
recent calls was a window on *recency*, and a long session walks out of it:
|
|
544
|
+
after six more calls, the only copy of a file the agent had read was gone,
|
|
545
|
+
and the agent read it again. On the long-task A/B that was seventeen
|
|
546
|
+
re-reads and five times the input tokens. The newest copy of each distinct
|
|
547
|
+
output now survives however old it is, and only the repeats are stubbed -
|
|
548
|
+
**-69% input tokens and -60% wall time** on that task, at the cost of a
|
|
549
|
+
bigger context. It is a setting: `prune.keepDistinctOutputs` in the profile,
|
|
550
|
+
`false` to get the old behaviour back.
|
|
551
|
+
|
|
542
552
|
## Memory
|
|
543
553
|
|
|
544
554
|
The kit does not implement memory. A local vector store with embeddings, a
|
|
@@ -814,11 +824,32 @@ next, which is the shape some of the kit's answers took. Re-scoring the
|
|
|
814
824
|
same 120 runs with the fixed grader gives 34/40 against 30/40 on the lookup
|
|
815
825
|
tasks - the kit ahead, where the old grader had it behind. The full account is in [eval/RESULTS.md](eval/RESULTS.md).
|
|
816
826
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
a
|
|
821
|
-
|
|
827
|
+
**On a long task the pruner is where the kit pays.** The suite above is
|
|
828
|
+
made of two-line questions answered in one or two reads, and a session that
|
|
829
|
+
short never grows the history far enough for pruning to matter. So there is
|
|
830
|
+
a second suite with one task that touches twelve files and asks a question
|
|
831
|
+
about the code that consumes what it changed — and on it, with the pruner's
|
|
832
|
+
rule fixed (see below), the kit's arm makes the same number of tool calls
|
|
833
|
+
as the arm without the kit and takes 1.2x the wall time.
|
|
834
|
+
|
|
835
|
+
**The pruner used to make the agent re-read.** The first run of that task
|
|
836
|
+
said the kit was much worse: 86 tool calls against 43 and 282,000 input
|
|
837
|
+
tokens against 51,000, for the same final context. Seventeen of the reads
|
|
838
|
+
were re-reads. The pruner keeps the last six tool outputs and stubs
|
|
839
|
+
everything older — a window on *recency* — so after six more calls the only
|
|
840
|
+
copy of a file the agent had read was gone, and the agent read it again. The
|
|
841
|
+
newest copy of each **distinct** output now survives however old it is, and
|
|
842
|
+
only the repeats are stubbed. Same subject, same task, same model: **-69%
|
|
843
|
+
input tokens and -60% wall time**, at the cost of a bigger context.
|
|
844
|
+
|
|
845
|
+
**The process is measured too, and the result is not flattering.** The kit
|
|
846
|
+
claims a reviewer that reviews and does not edit. A task that invites the
|
|
847
|
+
agent to fix what it finds, graded on the finding being cited AND the file
|
|
848
|
+
being untouched, gives **2/2 against 2/2**: the kit's reviewer lane and the
|
|
849
|
+
default agent behaved the same. The lane is not what made them behave, and
|
|
850
|
+
it is soft — a task that says "fix it" overrides it. Both are in
|
|
851
|
+
[eval/RESULTS.md](eval/RESULTS.md), because a claim that is not measured is
|
|
852
|
+
a claim.
|
|
822
853
|
|
|
823
854
|
Whether it is worth it depends on whether the work is long. A repository of
|
|
824
855
|
one-file lookups should not install this. The full table is in
|
package/dist/index.js
CHANGED
|
@@ -670,7 +670,8 @@ function normalize(p) {
|
|
|
670
670
|
enabled: p.prune?.enabled ?? false,
|
|
671
671
|
keepRecentToolCalls: p.prune?.keepRecentToolCalls ?? 6,
|
|
672
672
|
keepRecentTurns: p.prune?.keepRecentTurns ?? 3,
|
|
673
|
-
keepUserMessages: p.prune?.keepUserMessages ?? true
|
|
673
|
+
keepUserMessages: p.prune?.keepUserMessages ?? true,
|
|
674
|
+
keepDistinctOutputs: p.prune?.keepDistinctOutputs ?? true
|
|
674
675
|
},
|
|
675
676
|
fallback: p.fallback ?? []
|
|
676
677
|
};
|
|
@@ -942,6 +943,9 @@ function validate(p, path = "<memory>") {
|
|
|
942
943
|
if (prune.keepUserMessages !== void 0 && typeof prune.keepUserMessages !== "boolean") {
|
|
943
944
|
throw new ProfileError(`${where}: prune.keepUserMessages must be a boolean`);
|
|
944
945
|
}
|
|
946
|
+
if (prune.keepDistinctOutputs !== void 0 && typeof prune.keepDistinctOutputs !== "boolean") {
|
|
947
|
+
throw new ProfileError(`${where}: prune.keepDistinctOutputs must be a boolean`);
|
|
948
|
+
}
|
|
945
949
|
if (prune.enabled !== void 0 && typeof prune.enabled !== "boolean") {
|
|
946
950
|
throw new ProfileError(`${where}: prune.enabled must be a boolean`);
|
|
947
951
|
}
|
|
@@ -1166,6 +1170,17 @@ function activeProfileId(worktree) {
|
|
|
1166
1170
|
return null;
|
|
1167
1171
|
}
|
|
1168
1172
|
var REASONING_STUB = "[reasoning] dropped from context - it was from an earlier turn and is stale.";
|
|
1173
|
+
function toolSignature(part) {
|
|
1174
|
+
const tool = part?.state?.tool ?? part?.tool;
|
|
1175
|
+
if (!tool) return null;
|
|
1176
|
+
let input;
|
|
1177
|
+
try {
|
|
1178
|
+
input = JSON.stringify(part?.state?.input ?? {});
|
|
1179
|
+
} catch {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
return `${tool} ${input}`;
|
|
1183
|
+
}
|
|
1169
1184
|
function sizeOf(part) {
|
|
1170
1185
|
if (typeof part?.text === "string") return part.text.length;
|
|
1171
1186
|
if (typeof part?.state?.output === "string") return part.state.output.length;
|
|
@@ -1197,6 +1212,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1197
1212
|
keepCalls: 0,
|
|
1198
1213
|
keepTurns: 0,
|
|
1199
1214
|
keepUsers: true,
|
|
1215
|
+
keepDistinct: false,
|
|
1200
1216
|
threshold: 0
|
|
1201
1217
|
};
|
|
1202
1218
|
let cached = null;
|
|
@@ -1215,6 +1231,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1215
1231
|
keepCalls: profile.prune.keepRecentToolCalls ?? 6,
|
|
1216
1232
|
keepTurns: profile.prune.keepRecentTurns ?? 3,
|
|
1217
1233
|
keepUsers: profile.prune.keepUserMessages ?? true,
|
|
1234
|
+
keepDistinct: profile.prune.keepDistinctOutputs ?? true,
|
|
1218
1235
|
threshold: pruneThreshold(profile)
|
|
1219
1236
|
};
|
|
1220
1237
|
return cached;
|
|
@@ -1222,7 +1239,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1222
1239
|
return {
|
|
1223
1240
|
"experimental.chat.messages.transform": async (_input, output) => {
|
|
1224
1241
|
try {
|
|
1225
|
-
const { profile, keepCalls, keepTurns, keepUsers, threshold } = resolve4();
|
|
1242
|
+
const { profile, keepCalls, keepTurns, keepUsers, keepDistinct, threshold } = resolve4();
|
|
1226
1243
|
if (!profile) return;
|
|
1227
1244
|
const messages = output.messages;
|
|
1228
1245
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
@@ -1239,6 +1256,21 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1239
1256
|
seen++;
|
|
1240
1257
|
}
|
|
1241
1258
|
}
|
|
1259
|
+
if (keepDistinct) {
|
|
1260
|
+
const seenSignatures = /* @__PURE__ */ new Set();
|
|
1261
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1262
|
+
const msg = messages[i];
|
|
1263
|
+
if (!msg?.parts) continue;
|
|
1264
|
+
for (const part of msg.parts) {
|
|
1265
|
+
if (part?.type !== "tool") continue;
|
|
1266
|
+
if (part.state?.status !== "completed") continue;
|
|
1267
|
+
const sig = toolSignature(part);
|
|
1268
|
+
if (!sig || seenSignatures.has(sig)) continue;
|
|
1269
|
+
seenSignatures.add(sig);
|
|
1270
|
+
keepToolMessageIds.add(msg.info?.id ?? String(i));
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1242
1274
|
const stats = {
|
|
1243
1275
|
stubbed: 0,
|
|
1244
1276
|
kept: 0,
|
package/dist/profile.d.ts
CHANGED
|
@@ -54,6 +54,7 @@ export type Profile = {
|
|
|
54
54
|
keepRecentToolCalls?: number;
|
|
55
55
|
keepRecentTurns?: number;
|
|
56
56
|
keepUserMessages?: boolean;
|
|
57
|
+
keepDistinctOutputs?: boolean;
|
|
57
58
|
};
|
|
58
59
|
fallback?: string[];
|
|
59
60
|
};
|
|
@@ -86,7 +87,8 @@ export type RetentionRecord = {
|
|
|
86
87
|
* @property {Record<string, EffortRequest>} [roles]
|
|
87
88
|
* @property {Record<string, RoleModel>} [roleModels]
|
|
88
89
|
* @property {{ enabled?: boolean, keepRecentToolCalls?: number,
|
|
89
|
-
* keepRecentTurns?: number, keepUserMessages?: boolean
|
|
90
|
+
* keepRecentTurns?: number, keepUserMessages?: boolean,
|
|
91
|
+
* keepDistinctOutputs?: boolean }} [prune]
|
|
90
92
|
* @property {string[]} [fallback]
|
|
91
93
|
* @typedef {{ retention: Retention, note?: string, verified?: string, source?: string }} RetentionRecord
|
|
92
94
|
*/
|
package/docs/KNOWN-LIMITS.md
CHANGED
|
@@ -175,6 +175,11 @@ writes it to the project root, and reading `process.cwd()` alone reported
|
|
|
175
175
|
|
|
176
176
|
## The free tier cannot read a PDF, an audio file or a video
|
|
177
177
|
|
|
178
|
+
**Re-checked 2026-10-21, against the live tier, and both still hold.** A
|
|
179
|
+
PDF attached to a prompt is refused by the provider with the message below,
|
|
180
|
+
and an `.m4a` is refused by opencode with `Cannot read binary file`. The
|
|
181
|
+
model then answers as if it had heard nothing.
|
|
182
|
+
|
|
178
183
|
Three capabilities the catalogue advertises are not reachable, and no lane
|
|
179
184
|
claims them:
|
|
180
185
|
|
|
@@ -342,9 +347,19 @@ does; if it does not, `prune.ts` is doing the work anyway.
|
|
|
342
347
|
|
|
343
348
|
`prune.ts` keeps pointers rather than bodies. Full tool output is not deleted
|
|
344
349
|
from the session record, and OpenCode already writes over-cap output to its own
|
|
345
|
-
truncation directory, so re-reading is possible.
|
|
346
|
-
|
|
347
|
-
|
|
350
|
+
truncation directory, so re-reading is possible. The stub is one line precisely
|
|
351
|
+
so an agent can decide whether it is worth it, and the stub says so.
|
|
352
|
+
|
|
353
|
+
**But the newest copy of each DISTINCT output survives.** The window on
|
|
354
|
+
RECENT calls was a window on recency, and a long session walks out of it: after
|
|
355
|
+
six more tool calls, the only copy of a file the agent had read was gone, and
|
|
356
|
+
the agent read it again. The long-task A/B measured seventeen re-reads, five
|
|
357
|
+
times the input tokens and the same final context. The rule now keeps the
|
|
358
|
+
newest copy of each distinct output however old it is, and stubs only the
|
|
359
|
+
REPEATS of it. The context grows by the size of the distinct outputs, and the
|
|
360
|
+
re-reads stop: **-69% input tokens and -60% wall time** on that task, at the
|
|
361
|
+
cost of a bigger context. It is a setting - `prune.keepDistinctOutputs` - and
|
|
362
|
+
turning it off brings the old behaviour back.
|
|
348
363
|
|
|
349
364
|
A `savedChars` of 0 in `prune.jsonl` is not a second, redundant pass. It means
|
|
350
365
|
`bytesAfter` did not come out below `bytesBefore` for that turn, which happens
|
|
@@ -361,6 +376,22 @@ possible. Treat the ratio as a guide, not an exact count. It does import the
|
|
|
361
376
|
real allowlist rather than keeping a copy, so the list cannot drift from what is
|
|
362
377
|
actually shipped.
|
|
363
378
|
|
|
379
|
+
## A lane is a default, not a boundary
|
|
380
|
+
|
|
381
|
+
The kit's reviewer prompt says: *"You review. You never fix, because a
|
|
382
|
+
reviewer who edits stops being a reviewer."* That is an instruction, and an
|
|
383
|
+
instruction is only as strong as the task it sits next to.
|
|
384
|
+
|
|
385
|
+
It was measured. A task that says "review this and FIX it if it is wrong"
|
|
386
|
+
makes the reviewer lane edit the file, and makes the default agent edit it
|
|
387
|
+
too - both arms scored zero on "the finding was reported AND the file was
|
|
388
|
+
untouched". A task that says "tell me what needs to change" makes both arms
|
|
389
|
+
report and neither edit, so the lane made no difference there either.
|
|
390
|
+
|
|
391
|
+
So the lane holds a line when the task does not push against it, and the
|
|
392
|
+
kit's own loop keeps that line by never asking a reviewer to fix anything.
|
|
393
|
+
If you write a review task that says "fix it", the reviewer will fix it.
|
|
394
|
+
|
|
364
395
|
## What this kit does not do
|
|
365
396
|
|
|
366
397
|
It does not implement memory persistence, worktree orchestration, a context
|