opencode-harness-kit 0.5.4 → 0.5.6
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 +207 -1
- package/bin/oc-harness.mjs +278 -45
- package/dist/config.d.ts +5 -0
- package/dist/denylist.d.ts +5 -1
- package/dist/guard.d.ts +17 -1
- package/dist/health.d.ts +69 -7
- package/dist/index.js +514 -73
- package/dist/profile.d.ts +32 -2
- package/dist/telemetry-store.d.ts +22 -0
- package/docs/EXIT-CODES.md +7 -0
- package/docs/KNOWN-LIMITS.md +53 -11
- package/eval/measure-prune.mjs +228 -54
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +45 -2
- package/src/guard.ts +78 -7
- package/src/health.js +130 -5
- package/src/lane-usage.js +13 -6
- package/src/profile.js +187 -25
- package/src/prune.ts +58 -13
- package/src/router.ts +217 -17
- package/src/rules.js +440 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- package/src/telemetry.ts +69 -9
- package/src/trust.js +182 -54
package/dist/profile.d.ts
CHANGED
|
@@ -307,8 +307,21 @@ export declare function pruneThreshold(p: Profile, opts?: {
|
|
|
307
307
|
unhealthy?: Set<string>;
|
|
308
308
|
}): number;
|
|
309
309
|
/**
|
|
310
|
-
* Every model a profile can put behind the user: the base model
|
|
311
|
-
* each lane actually resolves to.
|
|
310
|
+
* Every model a profile can put behind the user: the base model, the model
|
|
311
|
+
* each lane actually resolves to, and the utility route.
|
|
312
|
+
*
|
|
313
|
+
* The utility route belongs in this list because it is WRITTEN into the
|
|
314
|
+
* config as `small_model` and opencode sends real requests down it - a
|
|
315
|
+
* title, a one-line summary, a rewritten question. It used to be a chain
|
|
316
|
+
* rung only, so it never appeared here, and a profile could name a
|
|
317
|
+
* may-train utility route under a zero-retention claim: the file said
|
|
318
|
+
* "zero-retention" while a training provider served the calls.
|
|
319
|
+
*
|
|
320
|
+
* Every caller of this list wants the same thing - the models that can
|
|
321
|
+
* serve a request - so the list is widened rather than a second one
|
|
322
|
+
* invented beside it. The one caller that needs the lanes alone is
|
|
323
|
+
* `validate()`, which cross-checks the profile's DECLARED retention against
|
|
324
|
+
* the base model and the lanes; that is `laneModelsInPlay`.
|
|
312
325
|
*
|
|
313
326
|
* @param {Profile} p
|
|
314
327
|
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
@@ -317,6 +330,23 @@ export declare function pruneThreshold(p: Profile, opts?: {
|
|
|
317
330
|
export declare function modelsInPlay(p: Profile, opts?: {
|
|
318
331
|
unhealthy?: Set<string>;
|
|
319
332
|
}): string[];
|
|
333
|
+
/**
|
|
334
|
+
* The models the profile's own base and lanes put behind the user, without
|
|
335
|
+
* the utility route.
|
|
336
|
+
*
|
|
337
|
+
* This is the set the profile's own `retention` field is about: the base
|
|
338
|
+
* model and the lanes it resolves to. The utility route is an extra route
|
|
339
|
+
* the profile adds on top of them, and it is reported where it belongs - the
|
|
340
|
+
* config header names it, and `oc-harness use` warns about it - so it does
|
|
341
|
+
* not decide what the profile may declare about itself.
|
|
342
|
+
*
|
|
343
|
+
* @param {Profile} p
|
|
344
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
345
|
+
* @returns {string[]}
|
|
346
|
+
*/
|
|
347
|
+
export declare function laneModelsInPlay(p: Profile, opts?: {
|
|
348
|
+
unhealthy?: Set<string>;
|
|
349
|
+
}): string[];
|
|
320
350
|
/**
|
|
321
351
|
* The split lanes, each with the limits of the model that lane actually runs
|
|
322
352
|
* on. A lane on a different model has a different output cap, and the header
|
|
@@ -3,6 +3,28 @@ export declare const MAX_BYTES: number;
|
|
|
3
3
|
export declare function logPath(dir: string): string;
|
|
4
4
|
/** The telemetry file and its rotated predecessors, oldest first. */
|
|
5
5
|
export declare function logFiles(dir: string): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Rotate by size so a long-running project cannot fill its disk.
|
|
8
|
+
*
|
|
9
|
+
* THREE generations are kept: the current file, `.1` and `.2`. The reader
|
|
10
|
+
* enumerates all three, so the writer has to produce all three.
|
|
11
|
+
*
|
|
12
|
+
* The rotation used to rename the current file straight to `.1`, and a
|
|
13
|
+
* rename REPLACES the destination on POSIX - so the previous generation was
|
|
14
|
+
* destroyed on every rotation and `.2` never came into existence. Two
|
|
15
|
+
* rotations after the fact, the oldest history was gone and the third
|
|
16
|
+
* generation the reader looks for was missing: the history a project lost
|
|
17
|
+
* was lost on a schedule nobody was watching.
|
|
18
|
+
*
|
|
19
|
+
* So the oldest is shifted out of the way first, and the oldest of the
|
|
20
|
+
* three is dropped - the disk use stays bounded at three files, and the
|
|
21
|
+
* generation that is dropped is the one that was already two rotations
|
|
22
|
+
* old.
|
|
23
|
+
*
|
|
24
|
+
* @param file the file to rotate
|
|
25
|
+
* @param maxBytes the size above which it is rotated
|
|
26
|
+
*/
|
|
27
|
+
export declare function rotate(file: string, maxBytes?: number): void;
|
|
6
28
|
/**
|
|
7
29
|
* Append one record.
|
|
8
30
|
*
|
package/docs/EXIT-CODES.md
CHANGED
|
@@ -75,6 +75,13 @@ attributable measurement carries the gate.
|
|
|
75
75
|
| 0 | The install is in place, or the change was confirmed. Something may have been left alone - a file that was already there, a prompt that was not answered - and the reason is printed. |
|
|
76
76
|
| 1 | The install was refused, or a step failed. |
|
|
77
77
|
|
|
78
|
+
A target that was left alone is NOT counted as installed: the install says
|
|
79
|
+
so in place of its tick, and `verify` reports the file that is not the kit's
|
|
80
|
+
- because a file of the user's own at `GLOBAL.md` means the kit's rules are
|
|
81
|
+
not being loaded, and a tick next to that is a claim the installer cannot
|
|
82
|
+
make. A link that could not be created is a step that failed, and the
|
|
83
|
+
install exits 1.
|
|
84
|
+
|
|
78
85
|
`doctor` also exits 1 when there is no config, or no profile recorded in
|
|
79
86
|
it: a kit that is installed but points at nothing is a broken install, and
|
|
80
87
|
a check that passes it is not a check.
|
package/docs/KNOWN-LIMITS.md
CHANGED
|
@@ -323,10 +323,28 @@ comparable, and the gate uses the number that IS comparable.
|
|
|
323
323
|
`eval/measure-prune.mjs` is that number. opencode stores the message parts
|
|
324
324
|
unpruned - the pruner rewrites the outgoing request, not the history - so the
|
|
325
325
|
stored parts of a real session are exactly what the pruner was given, and
|
|
326
|
-
replaying the real pruner over them measures exactly what it removed.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
326
|
+
replaying the real pruner over them measures exactly what it removed.
|
|
327
|
+
|
|
328
|
+
It reports **two** measures, because the script and the pruner's own log used
|
|
329
|
+
to answer two different questions and only one of them is this kit's claim:
|
|
330
|
+
|
|
331
|
+
- **REQUEST** - the size of the request that goes back to the model, counted
|
|
332
|
+
on the parts as they are serialised. This is the same definition
|
|
333
|
+
`savedChars` uses in `prune.jsonl`, so the script and the log ask the same
|
|
334
|
+
question of the same parts and their numbers reconcile rather than
|
|
335
|
+
disagree: the script's request delta IS the sum of the log's `savedChars`
|
|
336
|
+
for the same passes. It is the primary number and the one the `--min-saved`
|
|
337
|
+
gate is computed from, because "a smaller request" is what this kit claims.
|
|
338
|
+
- **TEXT** - the text inside the parts: how much text was replaced. It is the
|
|
339
|
+
larger of the two, because the request measure also carries the parts the
|
|
340
|
+
pruner left alone, and because the stub's own metadata is part of the
|
|
341
|
+
request and counts against the saving.
|
|
342
|
+
|
|
343
|
+
On one install: **80.5% of the text across 60 real sessions**, 6,608 parts
|
|
344
|
+
stubbed. That is the TEXT measure - the number `npm run measure:prune`
|
|
345
|
+
printed before the two were separated, when the script measured text and
|
|
346
|
+
labelled it "context". The REQUEST measure on the same install is lower, and
|
|
347
|
+
it is the one the gate checks.
|
|
330
348
|
|
|
331
349
|
The controlled comparison of the two arms is `eval/ab.mjs`, and its result is
|
|
332
350
|
in `eval/RESULTS.md`.
|
|
@@ -361,13 +379,37 @@ re-reads stop: **-69% input tokens and -60% wall time** on that task, at the
|
|
|
361
379
|
cost of a bigger context. It is a setting - `prune.keepDistinctOutputs` - and
|
|
362
380
|
turning it off brings the old behaviour back.
|
|
363
381
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
382
|
+
`savedChars` in `prune.jsonl` is the size of the request before the pass minus
|
|
383
|
+
the size after it, measured on the parts as they are serialised - not on the
|
|
384
|
+
text inside them. It used to measure the text only, and to ignore the state
|
|
385
|
+
the stub put in a part's place, which carries about fifty characters of its
|
|
386
|
+
own metadata per stubbed part. So a pass whose request GREW was reported as
|
|
387
|
+
a saving of the difference between two texts.
|
|
388
|
+
|
|
389
|
+
A negative `savedChars` therefore means the pass made the request BIGGER: the
|
|
390
|
+
stub was longer than the output it replaced, because the stub carries its own
|
|
391
|
+
label and its own metadata. Such a pass is still logged, because a part was
|
|
392
|
+
stubbed, which is the event worth recording, and the negative number is the
|
|
393
|
+
honest report of it.
|
|
394
|
+
|
|
395
|
+
The measurement script measures the same thing on purpose.
|
|
396
|
+
`eval/measure-prune.mjs` counts a part with the pruner's own expression -
|
|
397
|
+
`JSON.stringify(part).length` - before and after the pass, and reports it as
|
|
398
|
+
the REQUEST measure, so the two reconcile by construction instead of by
|
|
399
|
+
coincidence: replaying twelve real sessions through the real pruner, the
|
|
400
|
+
script measured a request delta of **6,049** characters and the log the
|
|
401
|
+
pruner wrote during that replay said `savedChars: 6049`. The script also
|
|
402
|
+
reports the TEXT measure, because the two answer different questions and the
|
|
403
|
+
difference is worth stating rather than hiding: the request measure counts
|
|
404
|
+
the stub's own metadata against the saving and carries the parts the pruner
|
|
405
|
+
left alone, so it is the smaller of the two, and it can be negative where the
|
|
406
|
+
text measure is positive.
|
|
407
|
+
|
|
408
|
+
There is no minimum size under which a part is left alone. This file used to
|
|
409
|
+
claim a 200 byte floor for `reasoning` parts; there was never such a floor in
|
|
410
|
+
the code, and a reasoning part shorter than the stub - about seventy-eight
|
|
411
|
+
characters - is still replaced, so the request grows. The measurement above
|
|
412
|
+
is what makes that visible instead of silent.
|
|
371
413
|
|
|
372
414
|
`baseline.mjs` measures permission pressure with its own glob approximation of
|
|
373
415
|
OpenCode's matcher, not OpenCode's matcher. It handles the cases that matter
|
package/eval/measure-prune.mjs
CHANGED
|
@@ -20,6 +20,31 @@
|
|
|
20
20
|
//
|
|
21
21
|
// The gate is on the CONTROLLED number. The install-wide one stays an
|
|
22
22
|
// observation.
|
|
23
|
+
//
|
|
24
|
+
// TWO MEASURES, both reported, neither hidden, because they answer two
|
|
25
|
+
// different questions:
|
|
26
|
+
//
|
|
27
|
+
// REQUEST - how much did the REQUEST that goes back to the model shrink.
|
|
28
|
+
// Counted the way the pruner's own log counts it: the size of a PART,
|
|
29
|
+
// before the pass and after it. `partSize` in src/prune.ts is
|
|
30
|
+
// `JSON.stringify(part).length`, and `savedChars` in `.space/prune.jsonl`
|
|
31
|
+
// is the delta of that number. The measure below is the SAME expression,
|
|
32
|
+
// so the script and the log ask the same question of the same parts and
|
|
33
|
+
// cannot disagree about what they mean. The delta is the same number
|
|
34
|
+
// too: the parts outside the pruner's window are untouched, so they
|
|
35
|
+
// contribute nothing to it either way.
|
|
36
|
+
//
|
|
37
|
+
// TEXT - how much TEXT was replaced. The text inside the parts, which is
|
|
38
|
+
// what this script used to measure and what it still reports.
|
|
39
|
+
//
|
|
40
|
+
// The two are not the same number twice. The request measure also carries
|
|
41
|
+
// the parts the pruner left alone, and the stub's own metadata is part of
|
|
42
|
+
// the request and is not text. So the request measure is the smaller of
|
|
43
|
+
// the two, and it can be negative where the text measure is positive - a
|
|
44
|
+
// stub that costs more than the output it replaced.
|
|
45
|
+
//
|
|
46
|
+
// The gate is on the REQUEST measure, because that is the claim the kit
|
|
47
|
+
// makes: a smaller request.
|
|
23
48
|
|
|
24
49
|
import { existsSync, mkdtempSync, rmSync } from "node:fs"
|
|
25
50
|
import { homedir, tmpdir } from "node:os"
|
|
@@ -186,20 +211,79 @@ const hook = await HarnessPlugin(
|
|
|
186
211
|
const transform = hook["experimental.chat.messages.transform"]
|
|
187
212
|
|
|
188
213
|
/**
|
|
189
|
-
* The size the
|
|
214
|
+
* The size of a PART, as it sits in the request.
|
|
215
|
+
*
|
|
216
|
+
* THIS IS THE PRUNER'S OWN DEFINITION, EXPRESSION FOR EXPRESSION.
|
|
217
|
+
* `partSize` in src/prune.ts is `JSON.stringify(part).length`, and
|
|
218
|
+
* `savedChars` in `.space/prune.jsonl` is that number's delta. This is the
|
|
219
|
+
* same expression on the same objects, so the script and the log measure
|
|
220
|
+
* the same thing and the two numbers are reconcilable by construction
|
|
221
|
+
* rather than by coincidence.
|
|
222
|
+
*
|
|
223
|
+
* CHARACTERS, not bytes: a UTF-8 character is more than one byte, and an
|
|
224
|
+
* agent's output is not ASCII. The pruner's measure counts characters
|
|
225
|
+
* too, and the byte figures are reported beside them, not instead of
|
|
226
|
+
* them.
|
|
227
|
+
*
|
|
228
|
+
* A part that cannot be serialised counts as zero, exactly as the
|
|
229
|
+
* pruner's own measure does: a measurement that throws is worse than a
|
|
230
|
+
* measurement that counts the part as nothing.
|
|
231
|
+
*
|
|
232
|
+
* @param {any} part
|
|
233
|
+
* @returns {number}
|
|
234
|
+
*/
|
|
235
|
+
function requestPartSize(part) {
|
|
236
|
+
try {
|
|
237
|
+
const json = JSON.stringify(part)
|
|
238
|
+
return typeof json === "string" ? json.length : 0
|
|
239
|
+
} catch {
|
|
240
|
+
return 0
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The same part, in bytes.
|
|
190
246
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* an agent's output is not ASCII. Both numbers are reported.
|
|
247
|
+
* @param {any} part
|
|
248
|
+
* @returns {number}
|
|
194
249
|
*/
|
|
195
|
-
function
|
|
250
|
+
function requestPartBytes(part) {
|
|
251
|
+
try {
|
|
252
|
+
const json = JSON.stringify(part)
|
|
253
|
+
return typeof json === "string" ? Buffer.byteLength(json, "utf8") : 0
|
|
254
|
+
} catch {
|
|
255
|
+
return 0
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The TEXT inside a part - the SECONDARY measure.
|
|
261
|
+
*
|
|
262
|
+
* `part.text` for a text or reasoning part, `part.state.output` for a tool
|
|
263
|
+
* part. This is what the script measured before, and it is still reported,
|
|
264
|
+
* because it answers a different question: how much text was replaced.
|
|
265
|
+
*
|
|
266
|
+
* It is not the claim the kit makes. The text measure cannot see the
|
|
267
|
+
* metadata a stub puts in a part's place, and it cannot see the parts the
|
|
268
|
+
* pruner left alone, so it is the larger of the two numbers and it can be
|
|
269
|
+
* positive where the request measure is negative.
|
|
270
|
+
*
|
|
271
|
+
* @param {any} part
|
|
272
|
+
* @returns {number}
|
|
273
|
+
*/
|
|
274
|
+
function textSize(part) {
|
|
196
275
|
if (typeof part?.text === "string") return part.text.length
|
|
197
276
|
if (typeof part?.state?.output === "string") return part.state.output.length
|
|
198
277
|
return 0
|
|
199
278
|
}
|
|
200
279
|
|
|
201
|
-
/**
|
|
202
|
-
|
|
280
|
+
/**
|
|
281
|
+
* The same text, in bytes.
|
|
282
|
+
*
|
|
283
|
+
* @param {any} part
|
|
284
|
+
* @returns {number}
|
|
285
|
+
*/
|
|
286
|
+
function textBytes(part) {
|
|
203
287
|
if (typeof part?.text === "string") return Buffer.byteLength(part.text, "utf8")
|
|
204
288
|
if (typeof part?.state?.output === "string") {
|
|
205
289
|
return Buffer.byteLength(part.state.output, "utf8")
|
|
@@ -207,10 +291,32 @@ function byteSizeOf(part) {
|
|
|
207
291
|
return 0
|
|
208
292
|
}
|
|
209
293
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
294
|
+
/**
|
|
295
|
+
* The size of a whole message list, by one measure.
|
|
296
|
+
*
|
|
297
|
+
* @param {{parts: any[]}[]} messages
|
|
298
|
+
* @param {(part: any) => number} size
|
|
299
|
+
* @returns {number}
|
|
300
|
+
*/
|
|
301
|
+
function measureAll(messages, size) {
|
|
302
|
+
let total = 0
|
|
303
|
+
for (const m of messages) {
|
|
304
|
+
for (const p of m.parts) total += size(p)
|
|
305
|
+
}
|
|
306
|
+
return total
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// The REQUEST measure: the request that goes back to the model, before the
|
|
310
|
+
// pass and after it, counted the way the pruner's log counts it.
|
|
311
|
+
let reqCharsBefore = 0
|
|
312
|
+
let reqCharsAfter = 0
|
|
313
|
+
let reqBytesBefore = 0
|
|
314
|
+
let reqBytesAfter = 0
|
|
315
|
+
// The TEXT measure: the text inside the parts, before and after.
|
|
316
|
+
let textCharsBefore = 0
|
|
317
|
+
let textCharsAfter = 0
|
|
318
|
+
let textBytesBefore = 0
|
|
319
|
+
let textBytesAfter = 0
|
|
214
320
|
let measured = 0
|
|
215
321
|
let stubbed = 0
|
|
216
322
|
const perSession = []
|
|
@@ -218,35 +324,38 @@ const perSession = []
|
|
|
218
324
|
for (const s of sessions) {
|
|
219
325
|
const messages = sessionMessages(s.id)
|
|
220
326
|
if (!messages.length) continue
|
|
221
|
-
const before = messages.reduce(
|
|
222
|
-
(n, m) => n + m.parts.reduce((k, p) => k + sizeOf(p), 0),
|
|
223
|
-
0,
|
|
224
|
-
)
|
|
225
|
-
const beforeBytes = messages.reduce(
|
|
226
|
-
(n, m) => n + m.parts.reduce((k, p) => k + byteSizeOf(p), 0),
|
|
227
|
-
0,
|
|
228
|
-
)
|
|
229
327
|
// The pruner mutates in place, so the copy has to be the real thing.
|
|
230
|
-
const reasoningBefore = messages.flatMap((m) =>
|
|
231
|
-
m.parts.filter((p) => p.type === "reasoning").map((p) => p.text),
|
|
232
|
-
)
|
|
233
328
|
const copy = messages.map((m) => ({
|
|
234
329
|
info: m.info,
|
|
235
330
|
parts: m.parts.map((p) => (p.state ? { ...p, state: { ...p.state } } : { ...p })),
|
|
236
331
|
}))
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
0,
|
|
332
|
+
// BEFORE is measured on the COPY - on the very objects the pruner is about
|
|
333
|
+
// to see. The copy serialises identically to the part it was made from
|
|
334
|
+
// (same keys, same order, same values), and measuring one set of objects
|
|
335
|
+
// before and after is the point: a "before" taken from a different object
|
|
336
|
+
// than the "after" is two measurements of two things.
|
|
337
|
+
const reqCharsBeforeSession = measureAll(copy, requestPartSize)
|
|
338
|
+
const reqBytesBeforeSession = measureAll(copy, requestPartBytes)
|
|
339
|
+
const textCharsBeforeSession = measureAll(copy, textSize)
|
|
340
|
+
const textBytesBeforeSession = measureAll(copy, textBytes)
|
|
341
|
+
const reasoningBefore = copy.flatMap((m) =>
|
|
342
|
+
m.parts.filter((p) => p.type === "reasoning").map((p) => p.text),
|
|
249
343
|
)
|
|
344
|
+
await transform({}, { messages: copy })
|
|
345
|
+
const reqCharsAfterSession = measureAll(copy, requestPartSize)
|
|
346
|
+
const reqBytesAfterSession = measureAll(copy, requestPartBytes)
|
|
347
|
+
const textCharsAfterSession = measureAll(copy, textSize)
|
|
348
|
+
const textBytesAfterSession = measureAll(copy, textBytes)
|
|
349
|
+
// A session with nothing in it has no percentage to report.
|
|
350
|
+
if (reqCharsBeforeSession <= 0) continue
|
|
351
|
+
reqCharsBefore += reqCharsBeforeSession
|
|
352
|
+
reqCharsAfter += reqCharsAfterSession
|
|
353
|
+
reqBytesBefore += reqBytesBeforeSession
|
|
354
|
+
reqBytesAfter += reqBytesAfterSession
|
|
355
|
+
textCharsBefore += textCharsBeforeSession
|
|
356
|
+
textCharsAfter += textCharsAfterSession
|
|
357
|
+
textBytesBefore += textBytesBeforeSession
|
|
358
|
+
textBytesAfter += textBytesAfterSession
|
|
250
359
|
measured++
|
|
251
360
|
// The pruner marks TOOL parts with `metadata.pruned` and REPLACES reasoning
|
|
252
361
|
// parts, so counting only the marked ones undercounts what was stubbed.
|
|
@@ -263,24 +372,60 @@ for (const s of sessions) {
|
|
|
263
372
|
stubbed += cut
|
|
264
373
|
perSession.push({
|
|
265
374
|
session: s.id,
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
375
|
+
// REQUEST - the size of the request, before and after the pass.
|
|
376
|
+
request_chars_before: reqCharsBeforeSession,
|
|
377
|
+
request_chars_after: reqCharsAfterSession,
|
|
378
|
+
request_bytes_before: reqBytesBeforeSession,
|
|
379
|
+
request_bytes_after: reqBytesAfterSession,
|
|
380
|
+
request_saved_pct: pctOf(reqCharsBeforeSession, reqCharsAfterSession),
|
|
381
|
+
// TEXT - the text inside the parts, before and after the pass.
|
|
382
|
+
text_chars_before: textCharsBeforeSession,
|
|
383
|
+
text_chars_after: textCharsAfterSession,
|
|
384
|
+
text_saved_pct: pctOf(textCharsBeforeSession, textCharsAfterSession),
|
|
269
385
|
parts_stubbed: cut,
|
|
270
386
|
tool_parts_stubbed: toolCut,
|
|
271
387
|
reasoning_parts_stubbed: reasoningCut,
|
|
272
388
|
})
|
|
273
389
|
}
|
|
274
390
|
|
|
275
|
-
|
|
276
|
-
|
|
391
|
+
/**
|
|
392
|
+
* A percentage, to two decimals, or null when there is no percentage to
|
|
393
|
+
* report.
|
|
394
|
+
*
|
|
395
|
+
* Two decimals, not one: an effect of 0.05% is a real effect, and one
|
|
396
|
+
* decimal renders it as "0%" - a number that says "nothing was removed"
|
|
397
|
+
* about a pass that removed seven thousand characters. That is the exact
|
|
398
|
+
* failure this script exists to catch, so it is not repeated in the
|
|
399
|
+
* reporting of it.
|
|
400
|
+
*
|
|
401
|
+
* @param {number} before
|
|
402
|
+
* @param {number} after
|
|
403
|
+
* @returns {number | null}
|
|
404
|
+
*/
|
|
405
|
+
function pctOf(before, after) {
|
|
406
|
+
if (!(before > 0)) return null
|
|
407
|
+
return Math.round((1 - after / before) * 10000) / 100
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** A percentage for printing. */
|
|
411
|
+
function fmtPct(n) {
|
|
412
|
+
return n === null ? "n/a" : `${n}%`
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// The REQUEST measure's percentage. This is the one the gate is computed
|
|
416
|
+
// from, because it is the claim the kit makes: a smaller request.
|
|
417
|
+
const requestSavedPct = pctOf(reqCharsBefore, reqCharsAfter)
|
|
418
|
+
// The TEXT measure's percentage: how much text was replaced.
|
|
419
|
+
const textSavedPct = pctOf(textCharsBefore, textCharsAfter)
|
|
277
420
|
|
|
278
421
|
const failures = []
|
|
279
422
|
if (minSaved !== null) {
|
|
280
423
|
if (measured === 0) {
|
|
281
424
|
failures.push("no session had assistant messages to measure")
|
|
282
|
-
} else if (
|
|
283
|
-
failures.push(
|
|
425
|
+
} else if (requestSavedPct === null || requestSavedPct < minSaved) {
|
|
426
|
+
failures.push(
|
|
427
|
+
`the pruner removed ${requestSavedPct ?? "n/a"}% of the request, expected at least ${minSaved}%`,
|
|
428
|
+
)
|
|
284
429
|
}
|
|
285
430
|
}
|
|
286
431
|
|
|
@@ -294,11 +439,21 @@ process.on("exit", () => {
|
|
|
294
439
|
const report = {
|
|
295
440
|
db: DB,
|
|
296
441
|
sessions_measured: measured,
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
442
|
+
// REQUEST measure - the size of the request, counted the way the pruner's
|
|
443
|
+
// own log counts it (the size of a PART). This is the number the gate is
|
|
444
|
+
// computed from.
|
|
445
|
+
request_chars_before: reqCharsBefore,
|
|
446
|
+
request_chars_after: reqCharsAfter,
|
|
447
|
+
request_bytes_before: reqBytesBefore,
|
|
448
|
+
request_bytes_after: reqBytesAfter,
|
|
449
|
+
request_saved_chars: reqCharsBefore - reqCharsAfter,
|
|
450
|
+
request_saved_pct: requestSavedPct,
|
|
451
|
+
// TEXT measure - the text inside the parts. How much text was replaced.
|
|
452
|
+
text_chars_before: textCharsBefore,
|
|
453
|
+
text_chars_after: textCharsAfter,
|
|
454
|
+
text_bytes_before: textBytesBefore,
|
|
455
|
+
text_bytes_after: textBytesAfter,
|
|
456
|
+
text_saved_pct: textSavedPct,
|
|
302
457
|
parts_stubbed: stubbed,
|
|
303
458
|
per_session: perSession,
|
|
304
459
|
verdict: failures.length ? "FAIL" : "PASS",
|
|
@@ -316,17 +471,36 @@ console.log(
|
|
|
316
471
|
` sessions ${measured} (last ${DAYS}d, at least ${MIN_MESSAGES} assistant messages each)`,
|
|
317
472
|
)
|
|
318
473
|
console.log("")
|
|
319
|
-
console.log(
|
|
320
|
-
console.log(
|
|
321
|
-
console.log(
|
|
322
|
-
console.log(`
|
|
474
|
+
console.log(" REQUEST measure - how much the request that goes back to the model shrank.")
|
|
475
|
+
console.log(" The size of a PART, before and after the pass: the same definition the")
|
|
476
|
+
console.log(" pruner's own log uses (src/prune.ts `partSize`).")
|
|
477
|
+
console.log(` request context before ${reqCharsBefore.toLocaleString("en-US")} characters`)
|
|
478
|
+
console.log(` request context after ${reqCharsAfter.toLocaleString("en-US")} characters`)
|
|
479
|
+
console.log(` request saved ${(reqCharsBefore - reqCharsAfter).toLocaleString("en-US")} characters`)
|
|
480
|
+
console.log(` request in bytes ${reqBytesBefore.toLocaleString("en-US")} -> ${reqBytesAfter.toLocaleString("en-US")}`)
|
|
481
|
+
console.log(` removed ${fmtPct(requestSavedPct)}`)
|
|
482
|
+
console.log(" <- the --min-saved gate is computed from this number, and the")
|
|
483
|
+
console.log(" saved figure is the same number the pruner's log writes as")
|
|
484
|
+
console.log(" savedChars for the same pass.")
|
|
485
|
+
console.log("")
|
|
486
|
+
console.log(" TEXT measure - how much text was replaced. The text inside the parts.")
|
|
487
|
+
console.log(` text before ${textCharsBefore.toLocaleString("en-US")} characters`)
|
|
488
|
+
console.log(` text after ${textCharsAfter.toLocaleString("en-US")} characters`)
|
|
489
|
+
console.log(` text saved ${(textCharsBefore - textCharsAfter).toLocaleString("en-US")} characters`)
|
|
490
|
+
console.log(` text in bytes ${textBytesBefore.toLocaleString("en-US")} -> ${textBytesAfter.toLocaleString("en-US")}`)
|
|
491
|
+
console.log(` removed ${fmtPct(textSavedPct)}`)
|
|
492
|
+
console.log("")
|
|
323
493
|
console.log(` parts stubbed ${stubbed}`)
|
|
324
494
|
if (perSession.length) {
|
|
325
495
|
console.log("")
|
|
326
|
-
const worst = [...perSession]
|
|
327
|
-
|
|
496
|
+
const worst = [...perSession]
|
|
497
|
+
.sort((a, b) => a.request_saved_pct - b.request_saved_pct)
|
|
498
|
+
.slice(0, 5)
|
|
499
|
+
console.log(" the five sessions the pruner helped least, by the request measure:")
|
|
328
500
|
for (const p of worst) {
|
|
329
|
-
console.log(
|
|
501
|
+
console.log(
|
|
502
|
+
` ${fmtPct(p.request_saved_pct)} ${p.request_bytes_before.toLocaleString("en-US")} bytes ${p.session.slice(0, 12)}`,
|
|
503
|
+
)
|
|
330
504
|
}
|
|
331
505
|
}
|
|
332
506
|
if (failures.length) {
|
|
@@ -334,6 +508,6 @@ if (failures.length) {
|
|
|
334
508
|
console.log(` FAIL ${failures.join("; ")}`)
|
|
335
509
|
} else if (minSaved !== null) {
|
|
336
510
|
console.log("")
|
|
337
|
-
console.log(` PASS the pruner removed at least ${minSaved}% of the
|
|
511
|
+
console.log(` PASS the pruner removed at least ${minSaved}% of the request.`)
|
|
338
512
|
}
|
|
339
513
|
process.exit(failures.length ? 1 : 0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-harness-kit",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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",
|