opencode-harness-kit 0.5.8 → 0.6.1

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,143 @@ is not happening.
12
12
 
13
13
  ---
14
14
 
15
+ ## [0.6.0] — 2026-10-22
16
+
17
+ Published. The tag is what cut it: `git tag v0.6.0 && git push origin
18
+ main v0.6.0`, and the publish job watches for exactly that.
19
+
20
+ A minor: a command for the half of the perishable-tier question nobody was
21
+ answering, and the fix that made it answerable.
22
+
23
+ The free tier turns over in days. `doctor` said whether the model you depend
24
+ on was still there; it said nothing about what had come to replace it, so a
25
+ withdrawal left a hole only a person could fill by hand out of a catalogue
26
+ dump. `research` fills the hole with a shortlist, and `refresh` takes the
27
+ dead rung out of the chain it was in.
28
+
29
+ Two of the bugs here were the same bug wearing different clothes: a check
30
+ that asked "is this model still IN the catalogue" and called the answer
31
+ "yes, it can run". models.dev does not delete a model the provider pulls - it
32
+ keeps the record and marks it `status: "deprecated"`. So this kit's own
33
+ default fleet was carrying a dead fallback rung in **all ten lanes** while
34
+ `doctor` called it healthy.
35
+
36
+ ### Added
37
+
38
+ - **`refresh` now takes the dead rung out.** `research` tells you a rung is
39
+ dead; `refresh` removes it. A chain names the models that could serve a
40
+ lane, best first, and a model the provider has withdrawn cannot run - so it
41
+ is dead weight in a chain whose entire job is to survive a withdrawal.
42
+
43
+ A lane whose own primary was withdrawn takes the next live rung of its own
44
+ chain, because a chain has to start with its model. The fallback queue
45
+ loses its dead entries, and the utility route moves to a model the profile
46
+ still reaches.
47
+
48
+ **The refresh only ever REMOVES.** Putting a model into a lane changes what
49
+ that lane does, and that is a person's decision - it is what `research` is
50
+ for. A script that quietly added a candidate would be putting a model whose
51
+ terms nobody has read into the path of every prompt.
52
+
53
+ Two cases a script cannot repair are reported rather than papered over: a
54
+ profile whose own model was withdrawn (there is no chain behind it to fall
55
+ back to, so the honest fix is to retire the profile, and that is a person's
56
+ call), and a lane left with no model at all.
57
+
58
+ - **`oc-harness research` — the half of the perishable-tier question nobody
59
+ was answering.** `doctor` says whether the model you depend on is still
60
+ there. It said nothing about what has come to replace it, so a withdrawal
61
+ left a hole only a person could fill, by hand, out of a catalogue dump.
62
+
63
+ The new command reads the live catalogue and reports the models in use that
64
+ have been withdrawn — split into the ones that are a profile's own base
65
+ model and the ones that are a rung in a lane chain — then the lanes of the
66
+ default profile with a count of the rungs still alive in each, then the
67
+ candidates: live, free models the kit does not use yet, each with its
68
+ context window, its output cap, the effort levels it publishes, and **which
69
+ lanes it could serve**.
70
+
71
+ "Which lanes it could serve" is the part that makes a candidate usable
72
+ rather than merely interesting. Every lane needs tool calls, the `vision`
73
+ lane needs a model that takes an image, and a lane whose role asks for
74
+ `max` needs a model that publishes `max`. A model that publishes no effort
75
+ levels at all counts as a match, because there is nothing to mismatch.
76
+
77
+ The logic lives in one file, [`src/research.js`](src/research.js), with no
78
+ I/O in it, and the command is a thin layer over it.
79
+
80
+ **It writes nothing** — not `retention.yml`, not a profile, not the config.
81
+ That is the line `refresh` draws, for the same reason: `retention.yml` is a
82
+ human judgement about a provider's terms, and no script can read a
83
+ provider's terms and come back with a privacy verdict. A candidate with no
84
+ verdict is reported as `retention: unknown` and carries an explicit blocker
85
+ rather than being quietly filed under whichever tier it is cheapest on. The
86
+ output is a shortlist to argue with, not a decision to accept.
87
+
88
+ `research --json` prints one parseable object and nothing else, so a
89
+ scheduler can read it.
90
+
91
+ ### Fixed
92
+
93
+ - **`doctor` reported a withdrawn model as a healthy fallback.** The chain
94
+ check asked whether the model was still *in* the catalogue. models.dev does
95
+ not delete a model the provider pulls: it keeps the record and marks it
96
+ `status: "deprecated"`. So "is it still there" and "can it still run" are
97
+ two different questions, and the kit's own default fleet carried a dead
98
+ fallback rung in **all ten lanes** while `doctor` called it healthy. The
99
+ message it printed already said "withdrawn" — only the filter was wrong,
100
+ which is exactly why the dead rung stayed invisible.
101
+
102
+ - **The pruner's ceiling on the distinct outputs did not bound what it kept.**
103
+ The rule keeps the newest copy of each distinct output, and the ceiling on
104
+ that is what stops it being a leak. But the ceiling charged the budget for
105
+ the *tool* parts only, while the rule keeps the *whole message*, verbatim,
106
+ every part in it. A message with a 300-character tool output and a
107
+ 4,000-character reasoning part therefore cost the budget 300 and kept
108
+ 4,300, and twenty such messages kept 40,000 characters of reasoning under a
109
+ 5,000-character ceiling — eight times the budget, carrying exactly the
110
+ reasoning the pruner exists to stub. The ceiling now charges what the rule
111
+ actually keeps.
112
+
113
+ - **A profile that could not be loaded turned the pruner off without a word.**
114
+ The lookup sat in an empty `catch`, so a marker naming a renamed or missing
115
+ profile left the pruner disabled with no line in the log, nothing on the
116
+ console, and nothing to show the user why their context kept growing. A
117
+ project in that state could not tell the difference between "the pruner has
118
+ nothing to do" and "the pruner is broken". The failure is now reported once
119
+ per session, by name.
120
+
121
+ ### Removed
122
+
123
+ - **The `longcat-2.5-preview-free` profile.** The provider withdrew the
124
+ model. A withdrawn model is not a profile that still works - it is a route
125
+ that fails on contact - and this one was the *only* model its profile named,
126
+ so there was no chain behind it to fall back to. The profile is retired and
127
+ recorded in `profiles/index.json` under `withdrawn`, with its retention
128
+ verdict removed from `retention.yml` along with it: a verdict about a model
129
+ that cannot run is not a verdict about anything.
130
+
131
+ The two fleets are unaffected in kind: `longcat` was a fallback rung in
132
+ every lane, and each of those lanes now runs on the rung above it. The
133
+ default fleet loses its third rung, so it now runs its ten lanes on the
134
+ three zero-retention models still live on the tier.
135
+
136
+ - **The demo video is not published with the repository.** The newer take —
137
+ thirty seconds, made on a machine with no screen, the terminal in it the
138
+ real output of the real commands — was under `docs/demo/` for the 0.5.8
139
+ release, and it is out of the repository now.
140
+
141
+ A take is about one machine and one moment, and so is the recording of it.
142
+ The `.gitignore` already said as much: "a recording stays on the machine
143
+ that made it until somebody decides, deliberately, to publish it". The
144
+ decision went the other way, so the file went back where that rule keeps
145
+ it — `video/build/`, which is ignored — and the README no longer links a
146
+ video the repository does not carry.
147
+
148
+ What is published is still the old `docs/demo/pitch.mp4`: four minutes
149
+ forty-seven seconds, recorded live from a Mac terminal, with a voice. The
150
+ npm package never carried a video at all.
151
+
15
152
  ## [0.5.8] — 2026-10-22
16
153
 
17
154
  Published. The tag is what cut it: `git tag v0.5.8 && git push origin
package/README.md CHANGED
@@ -15,16 +15,6 @@ record it and the two mistakes that cost the most time.*
15
15
  *It was recorded against an earlier version of the kit: the numbers it names
16
16
  are the ones from then, and the ones below are the ones now.*
17
17
 
18
- <p align="center">
19
- <video src="docs/demo/audit.mp4" width="720" controls></video>
20
- </p>
21
-
22
- *Thirty seconds, made on a machine with no screen and no camera: the scene is
23
- drawn by a browser, the terminal in it is the **real** output of the **real**
24
- commands — `oc-harness verify` above — and the narration is on the frame. It
25
- names the numbers of this release (475 tests), so nothing in it is out of date.
26
- How it is made is in [`video/`](video/).*
27
-
28
18
  A harness kit for [OpenCode](https://opencode.ai) that is **model-agnostic by
29
19
  construction**. Switching models is a data edit, not a code change.
30
20
 
@@ -111,13 +101,13 @@ The default profile is a **fleet**: ten jobs, ten free models, one session.
111
101
  The table below is the **unrestricted** fleet (`fleet-all`), where every lane
112
102
  gets the model built for it. The default is the **safe** fleet
113
103
  (`fleet-safe`), where every lane resolves to a provider with a zero-retention
114
- policy and six of the ten run on a second-choice model to stay there. Both
104
+ policy and seven of the ten run on a second-choice model to stay there. Both
115
105
  cover the same ten jobs; they differ in what they are willing to promise
116
106
  about your prompts.
117
107
 
118
108
  | lane | job | model | measured |
119
109
  |---|---|---|---|
120
- | `build` | write and verify the change | MiniMax M3.1 Flash | 6.9 s |
110
+ | `build` | write and verify the change | Flint 40B Free | 6.9 s |
121
111
  | `plan` | turn a request into a plan | Beam | **6.1 s** |
122
112
  | `reviewer` | read a change in two passes | Nemotron 3 Ultra | 7.8 s (34 s cold) |
123
113
  | `locate` | find where something lives | Nemotron 3.5 Lightning | 6.9 s |
@@ -126,7 +116,14 @@ about your prompts.
126
116
  | `doc` | read a long document or a spec | GLM 5.3 Flash | 7.4 s |
127
117
  | `schema` | produce JSON or a JSON schema | DeepSeek V4.1 Flash | 10.7 s |
128
118
  | `sweep` | sweep a lot of code mechanically | Nemotron 3.5 Super VL | 8.4 s |
129
- | `longform` | read a megabyte, or write alternatives | LongCat 2.5 Preview | 12.0 s |
119
+ | `longform` | read a megabyte, or write alternatives | Flint 40B Free | 6.9 s |
120
+
121
+ `longform` used to run on `longcat-2.5-preview-free`, and the provider
122
+ withdrew it. A withdrawn model is not a lane, it is a hole in a lane: the
123
+ route falls to the next rung of the same chain, where MiniMax then flint
124
+ carried the lane - flint is the model that replaced MiniMax on the tier, and
125
+ the only one left with the context the lane exists for. The `measured`
126
+ column for that lane is therefore flint's, not either withdrawn model's.
130
127
 
131
128
  The `measured` column is the median of five one-line requests to that model
132
129
  on 2026-10-21 - the same prompt for every model, from the process start, so
@@ -247,16 +244,16 @@ it. One file covers ten jobs either way.
247
244
  |---|---|---|---|---|
248
245
  | `fleet-safe` (default) | 10 | 200,000 | `max` | zero-retention |
249
246
  | `fleet-all` | 10 | 160,000 | `max` | **may train** |
250
- | `minimax-m3.1-flash-free` | 5 | 1,000,000 | `max` | zero-retention |
247
+ | `flint-40b-free` | 5 | 262,144 | `max` | **may train** |
251
248
  | `beam-free` | 5 | 1,048,576 | `max` | retained |
252
249
  | `deepseek-v4.1-flash-free` | 5 | 200,000 | `max` | zero-retention |
253
250
  | `glm-5.3-flash-free` | 5 | 200,000 | `max` | zero-retention |
254
- | `longcat-2.5-preview-free` | 5 | 1,000,000 | — | zero-retention |
255
251
  | `nemotron-3.5-super-vl-free` | 5 | 1,048,576 | `xhigh` | **may train** |
256
252
  | `nemotron-3-ultra-free` | 5 | 1,000,000 | — | **may train** |
257
253
  | `nemotron-3.5-lightning-free` | 5 | 262,144 | — | **may train** |
258
254
  | `mimo-v2.6-flash-free` | 5 | 200,000 | — | **may train** |
259
255
  | `big-pickle` | 5 | 160,000 | — | **may train** |
256
+ | `flint-40b-free` | 5 | 262,144 | `max` | **may train** |
260
257
 
261
258
  The five-lane profiles run everything on one model, which is the right shape
262
259
  when you want one route and want to know exactly which one.
@@ -287,11 +284,13 @@ withdrawn, and one of them was the **default** at the time:
287
284
  | `deepseek-v4-flash-free` | Withdrawn within a month. Replaced by `deepseek-v4.1-flash-free`. |
288
285
  | `muse-spark-1.3-contributor-free` | A Meta contributor route whose provider was permitted to train on prompts. The paid route is still in the catalogue. |
289
286
  | `mimo-v2.5-free` | Observed alive for one day. Replaced by `mimo-v2.6-flash-free`. |
287
+ | `longcat-2.5-preview-free` | Was the `longform` lane's primary in both fleets, so when it was pulled every lane fell back a rung, and the one profile built only for that model was retired. |
288
+ | `minimax-m3.1-flash-free` | Deprecated by the provider and replaced on the tier by `flint-40b-free`, which is the build and `longform` lane today. |
290
289
 
291
290
  The full list, with a reason for each, is in
292
291
  [`profiles/index.json`](profiles/index.json).
293
292
 
294
- ### The four commands, and when each one runs
293
+ ### The five commands, and when each one runs
295
294
 
296
295
  | command | what it checks | when |
297
296
  |---|---|---|
@@ -299,6 +298,7 @@ The full list, with a reason for each, is in
299
298
  | `oc-harness doctor` | every profile against the live model catalogue, and the installed config against the active profile | **before every release**, and whenever a lane starts failing with a 429 or a "model not found" |
300
299
  | `oc-harness doctor --check-models` | one run, non-zero when a shipped model is no longer in the catalogue | from cron: the shape a scheduler wants |
301
300
  | `oc-harness doctor --watch` | keeps watching, and reports only what **changed** | leave it in a terminal for a week |
301
+ | `oc-harness research` | what could **replace** a withdrawn model, and which lanes each candidate could serve | when `doctor` reports a withdrawal, and from cron before it happens |
302
302
  | `oc-harness refresh <id>` | re-derives a profile's machine-derived fields from live metadata | when `doctor` reports drift |
303
303
  | `oc-harness verify` | the installed config still matches the profile, and the kit's own files are still installed | after every `use` / `install` |
304
304
 
@@ -361,6 +361,193 @@ out to be. A lane is never left without one.
361
361
  See [docs/KNOWN-LIMITS.md](docs/KNOWN-LIMITS.md) for the rest of what this
362
362
  tier cannot do.
363
363
 
364
+ ### Research: what can replace the model that just died
365
+
366
+ `doctor` answers one half of the question a perishable tier forces on you:
367
+ **is the model I depend on still there?** The other half is **what is there
368
+ instead** — and nobody was answering that half, so a withdrawal left a hole
369
+ only a person could fill, by hand, out of a catalogue dump.
370
+
371
+ `oc-harness research` is that half, and it is a file:
372
+ [`src/research.js`](src/research.js).
373
+
374
+ ```
375
+ $ oc-harness research
376
+
377
+ research tier opencode, 10 live of 46 free
378
+
379
+ ✓ every model in use is still live
380
+
381
+ lanes
382
+ build 2 live 0 dead
383
+ plan 2 live 0 dead
384
+ reviewer 2 live 0 dead
385
+ ...
386
+
387
+ candidates live, free, and not in use yet
388
+
389
+ flint-40b-free ctx 262,144 out 65,536 effort low,medium,high,xhigh,max
390
+ fits: plan, build, reviewer, locate, verify, doc, schema, sweep, longform
391
+ retention: unknown
392
+ no retention verdict in retention.yml - a person has to read the provider's terms
393
+
394
+ nothing here is written anywhere. A retention verdict is a person reading a provider's terms.
395
+ ```
396
+
397
+ That is the healthy state. When something has been withdrawn, the same command
398
+ says which, and says where it was used:
399
+
400
+ ```
401
+ gone 1 model(s) in use have been withdrawn
402
+
403
+ ✗ longcat-2.5-preview-free
404
+ withdrawn from the provider - the catalogue keeps the record and marks it deprecated
405
+ base model of longcat-2.5-preview-free
406
+ fallback rung in fleet-all: build, plan, locate, vision, doc, sweep, longform
407
+ fallback rung in fleet-safe: build, plan, reviewer, locate, verify, vision, doc, schema, sweep, longform
408
+
409
+ lanes
410
+ build 2 live 1 dead (longcat-2.5-preview-free)
411
+ plan 2 live 1 dead (longcat-2.5-preview-free)
412
+ ...
413
+ ```
414
+
415
+ It reports four things:
416
+
417
+ 1. **The models in use that have been withdrawn**, split into the ones that are
418
+ a profile's own base model and the ones that are a rung in a lane chain. A
419
+ dead base model means that profile does nothing; a dead chain rung is a
420
+ fallback that fails on the day it is needed.
421
+ 2. **The lanes of the default profile**, each with how many rungs of its chain
422
+ are still alive. A lane with zero live rungs is a lane with no model.
423
+ 3. **The candidates** — live, free models the kit does not use yet, each with
424
+ its context window, its output cap, the effort levels it publishes, and
425
+ **which lanes it could serve**.
426
+ 4. **What is still undecided** about each candidate.
427
+
428
+ #### Why the candidate list knows about lanes
429
+
430
+ A model is not "better" or "worse" in the abstract. It is better or worse
431
+ **for a lane**, and the lanes are not asking for the same thing:
432
+
433
+ | what the lane needs | why |
434
+ |---|---|
435
+ | tool calls | every lane calls tools, so every lane needs a model that can |
436
+ | an image in | the `vision` lane reads an image, so it needs a model that takes one |
437
+ | a published effort level | a lane whose role asks for `max` needs a model that publishes `max` |
438
+
439
+ So `research` matches each candidate against each lane, and a model that
440
+ publishes **no** effort levels counts as a match rather than a failure: there
441
+ is nothing to mismatch, and the kit already puts such models in the chains of
442
+ lanes that ask for one.
443
+
444
+ #### The line the script does not cross
445
+
446
+ `research` **writes nothing**. Not `retention.yml`, not a profile, not the
447
+ config.
448
+
449
+ That is the same line `refresh` draws, and it is drawn for the same reason:
450
+ `retention.yml` is a human judgement about a provider's terms — whether your
451
+ prompts are kept, and whether they train on them. A machine cannot read a
452
+ provider's terms of service and come back with a privacy verdict. So a
453
+ candidate with no verdict is reported as `retention: unknown` and carries an
454
+ explicit blocker, rather than being quietly filed under the tier it happens to
455
+ be cheapest on.
456
+
457
+ `refresh` draws the line in the other direction, and it is the one that
458
+ matters: it **only ever removes**. A model that has been withdrawn is removed
459
+ from the chains. A model that might do the job better is never added, because
460
+ putting a model into a lane changes what that lane does, and that is a
461
+ person's decision.
462
+
463
+ The output of `research` is a shortlist to argue with, not a decision to
464
+ accept. When you have read the terms and made the call, `retention.yml` is
465
+ where the call lives, and `doctor` checks the rest.
466
+
467
+ #### Run it on a schedule
468
+
469
+ ```cron
470
+ 0 9 * * * cd ~/.config/opencode && oc-harness doctor --check-models
471
+ 30 9 * * * cd ~/.config/opencode && oc-harness research
472
+ ```
473
+
474
+ The first line says a route died. The second says what to do about it, and it
475
+ runs half an hour later because by then you have had a cup of coffee and the
476
+ answer is a decision, not an alarm.
477
+
478
+ `research --json` prints one parseable object and nothing else, so a script
479
+ can read it:
480
+
481
+ ```json
482
+ {
483
+ "ok": true,
484
+ "tier": { "provider": "opencode", "total": 46, "live": 10, "withdrawn": 36 },
485
+ "shipped": { "count": 9, "gone": [] },
486
+ "lanes": [ { "lane": "build", "dead": [], "live": ["minimax-m3.1-flash-free", "deepseek-v4.1-flash-free"] } ],
487
+ "candidates": [ { "id": "flint-40b-free", "fits": ["build"], "retention": "unknown", "blockers": ["..."] } ]
488
+ }
489
+ ```
490
+
491
+ #### One thing the catalogue does that a naive check misses
492
+
493
+ **A withdrawn model stays in the catalogue.** models.dev does not delete a
494
+ model the provider pulls — it keeps the record and marks it
495
+ `status: "deprecated"`. So "is it still in the catalogue" and "can it still
496
+ run" are two different questions, and a check that asks the first one reports
497
+ a dead model as a healthy fallback.
498
+
499
+ That is not hypothetical. This kit's own default fleet carried a dead fallback
500
+ rung in **all ten lanes**, and `doctor` called it healthy, because the chain
501
+ check asked whether the model was still present. It now asks whether it is
502
+ still alive, and it says so:
503
+
504
+ ```
505
+ ! fleet-safe drift
506
+ lane build: longcat-2.5-preview-free withdrawn, falls back to minimax-m3.1-flash-free
507
+ ```
508
+
509
+ **Free is a price, not a name.** The tier is the set of models the provider
510
+ charges nothing for. `big-pickle` is on it and does not end in `-free`, so
511
+ the suffix is a naming convention and not the test — `research` reads the
512
+ price.
513
+
514
+ ### Refresh: taking the dead rung out
515
+
516
+ `research` tells you a rung is dead. `refresh` takes it out.
517
+
518
+ ```
519
+ $ oc-harness refresh --all
520
+
521
+ refreshed 11 of 11 profile(s) against the live catalogue
522
+ ~ fleet-safe: withdrawn rungs removed: longcat-2.5-preview-free
523
+ lane build: minimax, deepseek, longcat -> minimax, deepseek
524
+ lane longform: longcat, minimax, deepseek -> minimax, deepseek
525
+ ...
526
+ ```
527
+
528
+ A chain names the models that could serve a lane, best first. A model the
529
+ provider has withdrawn is not one of them any more: it cannot run, so it is
530
+ dead weight in a chain whose entire job is to survive a withdrawal. The
531
+ refresh removes it, and if the lane's own primary was the dead one, the lane
532
+ takes the next live rung as its primary — a chain has to start with its
533
+ model. The fallback queue loses its dead entries, and the utility route moves
534
+ to a model the profile still reaches.
535
+
536
+ **The refresh only ever REMOVES.** Putting a model into a lane changes what
537
+ that lane does, and that is a person's decision — it is what `research` is
538
+ for. A script that quietly added a candidate would be putting a model whose
539
+ terms nobody has read into the path of every prompt.
540
+
541
+ Two cases a script cannot repair, and says so instead of pretending:
542
+
543
+ - **A profile whose own model was withdrawn.** There is no chain behind it to
544
+ fall back to, so there is nothing to repair. `refresh` reports it and leaves
545
+ the file alone; the honest fix is to retire the profile, and retiring one is
546
+ a decision a person makes. (That is exactly what happened to
547
+ `longcat-2.5-preview-free`.)
548
+ - **A lane left with no model at all.** Every rung of its chain was dead. It
549
+ is reported, not filled.
550
+
364
551
  ## Requirements
365
552
 
366
553
  | | |
@@ -368,9 +555,9 @@ tier cannot do.
368
555
  | Node | **22 or newer** — `engines` in package.json, and the CLI uses `node:` prefixed builtins. Node 20 fails at import time, not with a friendly message. |
369
556
  | npm | 10 or newer, for `npm ci` when installing from a checkout |
370
557
  | OS | macOS, Linux and Windows. The tests run on `ubuntu-latest`. |
371
- | Shell | none needed to *use* the kit — no `bash`, `zsh` or `fish` setup. `doctor` and `refresh` call `curl` to read models.dev. `baseline` and the measurement scripts read the install database with `sqlite3`, and fall back to python3's own `sqlite3` module when that binary is not installed — so python3 is the one thing to have. Everything else, including `install`, `use` and `verify`, works offline. |
372
- | Network | only `doctor` and `refresh` need it, and only to read the live model catalogue. `list` is offline: it reads the shipped profiles. Everything else works offline. |
373
- | Disk | `use` and `install` write into `~/.config/opencode/` and nothing else. `doctor` writes nothing. `refresh` rewrites a profile in the kit's own
558
+ | Shell | none needed to *use* the kit — no `bash`, `zsh` or `fish` setup. `doctor`, `refresh` and `research` call `curl` to read models.dev. `baseline` and the measurement scripts read the install database with `sqlite3`, and fall back to python3's own `sqlite3` module when that binary is not installed — so python3 is the one thing to have. Everything else, including `install`, `use` and `verify`, works offline. |
559
+ | Network | only `doctor`, `refresh` and `research` need it, and only to read the live model catalogue. `list` is offline: it reads the shipped profiles. Everything else works offline. |
560
+ | Disk | `use` and `install` write into `~/.config/opencode/` and nothing else. `doctor` writes nothing. `research` writes nothing — it is a report, not a migration. `refresh` rewrites a profile in the kit's own
374
561
  directory - `node_modules` for an npm install - and keeps a backup of the
375
562
  one it replaced. |
376
563
 
@@ -583,10 +770,23 @@ recent calls was a window on *recency*, and a long session walks out of it:
583
770
  after six more calls, the only copy of a file the agent had read was gone,
584
771
  and the agent read it again. On the long-task A/B that was seventeen
585
772
  re-reads and five times the input tokens. The newest copy of each distinct
586
- output now survives however old it is, and only the repeats are stubbed -
587
- **-69% input tokens and -60% wall time** on that task, at the cost of a
588
- bigger context. It is a setting: `prune.keepDistinctOutputs` in the profile,
589
- `false` to get the old behaviour back.
773
+ output now survives, and only the repeats are stubbed - **-69% input tokens
774
+ and -60% wall time** on that task, at the cost of a bigger context. It is a
775
+ setting: `prune.keepDistinctOutputs` in the profile, `false` to get the old
776
+ behaviour back.
777
+
778
+ **The distinct outputs have a ceiling.** The rule without one is not a
779
+ trade-off but a leak: it keeps every output the session has ever produced,
780
+ and a long session grows without limit. Measured on a real 137-message
781
+ session on 2026-10-22: the request was 5.26 MB, the pruner removed 0.04 MB of
782
+ it (0.8%), and 4.74 MB of it - 92% - was the distinct outputs of the whole
783
+ session. The profile's own budget for that request was 176,000 characters,
784
+ so the request was twenty-nine times over. The ceiling keeps the newest
785
+ distinct outputs and stubs the ones past it, so the property the rule was
786
+ added for - the copy the agent is working with survives, so it does not read
787
+ it again - still holds. It is `prune.keepDistinctBudgetChars` in the profile;
788
+ unset means the profile's own context budget, and `0` keeps no distinct
789
+ output at all.
590
790
 
591
791
  ## Memory
592
792