opencode-harness-kit 0.5.7 → 0.6.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.
@@ -373,12 +373,32 @@ RECENT calls was a window on recency, and a long session walks out of it: after
373
373
  six more tool calls, the only copy of a file the agent had read was gone, and
374
374
  the agent read it again. The long-task A/B measured seventeen re-reads, five
375
375
  times the input tokens and the same final context. The rule now keeps the
376
- newest copy of each distinct output however old it is, and stubs only the
377
- REPEATS of it. The context grows by the size of the distinct outputs, and the
376
+ newest copy of each distinct output, and stubs only the REPEATS of it. The
378
377
  re-reads stop: **-69% input tokens and -60% wall time** on that task, at the
379
378
  cost of a bigger context. It is a setting - `prune.keepDistinctOutputs` - and
380
379
  turning it off brings the old behaviour back.
381
380
 
381
+ **And the distinct outputs have a ceiling.** The rule as it first shipped had
382
+ no upper bound, which made it a leak rather than a trade-off: it kept every
383
+ output the session had ever produced, and a long session grew without limit.
384
+ Measured on a real 137-message session on 2026-10-22: the request was 5.26 MB,
385
+ the pruner removed 0.04 MB of it - 0.8% - and 4.74 MB of it, 92%, was the
386
+ distinct outputs of the whole session. The profile's own budget for that
387
+ request was 176,000 characters, so the request was twenty-nine times over it.
388
+
389
+ A request that size is not only expensive, it is what makes the server
390
+ slow: opencode persists a full copy of every updated part into its event
391
+ store, so every turn writes megabytes into a database that on that machine had
392
+ reached 4.2 GB, and the TUI - which is a client of that server - started
393
+ dropping keystrokes mid-word.
394
+
395
+ So the distinct outputs are now kept newest-first and only while they fit in
396
+ a budget: `prune.keepDistinctBudgetChars`, unset meaning the profile's own
397
+ context budget, `0` meaning none. The property the rule was added for still
398
+ holds - the copy of a recent output the agent is working with survives, so it
399
+ does not read the file again - and the part that was never wanted, the copy of
400
+ everything the session has ever touched for ever, is gone.
401
+
382
402
  `savedChars` in `prune.jsonl` is the size of the request before the pass minus
383
403
  the size after it, measured on the parts as they are serialised - not on the
384
404
  text inside them. It used to measure the text only, and to ignore the state
package/lanes.json CHANGED
@@ -69,7 +69,7 @@
69
69
  "permission": {
70
70
  "edit": "deny",
71
71
  "bash": {
72
- "*": "ask",
72
+ "*": "allow",
73
73
  "git status *": "allow",
74
74
  "git diff *": "allow",
75
75
  "mkdir*": "allow",
@@ -102,7 +102,7 @@
102
102
  "permission": {
103
103
  "edit": "deny",
104
104
  "bash": {
105
- "*": "ask",
105
+ "*": "allow",
106
106
  "pdftotext*": "allow",
107
107
  "python3 -c*": "allow",
108
108
  "cat*": "allow",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
4
4
  "description": "Harness kit for OpenCode: capability profiles, a fleet of job lanes with one free model each, role-based reasoning effort, context pruning, and measured baselines.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pham-canh/opencode-harness-kit#readme",
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "beam-free",
7
7
  "family": "beam",
8
- "verified": "2026-10-19",
8
+ "verified": "2026-10-22",
9
9
  "retention": "retained",
10
10
  "notes": "Reflection 501B-A23B, text only, and the freshest knowledge of the free set at 2026-06-30. Measured as the fastest route on the tier: 2.6s average over seven requests. Its provider does not train on prompts but retains them for 30 days, which is why it is `retained` rather than `zero-retention`.",
11
11
  "limits": {
@@ -45,7 +45,6 @@
45
45
  },
46
46
  "fallback": [
47
47
  "deepseek-v4.1-flash-free",
48
- "glm-5.3-flash-free",
49
- "longcat-2.5-preview-free"
48
+ "glm-5.3-flash-free"
50
49
  ]
51
50
  }
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "deepseek-v4.1-flash-free",
7
7
  "family": "deepseek-flash",
8
- "verified": "2026-10-19",
8
+ "verified": "2026-10-22",
9
9
  "retention": "zero-retention",
10
10
  "notes": "Reasoning plus agentic coding plus structured output on a zero-retention provider. The natural schema lane. Its knowledge cutoff is 2025-05, the oldest of the tier, so it is not the lane for anything that depends on recent facts.",
11
11
  "limits": {
@@ -43,7 +43,6 @@
43
43
  },
44
44
  "fallback": [
45
45
  "glm-5.3-flash-free",
46
- "longcat-2.5-preview-free",
47
46
  "beam-free"
48
47
  ]
49
48
  }
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "minimax-m3.1-flash-free",
7
7
  "family": null,
8
- "verified": "2026-10-21",
8
+ "verified": "2026-10-22",
9
9
  "retention": "may-train",
10
10
  "maxRetention": "may-train",
11
11
  "notes": "Every lane gets the model built for it: beam plans, Nemotron 3 Ultra reviews, Lightning sweeps, MiMo sees, GLM audits, Super VL reasons with the effort turned off. Five of the ten providers state that collected data may be used to improve the model, or log the session, so the profile as a whole is may-train and the generated config says so in its header. Install it for public or throwaway repositories.",
@@ -52,16 +52,14 @@
52
52
  "model": "minimax-m3.1-flash-free",
53
53
  "chain": [
54
54
  "minimax-m3.1-flash-free",
55
- "deepseek-v4.1-flash-free",
56
- "longcat-2.5-preview-free"
55
+ "deepseek-v4.1-flash-free"
57
56
  ]
58
57
  },
59
58
  "plan": {
60
59
  "model": "beam-free",
61
60
  "chain": [
62
61
  "beam-free",
63
- "deepseek-v4.1-flash-free",
64
- "longcat-2.5-preview-free"
62
+ "deepseek-v4.1-flash-free"
65
63
  ]
66
64
  },
67
65
  "reviewer": {
@@ -77,7 +75,6 @@
77
75
  "chain": [
78
76
  "nemotron-3.5-lightning-free",
79
77
  "big-pickle",
80
- "longcat-2.5-preview-free",
81
78
  "deepseek-v4.1-flash-free"
82
79
  ]
83
80
  },
@@ -93,7 +90,6 @@
93
90
  "model": "mimo-v2.6-flash-free",
94
91
  "chain": [
95
92
  "mimo-v2.6-flash-free",
96
- "longcat-2.5-preview-free",
97
93
  "minimax-m3.1-flash-free"
98
94
  ]
99
95
  },
@@ -101,8 +97,7 @@
101
97
  "model": "glm-5.3-flash-free",
102
98
  "chain": [
103
99
  "glm-5.3-flash-free",
104
- "deepseek-v4.1-flash-free",
105
- "longcat-2.5-preview-free"
100
+ "deepseek-v4.1-flash-free"
106
101
  ]
107
102
  },
108
103
  "schema": {
@@ -117,14 +112,12 @@
117
112
  "model": "nemotron-3.5-super-vl-free",
118
113
  "chain": [
119
114
  "nemotron-3.5-super-vl-free",
120
- "longcat-2.5-preview-free",
121
115
  "beam-free"
122
116
  ]
123
117
  },
124
118
  "longform": {
125
- "model": "longcat-2.5-preview-free",
119
+ "model": "minimax-m3.1-flash-free",
126
120
  "chain": [
127
- "longcat-2.5-preview-free",
128
121
  "minimax-m3.1-flash-free",
129
122
  "beam-free"
130
123
  ]
@@ -138,7 +131,6 @@
138
131
  },
139
132
  "fallback": [
140
133
  "minimax-m3.1-flash-free",
141
- "deepseek-v4.1-flash-free",
142
- "longcat-2.5-preview-free"
134
+ "deepseek-v4.1-flash-free"
143
135
  ]
144
136
  }
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "minimax-m3.1-flash-free",
7
7
  "family": null,
8
- "verified": "2026-10-21",
8
+ "verified": "2026-10-22",
9
9
  "retention": "zero-retention",
10
10
  "maxRetention": "zero-retention",
11
11
  "notes": "The default. Every lane resolves to a model whose provider states a zero-retention policy: no training, nothing kept. The cost is that six of the ten jobs run on a second-choice model - the planner loses beam (30-day retention) and the reviewer loses Nemotron 3 Ultra - so this is the floor, not the ceiling. The chains are ordered within the ceiling rather than copied from `fleet-all`: `locate` takes deepseek because it publishes a `low` effort level that longcat does not, and `vision` takes minimax because it reads an image in 7 seconds. `fleet-all` gives every lane the model built for it.",
@@ -52,31 +52,27 @@
52
52
  "model": "minimax-m3.1-flash-free",
53
53
  "chain": [
54
54
  "minimax-m3.1-flash-free",
55
- "deepseek-v4.1-flash-free",
56
- "longcat-2.5-preview-free"
55
+ "deepseek-v4.1-flash-free"
57
56
  ]
58
57
  },
59
58
  "plan": {
60
59
  "model": "deepseek-v4.1-flash-free",
61
60
  "chain": [
62
61
  "deepseek-v4.1-flash-free",
63
- "glm-5.3-flash-free",
64
- "longcat-2.5-preview-free"
62
+ "glm-5.3-flash-free"
65
63
  ]
66
64
  },
67
65
  "reviewer": {
68
66
  "model": "glm-5.3-flash-free",
69
67
  "chain": [
70
68
  "glm-5.3-flash-free",
71
- "deepseek-v4.1-flash-free",
72
- "longcat-2.5-preview-free"
69
+ "deepseek-v4.1-flash-free"
73
70
  ]
74
71
  },
75
72
  "locate": {
76
73
  "model": "deepseek-v4.1-flash-free",
77
74
  "chain": [
78
75
  "deepseek-v4.1-flash-free",
79
- "longcat-2.5-preview-free",
80
76
  "minimax-m3.1-flash-free"
81
77
  ]
82
78
  },
@@ -84,15 +80,13 @@
84
80
  "model": "deepseek-v4.1-flash-free",
85
81
  "chain": [
86
82
  "deepseek-v4.1-flash-free",
87
- "glm-5.3-flash-free",
88
- "longcat-2.5-preview-free"
83
+ "glm-5.3-flash-free"
89
84
  ]
90
85
  },
91
86
  "vision": {
92
87
  "model": "minimax-m3.1-flash-free",
93
88
  "chain": [
94
89
  "minimax-m3.1-flash-free",
95
- "longcat-2.5-preview-free",
96
90
  "deepseek-v4.1-flash-free"
97
91
  ]
98
92
  },
@@ -100,30 +94,26 @@
100
94
  "model": "glm-5.3-flash-free",
101
95
  "chain": [
102
96
  "glm-5.3-flash-free",
103
- "deepseek-v4.1-flash-free",
104
- "longcat-2.5-preview-free"
97
+ "deepseek-v4.1-flash-free"
105
98
  ]
106
99
  },
107
100
  "schema": {
108
101
  "model": "deepseek-v4.1-flash-free",
109
102
  "chain": [
110
103
  "deepseek-v4.1-flash-free",
111
- "glm-5.3-flash-free",
112
- "longcat-2.5-preview-free"
104
+ "glm-5.3-flash-free"
113
105
  ]
114
106
  },
115
107
  "sweep": {
116
108
  "model": "deepseek-v4.1-flash-free",
117
109
  "chain": [
118
110
  "deepseek-v4.1-flash-free",
119
- "longcat-2.5-preview-free",
120
111
  "minimax-m3.1-flash-free"
121
112
  ]
122
113
  },
123
114
  "longform": {
124
- "model": "longcat-2.5-preview-free",
115
+ "model": "minimax-m3.1-flash-free",
125
116
  "chain": [
126
- "longcat-2.5-preview-free",
127
117
  "minimax-m3.1-flash-free",
128
118
  "deepseek-v4.1-flash-free"
129
119
  ]
@@ -137,7 +127,6 @@
137
127
  },
138
128
  "fallback": [
139
129
  "deepseek-v4.1-flash-free",
140
- "longcat-2.5-preview-free",
141
130
  "glm-5.3-flash-free"
142
131
  ]
143
132
  }
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "glm-5.3-flash-free",
7
7
  "family": "glm-flash",
8
- "verified": "2026-10-19",
8
+ "verified": "2026-10-22",
9
9
  "retention": "zero-retention",
10
10
  "notes": "Zhipu. The only free model that publishes pdf input, and the only one with structured output and a zero-retention policy at the same time. The pdf does not work: the provider's endpoint rejects a `file` content part, and OpenCode will not read a binary file, so documents are extracted locally first. It measured 4.4-5.8s on the work a lane actually does, and it sits on the thorough lane because it is the careful one rather than the quick one.",
11
11
  "limits": {
@@ -43,7 +43,6 @@
43
43
  },
44
44
  "fallback": [
45
45
  "deepseek-v4.1-flash-free",
46
- "longcat-2.5-preview-free",
47
46
  "beam-free"
48
47
  ]
49
48
  }
@@ -68,17 +68,6 @@
68
68
  "default": false,
69
69
  "note": "Structured output on a zero-retention provider. Careful rather than fast: 4.4-5.8s on the work a lane does."
70
70
  },
71
- {
72
- "id": "longcat-2.5-preview-free",
73
- "file": "profiles/longcat-2.5-preview-free.json",
74
- "title": "LongCat 2.5 Preview Free",
75
- "retention": "zero-retention",
76
- "free": true,
77
- "topEffort": null,
78
- "context": 1000000,
79
- "default": false,
80
- "note": "1M context with image input and a zero-retention provider. A preview endpoint: it can be withdrawn without a rename."
81
- },
82
71
  {
83
72
  "id": "nemotron-3.5-super-vl-free",
84
73
  "file": "profiles/nemotron-3.5-super-vl-free.json",
@@ -156,6 +145,10 @@
156
145
  {
157
146
  "id": "mimo-v2.5-free",
158
147
  "reason": "Withdrawn from the provider. Observed alive for one day. Its replacement on the tier is mimo-v2.6-flash-free."
148
+ },
149
+ {
150
+ "id": "longcat-2.5-preview-free",
151
+ "reason": "Withdrawn from the provider. A preview endpoint, so it could be pulled without a rename - and it was: models.dev still carries the record and marks it deprecated. It was the longform lane's primary in both fleets, so every lane fell back to its second rung, and the profile named only this model had nothing left to fall back to and was retired."
159
152
  }
160
153
  ]
161
154
  }
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "mimo-v2.6-flash-free",
7
7
  "family": "mimo",
8
- "verified": "2026-10-19",
8
+ "verified": "2026-10-22",
9
9
  "retention": "may-train",
10
10
  "notes": "Xiaomi. The only free model that publishes audio input, and it read an image correctly in a live call, at 19.2s average - the slowest lane on the tier, and slower than minimax on the same picture. OpenCode 1.19 refuses to read a binary file at all, so the audio path is not reachable through the harness today; the vision path is. No effort levels, so no variant is written.",
11
11
  "limits": {
@@ -38,7 +38,6 @@
38
38
  "keepUserMessages": true
39
39
  },
40
40
  "fallback": [
41
- "longcat-2.5-preview-free",
42
41
  "minimax-m3.1-flash-free"
43
42
  ]
44
43
  }
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "minimax-m3.1-flash-free",
7
7
  "family": "minimax",
8
- "verified": "2026-10-21",
8
+ "verified": "2026-10-22",
9
9
  "retention": "zero-retention",
10
10
  "notes": "Flagship coding and agent model on the free tier: 512k output tokens, the widest of any free route, and the fastest measured image reader at 7.7s. Tool calls are stable. It does not support temperature, and models.dev publishes no structured_output flag for it, so JSON output is requested in the prompt rather than negotiated with the API. Measured at 3.1s for a one-word reply.",
11
11
  "limits": {
@@ -45,7 +45,6 @@
45
45
  },
46
46
  "fallback": [
47
47
  "deepseek-v4.1-flash-free",
48
- "longcat-2.5-preview-free",
49
48
  "glm-5.3-flash-free"
50
49
  ]
51
50
  }
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "nemotron-3.5-lightning-free",
7
7
  "family": "nemotron-free",
8
- "verified": "2026-10-19",
8
+ "verified": "2026-10-22",
9
9
  "retention": "may-train",
10
10
  "notes": "Its output cap equals its whole context window, so it is the lane for emitting a file larger than 128k tokens. It publishes no effort level, so it spends nothing on reasoning. It is usually the quickest lane on the tier - 3.7s average over four requests - but one measured call took 114s, so it is not on a latency-sensitive path.",
11
11
  "limits": {
@@ -38,7 +38,6 @@
38
38
  "keepUserMessages": true
39
39
  },
40
40
  "fallback": [
41
- "big-pickle",
42
- "longcat-2.5-preview-free"
41
+ "big-pickle"
43
42
  ]
44
43
  }
@@ -5,7 +5,7 @@
5
5
  "provider": "opencode",
6
6
  "model": "nemotron-3.5-super-vl-free",
7
7
  "family": "nemotron-free",
8
- "verified": "2026-10-19",
8
+ "verified": "2026-10-22",
9
9
  "retention": "may-train",
10
10
  "notes": "NVIDIA 120B-A12B. The only free model that publishes a `none` effort level, which is what makes it the cheap no-reasoning sweep lane. The catalog says it takes images, but a live call with an image attachment hangs and never returns: it is used here for text and for reasoning off, never for pictures. The hang is specific to an attachment - a tool read of the same file answers in 4.2s - so the rule is 'never hand it a file', not 'never use it'.",
11
11
  "limits": {
@@ -46,7 +46,6 @@
46
46
  "keepUserMessages": true
47
47
  },
48
48
  "fallback": [
49
- "longcat-2.5-preview-free",
50
49
  "beam-free"
51
50
  ]
52
51
  }
@@ -267,6 +267,14 @@
267
267
  "type": "boolean",
268
268
  "default": true,
269
269
  "description": "Keep the newest copy of each DISTINCT tool output, however old it is. The recent-calls window is a window on recency, and a long session walks out of it: after six more tool calls the only copy of a file the agent read is gone, and the agent reads it again. The long-task A/B measured seventeen re-reads and five times the input tokens for the same answer."
270
+ },
271
+ "keepDistinctBudgetChars": {
272
+ "type": [
273
+ "integer",
274
+ "null"
275
+ ],
276
+ "minimum": 0,
277
+ "description": "The character budget the distinct outputs have to fit in, newest first. The ones past the budget are stubbed like everything else. Null or unset means the profile's own context budget (pruneThreshold), which is what the pruner is measured against: without a ceiling the rule keeps every output the session has ever produced, and on a real 137-message session that was 4.74 MB of a 5.26 MB request against a 176,000 character budget. 0 keeps no distinct output at all."
270
278
  }
271
279
  }
272
280
  },
package/retention.yml CHANGED
@@ -51,15 +51,6 @@ models:
51
51
  verified: 2026-10-19
52
52
  source: https://opencode.ai/docs/zen
53
53
 
54
- opencode/longcat-2.5-preview-free:
55
- retention: zero-retention
56
- note: >-
57
- "Its provider follows a zero-retention policy and does not use your data
58
- for model training." Free for a limited time. A preview model: the
59
- endpoint can be withdrawn without a rename.
60
- verified: 2026-10-19
61
- source: https://opencode.ai/docs/zen
62
-
63
54
  # --- Retained: no training, but the provider keeps the data for a stated
64
55
  # window. Not the same promise as zero-retention, so it gets its own value. ---
65
56
 
package/src/config.js CHANGED
@@ -543,7 +543,15 @@ export function generateConfig(p, opts = {}) {
543
543
  * @returns {string}
544
544
  */
545
545
  function render(cfg, p, opts) {
546
- const c = opts.comments ? "// " : ""
546
+ // `comments: false` means "no comments", and the whole header IS comments.
547
+ // Emitting it without the `// ` prefix - which is what this used to do -
548
+ // produced a file that was neither JSON nor JSONC: the header sat there as
549
+ // bare text in front of the JSON body, and the kit's own `parseJsonc`
550
+ // returned null for it. A caller that asked for pure JSON got a file
551
+ // nothing could read.
552
+ if (!opts.comments) return `${JSON.stringify(cfg, null, 2)}\n`
553
+
554
+ const c = "// "
547
555
  const instructionsPath = opts.instructionsPath ?? globalInstructionsPath()
548
556
  const unhealthy = opts.unhealthy
549
557
  const lanes = loadLanes()
package/src/profile.js CHANGED
@@ -77,7 +77,7 @@ export const ROOT = findRoot(resolve(HERE, ".."))
77
77
  * @property {Record<string, RoleModel>} [roleModels]
78
78
  * @property {{ enabled?: boolean, keepRecentToolCalls?: number,
79
79
  * keepRecentTurns?: number, keepUserMessages?: boolean,
80
- * keepDistinctOutputs?: boolean }} [prune]
80
+ * keepDistinctOutputs?: boolean, keepDistinctBudgetChars?: number | null }} [prune]
81
81
  * @property {string[]} [fallback]
82
82
  * @typedef {{ retention: Retention, note?: string, verified?: string, source?: string }} RetentionRecord
83
83
  */
@@ -214,6 +214,9 @@ export function normalize(p) {
214
214
  keepRecentTurns: p.prune?.keepRecentTurns ?? 3,
215
215
  keepUserMessages: p.prune?.keepUserMessages ?? true,
216
216
  keepDistinctOutputs: p.prune?.keepDistinctOutputs ?? true,
217
+ // null means "the pruner's own budget decides" - the profile's context
218
+ // budget. A number is a ceiling in characters.
219
+ keepDistinctBudgetChars: p.prune?.keepDistinctBudgetChars ?? null,
217
220
  },
218
221
  fallback: p.fallback ?? [],
219
222
  }
@@ -738,6 +741,16 @@ export function validate(p, path = "<memory>") {
738
741
  ) {
739
742
  throw new ProfileError(`${where}: prune.keepDistinctOutputs must be a boolean`)
740
743
  }
744
+ if (prune.keepDistinctBudgetChars !== undefined && prune.keepDistinctBudgetChars !== null) {
745
+ if (
746
+ !Number.isInteger(prune.keepDistinctBudgetChars) ||
747
+ prune.keepDistinctBudgetChars < 0
748
+ ) {
749
+ throw new ProfileError(
750
+ `${where}: prune.keepDistinctBudgetChars must be an integer of at least 0`,
751
+ )
752
+ }
753
+ }
741
754
  if (prune.enabled !== undefined && typeof prune.enabled !== "boolean") {
742
755
  throw new ProfileError(`${where}: prune.enabled must be a boolean`)
743
756
  }
package/src/prune.ts CHANGED
@@ -174,6 +174,8 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
174
174
  // Whether the newest copy of each DISTINCT tool output survives, even
175
175
  // when it is older than the recent-calls window.
176
176
  keepDistinct: boolean
177
+ // The character budget the distinct outputs have to fit in.
178
+ distinctBudget: number
177
179
  threshold: number
178
180
  }
179
181
  const DISABLED: Resolved = {
@@ -182,12 +184,17 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
182
184
  keepTurns: 0,
183
185
  keepUsers: true,
184
186
  keepDistinct: false,
187
+ distinctBudget: 0,
185
188
  threshold: 0,
186
189
  }
187
190
  // Only a SUCCESSFUL lookup is cached. A lookup that found nothing is
188
191
  // retried on the next request, so a project whose marker appears later
189
192
  // still gets pruning.
190
193
  let cached: Resolved | null = null
194
+ // A broken profile is reported once per session, not once per request:
195
+ // only a SUCCESSFUL lookup is cached, so a failing one is retried on every
196
+ // request and would otherwise fill the console with the same line.
197
+ let reportedFailure = false
191
198
 
192
199
  const resolve = (): Resolved => {
193
200
  if (cached) return cached
@@ -195,17 +202,50 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
195
202
  try {
196
203
  const id = activeProfileId(project)
197
204
  if (id) profile = loadProfile(id)
198
- } catch {
205
+ } catch (err) {
199
206
  profile = null
207
+ if (!reportedFailure) {
208
+ reportedFailure = true
209
+ // A profile that cannot be loaded is not a reason to prune silently.
210
+ //
211
+ // The catch around the transform below logs when a prune FAILS, and
212
+ // this one did not: a marker pointing at a renamed or missing
213
+ // profile turned the pruner off with no line in the log, nothing on
214
+ // the console, and nothing to show the user why the context kept
215
+ // growing. A project in that state cannot tell the difference
216
+ // between "the pruner has nothing to do" and "the pruner is broken".
217
+ console.error(
218
+ `[opencode-harness-kit] prune disabled: ${
219
+ err instanceof Error ? err.message : String(err)
220
+ }`,
221
+ )
222
+ }
200
223
  }
201
224
  if (!profile?.prune?.enabled) return DISABLED
225
+ const threshold = pruneThreshold(profile)
202
226
  cached = {
203
227
  profile,
204
228
  keepCalls: profile.prune.keepRecentToolCalls ?? 6,
205
229
  keepTurns: profile.prune.keepRecentTurns ?? 3,
206
230
  keepUsers: profile.prune.keepUserMessages ?? true,
207
231
  keepDistinct: profile.prune.keepDistinctOutputs ?? true,
208
- threshold: pruneThreshold(profile),
232
+ // The budget the distinct outputs have to fit in.
233
+ //
234
+ // It defaults to the profile's own context budget - the number the
235
+ // pruner is measured against - because a rule with no ceiling is not a
236
+ // trade-off, it is a leak. Measured on a real 137-message session: the
237
+ // request was 5.26 MB and the pruner removed 0.04 MB of it (0.8%),
238
+ // because EVERY distinct output the session had ever produced was kept
239
+ // (4.74 MB, 92% of the request) "however old it is". The threshold
240
+ // that session was supposed to live under was 176,000 characters and
241
+ // the request was 5,140,000 - twenty-nine times over.
242
+ //
243
+ // The ceiling keeps the property the rule was added for (the newest
244
+ // copy of a file the agent read survives, so it does not read it
245
+ // again) and drops the part that was never wanted (the copy of every
246
+ // file the session has EVER touched, for ever).
247
+ distinctBudget: profile.prune.keepDistinctBudgetChars ?? threshold,
248
+ threshold,
209
249
  }
210
250
  return cached
211
251
  }
@@ -213,7 +253,7 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
213
253
  return {
214
254
  "experimental.chat.messages.transform": async (_input: any, output: any) => {
215
255
  try {
216
- const { profile, keepCalls, keepTurns, keepUsers, keepDistinct, threshold } = resolve()
256
+ const { profile, keepCalls, keepTurns, keepUsers, keepDistinct, distinctBudget, threshold } = resolve()
217
257
  if (!profile) return
218
258
  const messages = output.messages
219
259
  if (!Array.isArray(messages) || messages.length === 0) return
@@ -257,17 +297,62 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
257
297
  // same input have the same output.
258
298
  if (keepDistinct) {
259
299
  const seenSignatures = new Set<string>()
300
+ // What the distinct outputs kept so far cost the request.
301
+ let kept = 0
260
302
  for (let i = messages.length - 1; i >= 0; i--) {
261
303
  const msg = messages[i]
262
304
  if (!msg?.parts) continue
305
+ // The size of the NEW distinct outputs in this message. A message
306
+ // that repeats an output it already has adds nothing, so it is
307
+ // not charged twice.
308
+ let added = 0
263
309
  for (const part of msg.parts) {
264
310
  if (part?.type !== "tool") continue
265
311
  if (part.state?.status !== "completed") continue
266
312
  const sig = toolSignature(part)
267
- if (!sig || seenSignatures.has(sig)) continue
313
+ if (!sig) continue
314
+ const seen = seenSignatures.has(sig)
268
315
  seenSignatures.add(sig)
269
- keepToolMessageIds.add(msg.info?.id ?? String(i))
316
+ // A REPEAT is never kept by this rule - the newest copy is.
317
+ if (seen) continue
318
+ // Outside the prunable floor the part survives anyway, and it
319
+ // is the newest copy of its output, so it owns that output: an
320
+ // older copy of the same thing is stubbed and this one costs
321
+ // the budget nothing.
322
+ if (i >= floor) continue
323
+ added += partSize(part)
270
324
  }
325
+ if (added === 0) continue
326
+ // The ceiling has to charge what the rule actually KEEPS, and the
327
+ // rule keeps the WHOLE message - every part in it, verbatim
328
+ // (see the keepToolMessageIds branch in the main loop).
329
+ //
330
+ // Charging only the tool parts left the rest of the message free:
331
+ // a message with a 300 character tool output and a 4,000
332
+ // character reasoning part cost the budget 300 and kept 4,300.
333
+ // With a 5,000 character ceiling, twenty such messages kept
334
+ // 40,000 characters of reasoning the ceiling had already ruled
335
+ // out - the rule kept eight times its own budget, and the
336
+ // reasoning it carried along was exactly what the pruner exists
337
+ // to stub. There is no floor under which a reasoning part is
338
+ // left alone, and the ceiling is not one either.
339
+ added = (msg.parts ?? []).reduce(
340
+ (n: number, p: any) => n + partSize(p),
341
+ 0,
342
+ )
343
+ // The ceiling. The walk is newest-first, so what the agent is
344
+ // working with survives and what the session touched at the start
345
+ // is stubbed like everything else.
346
+ //
347
+ // Without it the rule is not a trade-off but a leak: measured on a
348
+ // real 137-message session, the request was 5.26 MB, the pruner
349
+ // removed 0.04 MB of it, and 4.74 MB of it - 92% - was the
350
+ // distinct outputs of the whole session, kept "however old it
351
+ // is". The profile's own budget for that request was 176,000
352
+ // characters.
353
+ if (kept + added > distinctBudget) break
354
+ kept += added
355
+ keepToolMessageIds.add(msg.info?.id ?? String(i))
271
356
  }
272
357
  }
273
358