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/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 plus the 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
  *
@@ -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.
@@ -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. On one
327
- install: **80.5% of the context
328
- across 60 real sessions**, 6,608 parts stubbed - the number
329
- `npm run measure:prune` prints.
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
- A `savedChars` of 0 in `prune.jsonl` is not a second, redundant pass. It means
365
- `bytesAfter` did not come out below `bytesBefore` for that turn, which happens
366
- when a stub is no shorter than the output it replaced (the stub's own label
367
- costs more than a short body), or when a `reasoning` part was under the 200
368
- byte floor and so was not shortened at all. These lines are still logged
369
- because a part was stubbed, which is the event worth recording. Savings
370
- aggregate correctly either way, since each line is measured independently.
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
@@ -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 PRUNER measures.
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
- * The pruner counts CHARACTERS, not bytes: `part.text.length`. The report
192
- * labelled that "bytes", which is wrong for anything that is not ASCII - and
193
- * an agent's output is not ASCII. Both numbers are reported.
247
+ * @param {any} part
248
+ * @returns {number}
194
249
  */
195
- function sizeOf(part) {
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
- /** The same text, in bytes. */
202
- function byteSizeOf(part) {
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
- let totalBefore = 0
211
- let totalAfter = 0
212
- let bytesBefore = 0
213
- let bytesAfter = 0
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
- await transform({}, { messages: copy })
238
- const after = copy.reduce(
239
- (n, m) => n + m.parts.reduce((k, p) => k + sizeOf(p), 0),
240
- 0,
241
- )
242
- if (!before) continue
243
- totalBefore += before
244
- totalAfter += after
245
- bytesBefore += beforeBytes
246
- bytesAfter += copy.reduce(
247
- (n, m) => n + m.parts.reduce((k, p) => k + byteSizeOf(p), 0),
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
- bytes_before: before,
267
- bytes_after: after,
268
- saved_pct: Math.round((1 - after / before) * 1000) / 10,
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
- const savedPct =
276
- totalBefore > 0 ? Math.round((1 - totalAfter / totalBefore) * 1000) / 10 : null
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 (savedPct === null || savedPct < minSaved) {
283
- failures.push(`the pruner removed ${savedPct ?? "n/a"}% of the context, expected at least ${minSaved}%`)
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
- characters_before: totalBefore,
298
- characters_after: totalAfter,
299
- bytes_before: bytesBefore,
300
- bytes_after: bytesAfter,
301
- saved_pct: savedPct,
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(` context before ${totalBefore.toLocaleString("en-US")} characters`)
320
- console.log(` context after ${totalAfter.toLocaleString("en-US")} characters`)
321
- console.log(` in bytes ${bytesBefore.toLocaleString("en-US")} -> ${bytesAfter.toLocaleString("en-US")}`)
322
- console.log(` removed ${savedPct === null ? "n/a" : savedPct + "%"}`)
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].sort((a, b) => a.saved_pct - b.saved_pct).slice(0, 5)
327
- console.log(" the five sessions the pruner helped least:")
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(` ${p.saved_pct}% ${p.bytes_before.toLocaleString("en-US")} bytes ${p.session.slice(0, 12)}`)
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 context.`)
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.4",
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",