opencode-harness-kit 0.5.5 → 0.5.7
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 +76 -1
- package/dist/index.js +80 -23
- package/docs/KNOWN-LIMITS.md +35 -4
- package/eval/measure-prune.mjs +227 -62
- package/package.json +1 -1
- package/profiles/fleet-all.json +2 -2
- package/profiles/fleet-safe.json +2 -2
- package/profiles/longcat-2.5-preview-free.json +2 -2
- package/profiles/minimax-m3.1-flash-free.json +2 -2
- package/src/denylist.js +5 -6
- package/src/rules.js +129 -18
- package/src/telemetry.ts +69 -9
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,79 @@ is not happening.
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## [0.5.7] — 2026-10-21
|
|
16
|
+
|
|
17
|
+
Published. The tag is what cut it: `git tag v0.5.7 && git push origin main
|
|
18
|
+
v0.5.7`, and the publish job watches for exactly that.
|
|
19
|
+
|
|
20
|
+
A patch, and a small one: the shipped profiles are refreshed against the
|
|
21
|
+
live catalogue, and a test that pinned the catalogue's old answer stops
|
|
22
|
+
pinning it.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **The profiles are refreshed against the live catalogue.**
|
|
27
|
+
`oc-harness refresh --all` is the kit's own command for this, and it had
|
|
28
|
+
not been run since the 19th. The catalogue now says that
|
|
29
|
+
`minimax-m3.1-flash-free` takes a temperature, and that
|
|
30
|
+
`longcat-2.5-preview-free` does not; the profiles said the opposite in one
|
|
31
|
+
case each. The `verified` date is the day the profile was last checked
|
|
32
|
+
against the catalogue, and leaving it stale after a check makes it a lie.
|
|
33
|
+
|
|
34
|
+
Nothing in the generated config changes: the kit sets no temperature at
|
|
35
|
+
all, so the profile's answer only decides what the header says.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **A test failed on a day when nothing in the kit had changed.** The test
|
|
40
|
+
for "temperature is only emitted for models that support it" asserted
|
|
41
|
+
that one particular model does not support one - a value the catalogue
|
|
42
|
+
owns and changes. The test now asks whether the rule holds: no config the
|
|
43
|
+
kit writes may carry a temperature, and every profile has to say whether
|
|
44
|
+
its model takes one. A test that pins a value the world owns fails for
|
|
45
|
+
the wrong reason.
|
|
46
|
+
|
|
47
|
+
## [0.5.6] — 2026-10-21
|
|
48
|
+
|
|
49
|
+
Published. The tag is what cut it: `git tag v0.5.6 && git push origin main
|
|
50
|
+
v0.5.6`, and the publish job watches for exactly that.
|
|
51
|
+
|
|
52
|
+
A patch, and it is a patch of the same kind as 0.5.5: the three holes the
|
|
53
|
+
guard had left, one measurement that did not measure what it claimed, and
|
|
54
|
+
one measurement that had never produced a number at all.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **`rm /tmp/x -rf` was allowed.** The rule that blocks a recursive delete
|
|
59
|
+
read the first flag and then looked for a path after it, so a flag placed
|
|
60
|
+
after the path was invisible - and GNU rm permutes its options. The rule
|
|
61
|
+
now asks two separate questions: is any flag recursive or force, and is
|
|
62
|
+
there a path.
|
|
63
|
+
|
|
64
|
+
- **`chmod --recursive 777 /` was allowed**, by the rule that blocks
|
|
65
|
+
`chmod 777`: the rule's flag position only accepted short letter flags,
|
|
66
|
+
and a long option put the mode out of reach.
|
|
67
|
+
|
|
68
|
+
- **`curl <url> | grep y | sh` was allowed.** The pipe-to-a-shell rule
|
|
69
|
+
required the download to feed the shell directly, and a filter in between
|
|
70
|
+
hid the shell from it. A filter does not change what the shell runs.
|
|
71
|
+
|
|
72
|
+
- **The pruner measurement and the pruner's own log measured different
|
|
73
|
+
things.** The log reports the REQUEST's size delta - the part before the
|
|
74
|
+
transform against the part after it - and the measurement script counted
|
|
75
|
+
the TEXT inside each part, so the two disagreed and only the log's number
|
|
76
|
+
matched the kit's claim. The script measures the request now, the way the
|
|
77
|
+
pruner does, and reports the text measure beside it under its own name.
|
|
78
|
+
The gate is computed from the request measure.
|
|
79
|
+
|
|
80
|
+
- **The lane record was never written.** Not once, in any session, on any
|
|
81
|
+
machine. The plugin read the lane name from `agent` on the assistant
|
|
82
|
+
message, and opencode's assistant message has no `agent` field - so
|
|
83
|
+
`oc-harness lanes` was reading a file that could not exist, and the
|
|
84
|
+
advice to cut the lanes nobody calls had nothing behind it. The name is
|
|
85
|
+
on the request, and the assistant message names that request's user
|
|
86
|
+
message as its parent, so the two join there.
|
|
87
|
+
|
|
15
88
|
## [0.5.5] — 2026-10-21
|
|
16
89
|
|
|
17
90
|
Published. The tag is what cut it: `git tag v0.5.5 && git push origin main
|
|
@@ -1396,7 +1469,9 @@ silent in the worst way:
|
|
|
1396
1469
|
|
|
1397
1470
|
---
|
|
1398
1471
|
|
|
1399
|
-
[Unreleased]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.
|
|
1472
|
+
[Unreleased]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.7...HEAD
|
|
1473
|
+
[0.5.7]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.6...v0.5.7
|
|
1474
|
+
[0.5.6]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.5...v0.5.6
|
|
1400
1475
|
[0.5.5]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.4...v0.5.5
|
|
1401
1476
|
[0.5.4]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.3...v0.5.4
|
|
1402
1477
|
[0.5.3]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.2...v0.5.3
|
package/dist/index.js
CHANGED
|
@@ -36,12 +36,11 @@ var BASH_DENYLIST = [
|
|
|
36
36
|
// asked about by the config - the config asks about everything it does
|
|
37
37
|
// not name, and asking is the safe direction.
|
|
38
38
|
//
|
|
39
|
-
// A PERMUTED delete (`rm /tmp/x -rf`) is caught by
|
|
40
|
-
// the
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// spelling would still be the one it missed.
|
|
39
|
+
// A PERMUTED delete (`rm /tmp/x -rf`) is caught by the GUARD, which reads
|
|
40
|
+
// the tokens and does not care which order they come in. The glob cannot
|
|
41
|
+
// name it - a glob here could only name one spelling of it, and the
|
|
42
|
+
// spelling would still be the one it missed. So the config asks about it,
|
|
43
|
+
// and a human decides, and the code-level block is the guard's.
|
|
45
44
|
"rm -f *",
|
|
46
45
|
"rm -r *",
|
|
47
46
|
"rm -R *",
|
|
@@ -432,24 +431,61 @@ function runsScript(stage) {
|
|
|
432
431
|
}
|
|
433
432
|
function pipesScriptIntoShell(command) {
|
|
434
433
|
const stages = pipeStages(command);
|
|
435
|
-
for (let i =
|
|
436
|
-
if (!DOWNLOADERS.test(stages[i
|
|
437
|
-
|
|
438
|
-
|
|
434
|
+
for (let i = 0; i < stages.length; i++) {
|
|
435
|
+
if (!DOWNLOADERS.test(stages[i])) continue;
|
|
436
|
+
for (let j = i + 1; j < stages.length; j++) {
|
|
437
|
+
if (!runsScript(stages[j])) continue;
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
439
440
|
}
|
|
440
441
|
return false;
|
|
441
442
|
}
|
|
443
|
+
function isDestructiveRmFlag(token) {
|
|
444
|
+
if (/^--(recursive|force)$/i.test(token)) return true;
|
|
445
|
+
return /^-[a-zA-Z]*[rf]/i.test(token);
|
|
446
|
+
}
|
|
442
447
|
function isRecursiveDelete(text) {
|
|
443
448
|
const tokens = tokenize(text);
|
|
444
449
|
if (tokens.length < 2) return false;
|
|
445
450
|
if (baseName(tokens[0]).toLowerCase() !== "rm") return false;
|
|
451
|
+
let destructive = false;
|
|
452
|
+
let hasPath = false;
|
|
446
453
|
for (let i = 1; i < tokens.length; i++) {
|
|
447
454
|
const token = tokens[i];
|
|
448
|
-
if (
|
|
449
|
-
|
|
450
|
-
|
|
455
|
+
if (token === "--") {
|
|
456
|
+
hasPath = tokens.length > i + 1;
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
if (!token.startsWith("-")) {
|
|
460
|
+
hasPath = true;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (isDestructiveRmFlag(token)) destructive = true;
|
|
451
464
|
}
|
|
452
|
-
return
|
|
465
|
+
return destructive && hasPath;
|
|
466
|
+
}
|
|
467
|
+
var CHMOD_VALUE_OPTIONS = /* @__PURE__ */ new Set(["--reference"]);
|
|
468
|
+
function chmodMode(text) {
|
|
469
|
+
const tokens = tokenize(text);
|
|
470
|
+
if (tokens.length < 2) return null;
|
|
471
|
+
if (baseName(tokens[0]).toLowerCase() !== "chmod") return null;
|
|
472
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
473
|
+
const token = tokens[i];
|
|
474
|
+
if (token === "--") continue;
|
|
475
|
+
if (token.startsWith("--")) {
|
|
476
|
+
if (!token.includes("=") && CHMOD_VALUE_OPTIONS.has(token)) i++;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (token.startsWith("-")) continue;
|
|
480
|
+
if (/^\d+$/.test(token)) return token;
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
function opensPermissions(text) {
|
|
486
|
+
const mode = chmodMode(text);
|
|
487
|
+
if (mode === null) return false;
|
|
488
|
+
return /^0*777$/.test(mode);
|
|
453
489
|
}
|
|
454
490
|
var ASK_GATED = [/^\s*git\s+push\b/];
|
|
455
491
|
var BASH_RULES = [
|
|
@@ -495,10 +531,11 @@ var BASH_RULES = [
|
|
|
495
531
|
{
|
|
496
532
|
name: "chmod 777",
|
|
497
533
|
scope: "segment",
|
|
498
|
-
// The mode
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
|
|
534
|
+
// The mode is read as an OPERAND rather than as a literal, because the
|
|
535
|
+
// flags in front of it can be long. `chmod --recursive 777 /` is the
|
|
536
|
+
// same chmod as `chmod -R 777 /`, and a rule that only knew the short
|
|
537
|
+
// flag cluster let the long spelling through.
|
|
538
|
+
check: (text) => opensPermissions(text),
|
|
502
539
|
why: "opens permissions on everything"
|
|
503
540
|
},
|
|
504
541
|
{
|
|
@@ -1958,7 +1995,26 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
|
|
|
1958
1995
|
const started = Date.now();
|
|
1959
1996
|
const opened = /* @__PURE__ */ new Set();
|
|
1960
1997
|
const closed = /* @__PURE__ */ new Set();
|
|
1998
|
+
const requests = /* @__PURE__ */ new Map();
|
|
1999
|
+
const REQUEST_MEMORY = 500;
|
|
2000
|
+
const rememberRequest = (input) => {
|
|
2001
|
+
const id = input?.message?.id;
|
|
2002
|
+
if (typeof id !== "string") return;
|
|
2003
|
+
const model = input?.model;
|
|
2004
|
+
requests.set(id, {
|
|
2005
|
+
agent: typeof input?.agent === "string" ? input.agent : void 0,
|
|
2006
|
+
model: typeof model?.modelID === "string" ? model.modelID : void 0,
|
|
2007
|
+
provider: typeof model?.providerID === "string" ? model.providerID : void 0
|
|
2008
|
+
});
|
|
2009
|
+
if (requests.size > REQUEST_MEMORY) {
|
|
2010
|
+
const oldest = requests.keys().next();
|
|
2011
|
+
if (!oldest.done) requests.delete(oldest.value);
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
1961
2014
|
return {
|
|
2015
|
+
"chat.params": async (input) => {
|
|
2016
|
+
rememberRequest(input);
|
|
2017
|
+
},
|
|
1962
2018
|
event: async ({ event }) => {
|
|
1963
2019
|
const type = event?.type;
|
|
1964
2020
|
if (typeof type !== "string") return;
|
|
@@ -1967,7 +2023,7 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
|
|
|
1967
2023
|
return;
|
|
1968
2024
|
}
|
|
1969
2025
|
if (type === "message.updated") {
|
|
1970
|
-
const lane = laneOf(event);
|
|
2026
|
+
const lane = laneOf(event, requests);
|
|
1971
2027
|
if (!lane || !lane.id) return;
|
|
1972
2028
|
if (lane.ms !== void 0) {
|
|
1973
2029
|
if (closed.has(lane.id)) return;
|
|
@@ -1991,15 +2047,16 @@ var TelemetryPlugin = async ({ worktree, directory }) => {
|
|
|
1991
2047
|
}
|
|
1992
2048
|
};
|
|
1993
2049
|
};
|
|
1994
|
-
function laneOf(event) {
|
|
2050
|
+
function laneOf(event, requests) {
|
|
1995
2051
|
const e = event;
|
|
1996
2052
|
const info = e?.properties?.info ?? e?.properties;
|
|
1997
2053
|
if (!info || typeof info !== "object") return null;
|
|
1998
2054
|
if (info.role && info.role !== "assistant") return null;
|
|
1999
2055
|
const id = typeof info.id === "string" ? info.id : void 0;
|
|
2000
|
-
const
|
|
2001
|
-
const
|
|
2002
|
-
const
|
|
2056
|
+
const parent = typeof info.parentID === "string" ? requests.get(info.parentID) : void 0;
|
|
2057
|
+
const agent = parent?.agent ?? (typeof info.agent === "string" ? info.agent : void 0);
|
|
2058
|
+
const modelId = parent?.model ?? info.model?.modelID ?? info.modelID;
|
|
2059
|
+
const providerId = parent?.provider ?? info.model?.providerID ?? info.providerID;
|
|
2003
2060
|
const time = info.time ?? {};
|
|
2004
2061
|
const created = typeof time.created === "number" ? time.created : void 0;
|
|
2005
2062
|
const completed = typeof time.completed === "number" ? time.completed : void 0;
|
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`.
|
|
@@ -374,6 +392,19 @@ label and its own metadata. Such a pass is still logged, because a part was
|
|
|
374
392
|
stubbed, which is the event worth recording, and the negative number is the
|
|
375
393
|
honest report of it.
|
|
376
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
|
+
|
|
377
408
|
There is no minimum size under which a part is left alone. This file used to
|
|
378
409
|
claim a 200 byte floor for `reasoning` parts; there was never such a floor in
|
|
379
410
|
the code, and a reasoning part shorter than the stub - about seventy-eight
|
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,29 +211,79 @@ const hook = await HarnessPlugin(
|
|
|
186
211
|
const transform = hook["experimental.chat.messages.transform"]
|
|
187
212
|
|
|
188
213
|
/**
|
|
189
|
-
* The size
|
|
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.
|
|
190
227
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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.
|
|
194
246
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* two different questions and are not the same number twice: this one asks
|
|
198
|
-
* how much TEXT the pruner removed from a stored session, the log asks how
|
|
199
|
-
* much the REQUEST that went out shrank. The stub the pruner leaves behind
|
|
200
|
-
* carries about fifty characters of metadata of its own, so the log's
|
|
201
|
-
* saving is the smaller of the two, and it can even be negative where this
|
|
202
|
-
* one is positive. The gap is stated rather than hidden.
|
|
247
|
+
* @param {any} part
|
|
248
|
+
* @returns {number}
|
|
203
249
|
*/
|
|
204
|
-
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) {
|
|
205
275
|
if (typeof part?.text === "string") return part.text.length
|
|
206
276
|
if (typeof part?.state?.output === "string") return part.state.output.length
|
|
207
277
|
return 0
|
|
208
278
|
}
|
|
209
279
|
|
|
210
|
-
/**
|
|
211
|
-
|
|
280
|
+
/**
|
|
281
|
+
* The same text, in bytes.
|
|
282
|
+
*
|
|
283
|
+
* @param {any} part
|
|
284
|
+
* @returns {number}
|
|
285
|
+
*/
|
|
286
|
+
function textBytes(part) {
|
|
212
287
|
if (typeof part?.text === "string") return Buffer.byteLength(part.text, "utf8")
|
|
213
288
|
if (typeof part?.state?.output === "string") {
|
|
214
289
|
return Buffer.byteLength(part.state.output, "utf8")
|
|
@@ -216,10 +291,32 @@ function byteSizeOf(part) {
|
|
|
216
291
|
return 0
|
|
217
292
|
}
|
|
218
293
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
223
320
|
let measured = 0
|
|
224
321
|
let stubbed = 0
|
|
225
322
|
const perSession = []
|
|
@@ -227,35 +324,38 @@ const perSession = []
|
|
|
227
324
|
for (const s of sessions) {
|
|
228
325
|
const messages = sessionMessages(s.id)
|
|
229
326
|
if (!messages.length) continue
|
|
230
|
-
const before = messages.reduce(
|
|
231
|
-
(n, m) => n + m.parts.reduce((k, p) => k + sizeOf(p), 0),
|
|
232
|
-
0,
|
|
233
|
-
)
|
|
234
|
-
const beforeBytes = messages.reduce(
|
|
235
|
-
(n, m) => n + m.parts.reduce((k, p) => k + byteSizeOf(p), 0),
|
|
236
|
-
0,
|
|
237
|
-
)
|
|
238
327
|
// The pruner mutates in place, so the copy has to be the real thing.
|
|
239
|
-
const reasoningBefore = messages.flatMap((m) =>
|
|
240
|
-
m.parts.filter((p) => p.type === "reasoning").map((p) => p.text),
|
|
241
|
-
)
|
|
242
328
|
const copy = messages.map((m) => ({
|
|
243
329
|
info: m.info,
|
|
244
330
|
parts: m.parts.map((p) => (p.state ? { ...p, state: { ...p.state } } : { ...p })),
|
|
245
331
|
}))
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
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),
|
|
258
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
|
|
259
359
|
measured++
|
|
260
360
|
// The pruner marks TOOL parts with `metadata.pruned` and REPLACES reasoning
|
|
261
361
|
// parts, so counting only the marked ones undercounts what was stubbed.
|
|
@@ -272,24 +372,60 @@ for (const s of sessions) {
|
|
|
272
372
|
stubbed += cut
|
|
273
373
|
perSession.push({
|
|
274
374
|
session: s.id,
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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),
|
|
278
385
|
parts_stubbed: cut,
|
|
279
386
|
tool_parts_stubbed: toolCut,
|
|
280
387
|
reasoning_parts_stubbed: reasoningCut,
|
|
281
388
|
})
|
|
282
389
|
}
|
|
283
390
|
|
|
284
|
-
|
|
285
|
-
|
|
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)
|
|
286
420
|
|
|
287
421
|
const failures = []
|
|
288
422
|
if (minSaved !== null) {
|
|
289
423
|
if (measured === 0) {
|
|
290
424
|
failures.push("no session had assistant messages to measure")
|
|
291
|
-
} else if (
|
|
292
|
-
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
|
+
)
|
|
293
429
|
}
|
|
294
430
|
}
|
|
295
431
|
|
|
@@ -303,11 +439,21 @@ process.on("exit", () => {
|
|
|
303
439
|
const report = {
|
|
304
440
|
db: DB,
|
|
305
441
|
sessions_measured: measured,
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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,
|
|
311
457
|
parts_stubbed: stubbed,
|
|
312
458
|
per_session: perSession,
|
|
313
459
|
verdict: failures.length ? "FAIL" : "PASS",
|
|
@@ -325,17 +471,36 @@ console.log(
|
|
|
325
471
|
` sessions ${measured} (last ${DAYS}d, at least ${MIN_MESSAGES} assistant messages each)`,
|
|
326
472
|
)
|
|
327
473
|
console.log("")
|
|
328
|
-
console.log(
|
|
329
|
-
console.log(
|
|
330
|
-
console.log(
|
|
331
|
-
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("")
|
|
332
493
|
console.log(` parts stubbed ${stubbed}`)
|
|
333
494
|
if (perSession.length) {
|
|
334
495
|
console.log("")
|
|
335
|
-
const worst = [...perSession]
|
|
336
|
-
|
|
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:")
|
|
337
500
|
for (const p of worst) {
|
|
338
|
-
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
|
+
)
|
|
339
504
|
}
|
|
340
505
|
}
|
|
341
506
|
if (failures.length) {
|
|
@@ -343,6 +508,6 @@ if (failures.length) {
|
|
|
343
508
|
console.log(` FAIL ${failures.join("; ")}`)
|
|
344
509
|
} else if (minSaved !== null) {
|
|
345
510
|
console.log("")
|
|
346
|
-
console.log(` PASS the pruner removed at least ${minSaved}% of the
|
|
511
|
+
console.log(` PASS the pruner removed at least ${minSaved}% of the request.`)
|
|
347
512
|
}
|
|
348
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.7",
|
|
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",
|
package/profiles/fleet-all.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"provider": "opencode",
|
|
6
6
|
"model": "minimax-m3.1-flash-free",
|
|
7
7
|
"family": null,
|
|
8
|
-
"verified": "2026-10-
|
|
8
|
+
"verified": "2026-10-21",
|
|
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.",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"supports": {
|
|
17
17
|
"reasoning": true,
|
|
18
|
-
"temperature":
|
|
18
|
+
"temperature": true,
|
|
19
19
|
"toolCall": true,
|
|
20
20
|
"structuredOutput": false,
|
|
21
21
|
"attachment": true
|
package/profiles/fleet-safe.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"provider": "opencode",
|
|
6
6
|
"model": "minimax-m3.1-flash-free",
|
|
7
7
|
"family": null,
|
|
8
|
-
"verified": "2026-10-
|
|
8
|
+
"verified": "2026-10-21",
|
|
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.",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"supports": {
|
|
17
17
|
"reasoning": true,
|
|
18
|
-
"temperature":
|
|
18
|
+
"temperature": true,
|
|
19
19
|
"toolCall": true,
|
|
20
20
|
"structuredOutput": false,
|
|
21
21
|
"attachment": true
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"provider": "opencode",
|
|
6
6
|
"model": "longcat-2.5-preview-free",
|
|
7
7
|
"family": "longcat",
|
|
8
|
-
"verified": "2026-10-
|
|
8
|
+
"verified": "2026-10-21",
|
|
9
9
|
"retention": "zero-retention",
|
|
10
10
|
"notes": "Meituan. 1M context with image input, and a reasoning toggle rather than a tier, so no variant is written. It read a screenshot correctly in a live call, which is what makes it the zero-retention fallback for the vision lane. Measured at 9.4-13.2s. A preview endpoint: it can be withdrawn without a rename.",
|
|
11
11
|
"limits": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"supports": {
|
|
16
16
|
"reasoning": true,
|
|
17
|
-
"temperature":
|
|
17
|
+
"temperature": false,
|
|
18
18
|
"toolCall": true,
|
|
19
19
|
"structuredOutput": false,
|
|
20
20
|
"attachment": true
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"provider": "opencode",
|
|
6
6
|
"model": "minimax-m3.1-flash-free",
|
|
7
7
|
"family": "minimax",
|
|
8
|
-
"verified": "2026-10-
|
|
8
|
+
"verified": "2026-10-21",
|
|
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": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"supports": {
|
|
16
16
|
"reasoning": true,
|
|
17
|
-
"temperature":
|
|
17
|
+
"temperature": true,
|
|
18
18
|
"toolCall": true,
|
|
19
19
|
"structuredOutput": false,
|
|
20
20
|
"attachment": true
|
package/src/denylist.js
CHANGED
|
@@ -39,12 +39,11 @@ export const BASH_DENYLIST = [
|
|
|
39
39
|
// asked about by the config - the config asks about everything it does
|
|
40
40
|
// not name, and asking is the safe direction.
|
|
41
41
|
//
|
|
42
|
-
// A PERMUTED delete (`rm /tmp/x -rf`) is caught by
|
|
43
|
-
// the
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// spelling would still be the one it missed.
|
|
42
|
+
// A PERMUTED delete (`rm /tmp/x -rf`) is caught by the GUARD, which reads
|
|
43
|
+
// the tokens and does not care which order they come in. The glob cannot
|
|
44
|
+
// name it - a glob here could only name one spelling of it, and the
|
|
45
|
+
// spelling would still be the one it missed. So the config asks about it,
|
|
46
|
+
// and a human decides, and the code-level block is the guard's.
|
|
48
47
|
"rm -f *",
|
|
49
48
|
"rm -r *",
|
|
50
49
|
"rm -R *",
|
package/src/rules.js
CHANGED
|
@@ -615,26 +615,63 @@ function runsScript(stage) {
|
|
|
615
615
|
* still a pipe into a shell, because the `;` ends the word and not the
|
|
616
616
|
* pipe.
|
|
617
617
|
*
|
|
618
|
+
* The shell does not have to be the stage right after the download. A filter
|
|
619
|
+
* in between (`curl x | grep y | sh`) does not change what the shell RUNS -
|
|
620
|
+
* it changes only what the agent sees on the way there - so the chain is
|
|
621
|
+
* the same download-and-execute. The rule used to look at the stage after
|
|
622
|
+
* the download only, so a filter in the middle hid the shell from it.
|
|
623
|
+
*
|
|
624
|
+
* The order still matters: the shell has to come AFTER the download.
|
|
625
|
+
* `sh | grep y` runs a shell over a pipe it did not fetch, and there is
|
|
626
|
+
* nothing downloaded for it to execute.
|
|
627
|
+
*
|
|
618
628
|
* @param {string} command
|
|
619
629
|
* @returns {boolean}
|
|
620
630
|
*/
|
|
621
631
|
function pipesScriptIntoShell(command) {
|
|
622
632
|
const stages = pipeStages(command)
|
|
623
|
-
for (let i =
|
|
624
|
-
if (!DOWNLOADERS.test(stages[i
|
|
625
|
-
|
|
626
|
-
|
|
633
|
+
for (let i = 0; i < stages.length; i++) {
|
|
634
|
+
if (!DOWNLOADERS.test(stages[i])) continue
|
|
635
|
+
for (let j = i + 1; j < stages.length; j++) {
|
|
636
|
+
if (!runsScript(stages[j])) continue
|
|
637
|
+
return true
|
|
638
|
+
}
|
|
627
639
|
}
|
|
628
640
|
return false
|
|
629
641
|
}
|
|
630
642
|
|
|
643
|
+
/**
|
|
644
|
+
* Whether a token is a flag that makes an rm destructive.
|
|
645
|
+
*
|
|
646
|
+
* The short form is a CLUSTER of letters, so `-rf`, `-fr` and `-R` all name
|
|
647
|
+
* a recursive delete and `-f` names a forced one. The long form is GNU's
|
|
648
|
+
* own name for the same two flags.
|
|
649
|
+
*
|
|
650
|
+
* @param {string} token
|
|
651
|
+
* @returns {boolean}
|
|
652
|
+
*/
|
|
653
|
+
function isDestructiveRmFlag(token) {
|
|
654
|
+
if (/^--(recursive|force)$/i.test(token)) return true
|
|
655
|
+
return /^-[a-zA-Z]*[rf]/i.test(token)
|
|
656
|
+
}
|
|
657
|
+
|
|
631
658
|
/**
|
|
632
659
|
* Whether a command is an rm with a flag that makes it destructive.
|
|
633
660
|
*
|
|
634
|
-
* The
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
661
|
+
* The flags and the paths are read SEPARATELY, because GNU rm PERMUTES its
|
|
662
|
+
* options: the flags can come after the path, and `rm /tmp/x -rf` is the
|
|
663
|
+
* same delete as `rm -rf /tmp/x`. The rule used to return at the FIRST flag
|
|
664
|
+
* token and then look for a path AFTER it, so a flag that came after the
|
|
665
|
+
* path was invisible and the delete the rule exists to stop went through.
|
|
666
|
+
*
|
|
667
|
+
* So the question is not "is the first flag recursive" but "is ANY of the
|
|
668
|
+
* flags recursive or force, AND is there a path" - a delete of one named
|
|
669
|
+
* file (`rm file.txt`, `rm -i notes.md`) is not a recursive delete, and a
|
|
670
|
+
* command with no path at all (`rm`, `rm -rf`) is not a delete.
|
|
671
|
+
*
|
|
672
|
+
* `--` ends the options, so every word after it is a path whatever it looks
|
|
673
|
+
* like. That makes `rm -- -rf` a delete of a file NAMED `-rf`, and not a
|
|
674
|
+
* recursive delete.
|
|
638
675
|
*
|
|
639
676
|
* @param {string} text the segment, with its prefixes stripped
|
|
640
677
|
* @returns {boolean}
|
|
@@ -643,15 +680,88 @@ function isRecursiveDelete(text) {
|
|
|
643
680
|
const tokens = tokenize(text)
|
|
644
681
|
if (tokens.length < 2) return false
|
|
645
682
|
if (baseName(tokens[0]).toLowerCase() !== "rm") return false
|
|
683
|
+
let destructive = false
|
|
684
|
+
let hasPath = false
|
|
646
685
|
for (let i = 1; i < tokens.length; i++) {
|
|
647
686
|
const token = tokens[i]
|
|
648
|
-
if (
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
687
|
+
if (token === "--") {
|
|
688
|
+
// Everything after `--` is a path, so the words that follow it are
|
|
689
|
+
// the path this rm destroys.
|
|
690
|
+
hasPath = tokens.length > i + 1
|
|
691
|
+
break
|
|
692
|
+
}
|
|
693
|
+
if (!token.startsWith("-")) {
|
|
694
|
+
hasPath = true
|
|
695
|
+
continue
|
|
696
|
+
}
|
|
697
|
+
if (isDestructiveRmFlag(token)) destructive = true
|
|
653
698
|
}
|
|
654
|
-
return
|
|
699
|
+
return destructive && hasPath
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The chmod options that carry their value in the NEXT word.
|
|
704
|
+
*
|
|
705
|
+
* `chmod --reference FILE` takes the mode from another file, so the word
|
|
706
|
+
* after it is not the mode. A scanner that read that word as the mode would
|
|
707
|
+
* see `chmod --reference 777 file` as "chmod 777", and that command is a
|
|
708
|
+
* file named 777 rather than a mode.
|
|
709
|
+
*/
|
|
710
|
+
const CHMOD_VALUE_OPTIONS = new Set(["--reference"])
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* The mode a chmod command sets, or null when it sets none.
|
|
714
|
+
*
|
|
715
|
+
* The mode is the first numeric OPERAND, and every option in front of it is
|
|
716
|
+
* skipped - INCLUDING the long forms. The rule used to accept only a short
|
|
717
|
+
* letter cluster in the flag position, so GNU's own `--recursive` put the
|
|
718
|
+
* mode out of the rule's reach: `chmod --recursive 777 /` was allowed while
|
|
719
|
+
* `chmod -R 777 /` was blocked, and the mode is the same mode.
|
|
720
|
+
*
|
|
721
|
+
* A symbolic mode (`a=rwx`) is not a numeric one, so it is not a mode this
|
|
722
|
+
* function returns. That is a policy gap and not a spelling one, and it is
|
|
723
|
+
* left alone on purpose.
|
|
724
|
+
*
|
|
725
|
+
* @param {string} text the segment, with its prefixes stripped
|
|
726
|
+
* @returns {string | null} the mode, as it was written
|
|
727
|
+
*/
|
|
728
|
+
function chmodMode(text) {
|
|
729
|
+
const tokens = tokenize(text)
|
|
730
|
+
if (tokens.length < 2) return null
|
|
731
|
+
if (baseName(tokens[0]).toLowerCase() !== "chmod") return null
|
|
732
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
733
|
+
const token = tokens[i]
|
|
734
|
+
if (token === "--") continue
|
|
735
|
+
if (token.startsWith("--")) {
|
|
736
|
+
// `--flag=value` carries its own value, and a boolean long option
|
|
737
|
+
// carries none. Only the option that takes the next word swallows
|
|
738
|
+
// it, and that word is then not the mode.
|
|
739
|
+
if (!token.includes("=") && CHMOD_VALUE_OPTIONS.has(token)) i++
|
|
740
|
+
continue
|
|
741
|
+
}
|
|
742
|
+
if (token.startsWith("-")) continue
|
|
743
|
+
// The first numeric operand is the mode; the words after it are the
|
|
744
|
+
// files the mode is applied to.
|
|
745
|
+
if (/^\d+$/.test(token)) return token
|
|
746
|
+
return null
|
|
747
|
+
}
|
|
748
|
+
return null
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Whether a chmod command sets the mode that opens permissions to everyone.
|
|
753
|
+
*
|
|
754
|
+
* The mode may be written with leading zeros: `0777` and `777` are the same
|
|
755
|
+
* mode, and a rule that required the bare digits let one spelling of it
|
|
756
|
+
* through.
|
|
757
|
+
*
|
|
758
|
+
* @param {string} text the segment, with its prefixes stripped
|
|
759
|
+
* @returns {boolean}
|
|
760
|
+
*/
|
|
761
|
+
function opensPermissions(text) {
|
|
762
|
+
const mode = chmodMode(text)
|
|
763
|
+
if (mode === null) return false
|
|
764
|
+
return /^0*777$/.test(mode)
|
|
655
765
|
}
|
|
656
766
|
|
|
657
767
|
/**
|
|
@@ -711,10 +821,11 @@ export const BASH_RULES = [
|
|
|
711
821
|
{
|
|
712
822
|
name: "chmod 777",
|
|
713
823
|
scope: "segment",
|
|
714
|
-
// The mode
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
|
|
824
|
+
// The mode is read as an OPERAND rather than as a literal, because the
|
|
825
|
+
// flags in front of it can be long. `chmod --recursive 777 /` is the
|
|
826
|
+
// same chmod as `chmod -R 777 /`, and a rule that only knew the short
|
|
827
|
+
// flag cluster let the long spelling through.
|
|
828
|
+
check: (text) => opensPermissions(text),
|
|
718
829
|
why: "opens permissions on everything",
|
|
719
830
|
},
|
|
720
831
|
{
|
package/src/telemetry.ts
CHANGED
|
@@ -27,10 +27,24 @@
|
|
|
27
27
|
// one with a duration, so "how long does this lane take" and "was this lane
|
|
28
28
|
// called at all" are answered from the same file without guessing.
|
|
29
29
|
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
30
|
+
// WHERE THE LANE NAME COMES FROM
|
|
31
|
+
//
|
|
32
|
+
// Not from the message. opencode's AssistantMessage has no `agent` field - it
|
|
33
|
+
// has id, role, model, tokens, time and parentID - and the plugin read the
|
|
34
|
+
// agent from exactly that missing field. The lane record was therefore never
|
|
35
|
+
// written: not once, in any session, on any machine. The measurement that
|
|
36
|
+
// decides whether the fleet stays at ten lanes was reading a file that could
|
|
37
|
+
// not exist.
|
|
38
|
+
//
|
|
39
|
+
// The agent name is on the REQUEST. `chat.params` carries the agent and the
|
|
40
|
+
// model of the call that is about to be made, and the user message that call
|
|
41
|
+
// belongs to; the assistant message names that user message as its parent, so
|
|
42
|
+
// the two join.
|
|
43
|
+
//
|
|
44
|
+
// The `chat.message` hook is not used for this. Measured on OpenCode 1.19 its
|
|
45
|
+
// `agent` field is undefined, while `chat.params` declares it as required -
|
|
46
|
+
// and a second counter that cannot be correlated with the first would
|
|
47
|
+
// double-count every call.
|
|
34
48
|
//
|
|
35
49
|
// WHAT A DENIAL LOOKS LIKE HERE
|
|
36
50
|
//
|
|
@@ -69,7 +83,47 @@ export const TelemetryPlugin: Plugin = async ({ worktree, directory }) => {
|
|
|
69
83
|
const opened = new Set<string>()
|
|
70
84
|
const closed = new Set<string>()
|
|
71
85
|
|
|
86
|
+
// The lane a request ran on, and the model it ran on, keyed by the id of
|
|
87
|
+
// the user message the request was made from.
|
|
88
|
+
//
|
|
89
|
+
// The assistant message names that user message as its parent, so this is
|
|
90
|
+
// how a call is attributed to a lane.
|
|
91
|
+
//
|
|
92
|
+
// The map is bounded: a long session makes a lot of requests, and the map
|
|
93
|
+
// is only a bridge between a request and the assistant message that
|
|
94
|
+
// answers it. The oldest entries are dropped once it grows past what a
|
|
95
|
+
// session needs, so the plugin cannot grow without limit.
|
|
96
|
+
const requests = new Map<string, { agent?: string; model?: string; provider?: string }>()
|
|
97
|
+
const REQUEST_MEMORY = 500
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remember which lane a request was made from.
|
|
101
|
+
*
|
|
102
|
+
* @param {any} input the `chat.params` input
|
|
103
|
+
*/
|
|
104
|
+
const rememberRequest = (input: any) => {
|
|
105
|
+
const id = input?.message?.id
|
|
106
|
+
if (typeof id !== "string") return
|
|
107
|
+
const model = input?.model
|
|
108
|
+
requests.set(id, {
|
|
109
|
+
agent: typeof input?.agent === "string" ? input.agent : undefined,
|
|
110
|
+
model: typeof model?.modelID === "string" ? model.modelID : undefined,
|
|
111
|
+
provider: typeof model?.providerID === "string" ? model.providerID : undefined,
|
|
112
|
+
})
|
|
113
|
+
if (requests.size > REQUEST_MEMORY) {
|
|
114
|
+
// The oldest first: a Map keeps its insertion order.
|
|
115
|
+
const oldest = requests.keys().next()
|
|
116
|
+
if (!oldest.done) requests.delete(oldest.value)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
72
120
|
return {
|
|
121
|
+
"chat.params": async (input: any) => {
|
|
122
|
+
// The lane and the model of the call that is about to be made. This is
|
|
123
|
+
// the only place opencode says which agent is running.
|
|
124
|
+
rememberRequest(input)
|
|
125
|
+
},
|
|
126
|
+
|
|
73
127
|
event: async ({ event }: any) => {
|
|
74
128
|
// Events are provider-specific and opencode may add shapes we have never
|
|
75
129
|
// seen. Anything unrecognised is ignored rather than trusted.
|
|
@@ -91,7 +145,7 @@ export const TelemetryPlugin: Plugin = async ({ worktree, directory }) => {
|
|
|
91
145
|
// on which model, and how long it took. That record is what makes "cut
|
|
92
146
|
// the lanes nobody uses" a measurement instead of a guess.
|
|
93
147
|
if (type === "message.updated") {
|
|
94
|
-
const lane = laneOf(event)
|
|
148
|
+
const lane = laneOf(event, requests)
|
|
95
149
|
if (!lane || !lane.id) return
|
|
96
150
|
if (lane.ms !== undefined) {
|
|
97
151
|
// The completed event. Written once, and only if the open record
|
|
@@ -142,7 +196,10 @@ export const TelemetryPlugin: Plugin = async ({ worktree, directory }) => {
|
|
|
142
196
|
*
|
|
143
197
|
* The message id is what makes the record countable: one message is one call.
|
|
144
198
|
*/
|
|
145
|
-
function laneOf(
|
|
199
|
+
function laneOf(
|
|
200
|
+
event: unknown,
|
|
201
|
+
requests: Map<string, { agent?: string; model?: string; provider?: string }>,
|
|
202
|
+
): {
|
|
146
203
|
id?: string
|
|
147
204
|
agent?: string
|
|
148
205
|
model?: string
|
|
@@ -159,9 +216,12 @@ function laneOf(event: unknown): {
|
|
|
159
216
|
if (info.role && info.role !== "assistant") return null
|
|
160
217
|
|
|
161
218
|
const id = typeof info.id === "string" ? info.id : undefined
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
219
|
+
// The request this message answers. The message does not name the agent;
|
|
220
|
+
// the request does, and the parent id is the join.
|
|
221
|
+
const parent = typeof info.parentID === "string" ? requests.get(info.parentID) : undefined
|
|
222
|
+
const agent = parent?.agent ?? (typeof info.agent === "string" ? info.agent : undefined)
|
|
223
|
+
const modelId = parent?.model ?? info.model?.modelID ?? info.modelID
|
|
224
|
+
const providerId = parent?.provider ?? info.model?.providerID ?? info.providerID
|
|
165
225
|
const time = info.time ?? {}
|
|
166
226
|
const created = typeof time.created === "number" ? time.created : undefined
|
|
167
227
|
const completed = typeof time.completed === "number" ? time.completed : undefined
|