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 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
- What the kit buys is in the two places that suite cannot see: **long
818
- sessions**, where the pruner removes 65-80% of the context depending on the
819
- sessions it is measured over, and **process** -
820
- a reviewer that reviews and does not edit, a verify that runs the checks and
821
- does not fix them, a publish decision that belongs to a human.
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 }} [prune]
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
  */
@@ -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. But "re-run the command to see
346
- it again" is more expensive than "scroll up". The stub is one line precisely so
347
- an agent can decide whether it is worth it, and the stub says so.
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