opencode-harness-kit 0.4.2 → 0.4.4
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 +544 -19
- package/CONTRIBUTING.md +242 -0
- package/GLOBAL.md +34 -5
- package/MIGRATION.md +151 -0
- package/README.md +294 -37
- package/SECURITY.md +30 -0
- package/bin/baseline.mjs +15 -7
- package/bin/git-hooks/pre-commit +41 -9
- package/bin/oc-harness.mjs +1265 -82
- package/dist/config.d.ts +127 -0
- package/dist/denylist.d.ts +33 -0
- package/dist/effort.d.ts +64 -0
- package/dist/guard.d.ts +23 -0
- package/dist/health.d.ts +43 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +532 -149
- package/dist/plugin.d.ts +15 -0
- package/dist/profile.d.ts +399 -0
- package/dist/project.d.ts +8 -0
- package/dist/prune.d.ts +6 -0
- package/dist/router.d.ts +3 -0
- package/dist/rules.d.ts +109 -0
- package/dist/safe.d.ts +7 -0
- package/dist/telemetry-store.d.ts +12 -0
- package/dist/telemetry.d.ts +3 -0
- package/docs/EXIT-CODES.md +71 -0
- package/docs/KNOWN-LIMITS.md +45 -21
- package/docs/RESEARCH.md +9 -4
- package/docs/ROUTING.md +53 -8
- package/eval/RESULTS.md +443 -0
- package/eval/ab.mjs +743 -0
- package/eval/attribute.mjs +170 -0
- package/eval/flags.mjs +88 -0
- package/eval/measure-install.mjs +323 -0
- package/eval/measure-prune.mjs +339 -0
- package/eval/metrics.js +79 -0
- package/eval/premises.mjs +200 -0
- package/eval/prepare.mjs +233 -0
- package/eval/run.mjs +376 -0
- package/eval/tasks-self.json +81 -0
- package/eval/tasks-ts.json +50 -0
- package/eval/tasks.json +83 -0
- package/eval/thresholds.mjs +134 -0
- package/eval/verify.mjs +287 -0
- package/package.json +44 -17
- package/profiles/fleet-all.json +6 -1
- package/profiles/fleet-safe.json +7 -3
- package/profiles/schema.json +182 -51
- package/prompts/build.md +4 -2
- package/skills/space-build/SKILL.md +4 -2
- package/skills/space-finish/SKILL.md +6 -2
- package/skills/space-plan/reference/task-template.md +13 -5
- package/src/config.js +182 -32
- package/src/denylist.js +95 -0
- package/src/driver/run.mjs +81 -16
- package/src/effort.js +15 -4
- package/src/guard.ts +165 -13
- package/src/health.js +80 -0
- package/src/index.ts +15 -4
- package/src/lane-usage.js +72 -18
- package/src/plugin-type-check.ts +38 -0
- package/src/plugin.ts +39 -0
- package/src/profile.js +225 -26
- package/src/project.js +47 -0
- package/src/prune.ts +110 -31
- package/src/router.ts +32 -63
- package/src/rules.js +99 -7
- package/src/safe.ts +27 -0
- package/src/sqlite.js +144 -0
- package/src/tasks.js +112 -29
- package/src/telemetry-store.ts +62 -0
- package/src/telemetry.ts +60 -57
- package/src/trust.js +70 -9
package/src/guard.ts
CHANGED
|
@@ -6,10 +6,82 @@
|
|
|
6
6
|
// Fail-closed: if the guard cannot decide, it blocks. A guard that quietly
|
|
7
7
|
// passes when broken is worse than none, because the user stops watching it.
|
|
8
8
|
|
|
9
|
-
import type { Plugin } from "
|
|
10
|
-
import {
|
|
9
|
+
import type { Plugin } from "./plugin.js"
|
|
10
|
+
import { homedir, tmpdir } from "node:os"
|
|
11
|
+
import { join, resolve } from "node:path"
|
|
12
|
+
import { checkBash, checkDenied, checkWrite, insideRoot } from "./rules.js"
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
import { write } from "./telemetry-store.js"
|
|
15
|
+
import { projectRoot } from "./project.js"
|
|
16
|
+
|
|
17
|
+
export const MARKER = "[opencode-harness-kit] blocked"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The tools that write to a file, and where the path to write lives in each.
|
|
21
|
+
*
|
|
22
|
+
* OpenCode 1.19 has `write` and `edit` (both take `filePath`) and
|
|
23
|
+
* `apply_patch` (which takes a whole patch as `patchText`). On a GPT-5-class
|
|
24
|
+
* model `write` and `edit` are REMOVED and replaced by `apply_patch`, so a
|
|
25
|
+
* guard that only knew about `write` and `edit` enforced nothing at all on
|
|
26
|
+
* those models - the tool the agent actually had was not one it looked at.
|
|
27
|
+
*
|
|
28
|
+
* `patch` and `changes[].filePath` are not OpenCode 1.19 shapes. They are
|
|
29
|
+
* kept because a third-party plugin may register such a tool, and a tool the
|
|
30
|
+
* guard cannot read is a tool the guard cannot protect.
|
|
31
|
+
*/
|
|
32
|
+
const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"])
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The files a patch touches.
|
|
36
|
+
*
|
|
37
|
+
* `apply_patch` sends a patch, not a path, so the paths have to be read out
|
|
38
|
+
* of it. Two formats are in use: opencode's own `*** Update File: path`
|
|
39
|
+
* markers, and a plain unified diff with `+++ b/path` headers.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {string[]}
|
|
43
|
+
*/
|
|
44
|
+
export function pathsInPatch(text: string): string[] {
|
|
45
|
+
const out: string[] = []
|
|
46
|
+
const push = (p: string) => {
|
|
47
|
+
const v = p.trim()
|
|
48
|
+
if (!v) return
|
|
49
|
+
if (v === "/dev/null") return
|
|
50
|
+
if (!out.includes(v)) out.push(v)
|
|
51
|
+
}
|
|
52
|
+
for (const m of text.matchAll(/^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/gm)) {
|
|
53
|
+
push(m[1])
|
|
54
|
+
}
|
|
55
|
+
for (const m of text.matchAll(/^\+\+\+\s+(?:b\/)?(.+)$/gm)) {
|
|
56
|
+
push(m[1])
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The files a write tool is about to touch.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} tool
|
|
65
|
+
* @param {any} args
|
|
66
|
+
* @returns {string[]} empty when the arguments name no file
|
|
67
|
+
*/
|
|
68
|
+
export function writeTargets(tool: string, args: any): string[] {
|
|
69
|
+
if (!args || typeof args !== "object") return []
|
|
70
|
+
if (tool === "apply_patch") {
|
|
71
|
+
return pathsInPatch(String(args.patchText ?? args.patch ?? ""))
|
|
72
|
+
}
|
|
73
|
+
const out: string[] = []
|
|
74
|
+
const one = (v: any) => {
|
|
75
|
+
if (typeof v === "string" && v) out.push(v)
|
|
76
|
+
}
|
|
77
|
+
one(args.filePath)
|
|
78
|
+
one(args.path)
|
|
79
|
+
for (const c of Array.isArray(args.changes) ? args.changes : []) {
|
|
80
|
+
one(c?.filePath)
|
|
81
|
+
one(c?.path)
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
13
85
|
|
|
14
86
|
function deny(why: string, detail: string): never {
|
|
15
87
|
// Throwing is the only way to stop a tool call from a hook.
|
|
@@ -19,33 +91,113 @@ function deny(why: string, detail: string): never {
|
|
|
19
91
|
`safe, run it yourself in a terminal.`,
|
|
20
92
|
)
|
|
21
93
|
}
|
|
22
|
-
|
|
23
94
|
function truncate(s: string, n = 200): string {
|
|
24
95
|
const one = s.replace(/\s+/g, " ").trim()
|
|
25
96
|
return one.length > n ? `${one.slice(0, n)}...` : one
|
|
26
97
|
}
|
|
27
98
|
|
|
28
|
-
export const GuardPlugin: Plugin = async ({ worktree }) => {
|
|
99
|
+
export const GuardPlugin: Plugin = async ({ worktree, directory }) => {
|
|
100
|
+
// The project root, not the worktree: the worktree is the filesystem root
|
|
101
|
+
// in a project that is not a git repository, so the scope check was against
|
|
102
|
+
// "/" - which contains every path - and the denial log went nowhere.
|
|
103
|
+
const project = projectRoot(directory, worktree)
|
|
104
|
+
// Where a denial is recorded.
|
|
105
|
+
//
|
|
106
|
+
// It has to be recorded HERE. A denial throws in `tool.execute.before`, and
|
|
107
|
+
// opencode calls `tool.execute.after` only after the tool has run - so a
|
|
108
|
+
// blocked command produces no `after` event at all, and the telemetry
|
|
109
|
+
// plugin never sees it. The file's own header promised to record "tool
|
|
110
|
+
// denials (to see the guard being useful rather than annoying)", and it
|
|
111
|
+
// could not.
|
|
112
|
+
const telemetryDir = join(project, ".space")
|
|
113
|
+
|
|
114
|
+
/** Record the denial, then stop the call. */
|
|
115
|
+
const refuse = (tool: string, rule: string, why: string, detail: string): never => {
|
|
116
|
+
write(telemetryDir, { kind: "guard", tool, rule })
|
|
117
|
+
deny(why, detail)
|
|
118
|
+
}
|
|
119
|
+
|
|
29
120
|
return {
|
|
30
|
-
"tool.execute.before": async (input, output) => {
|
|
121
|
+
"tool.execute.before": async (input: any, output: any) => {
|
|
31
122
|
try {
|
|
32
123
|
if (input.tool === "bash") {
|
|
124
|
+
// The bash tool takes a `workdir`, and it becomes the command's
|
|
125
|
+
// working directory. A command run from outside the project is
|
|
126
|
+
// writing outside the project, which is the one thing the scope
|
|
127
|
+
// rule exists to stop - and the guard used to ignore the argument
|
|
128
|
+
// entirely, so `workdir: "/etc"` was a way around it.
|
|
129
|
+
const raw = output.args?.workdir
|
|
130
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
131
|
+
const target = raw.startsWith("~")
|
|
132
|
+
? join(homedir(), raw.slice(1))
|
|
133
|
+
: resolve(project, raw)
|
|
134
|
+
if (!insideRoot(target, project) && !insideRoot(target, tmpdir())) {
|
|
135
|
+
refuse(
|
|
136
|
+
input.tool,
|
|
137
|
+
"command outside the project",
|
|
138
|
+
"command runs outside the project",
|
|
139
|
+
`the working directory is ${truncate(target)}, and this project's files are the ones you were asked to change`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
33
143
|
const hit = checkBash(String(output.args?.command ?? ""))
|
|
34
|
-
if (hit)
|
|
144
|
+
if (hit) {
|
|
145
|
+
refuse(input.tool, hit.name, hit.name, `${hit.why}\n command: ${truncate(hit.segment)}`)
|
|
146
|
+
}
|
|
147
|
+
// The shared denylist, enforced in code.
|
|
148
|
+
//
|
|
149
|
+
// The config denies these through opencode's permission map, and the
|
|
150
|
+
// guard used to enforce a DIFFERENT set - it blocked neither
|
|
151
|
+
// `gh pr create` nor `npm publish`. The config's own comment said
|
|
152
|
+
// the two were the same, and they were not. One list, two
|
|
153
|
+
// enforcers: the permission map can be overridden by a plugin or
|
|
154
|
+
// lost to a hand-edit, and the guard does not care.
|
|
155
|
+
const command = String(output.args?.command ?? "")
|
|
156
|
+
// Checked per segment, with the wrappers stripped and the payload of
|
|
157
|
+
// a `bash -c` followed: `cd /tmp && npm publish`, `sudo npm
|
|
158
|
+
// publish` and `sh -c "npm publish"` are all the same command, and
|
|
159
|
+
// matching the raw string let every one of them through.
|
|
160
|
+
const denied = checkDenied(command)
|
|
161
|
+
if (denied) {
|
|
162
|
+
refuse(
|
|
163
|
+
input.tool,
|
|
164
|
+
`denied command (${denied.pattern})`,
|
|
165
|
+
"denied command",
|
|
166
|
+
`this command matches \`${denied.pattern}\`, which the kit never allows.\n command: ${truncate(denied.segment)}`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
35
169
|
return
|
|
36
170
|
}
|
|
37
171
|
|
|
38
|
-
if (
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
172
|
+
if (WRITE_TOOLS.has(input.tool)) {
|
|
173
|
+
const targets = writeTargets(input.tool, output.args)
|
|
174
|
+
if (!targets.length) {
|
|
175
|
+
// A write tool whose arguments name no file. Failing open here
|
|
176
|
+
// would mean a tool this guard does not understand writes
|
|
177
|
+
// wherever it likes.
|
|
178
|
+
refuse(
|
|
179
|
+
input.tool,
|
|
180
|
+
"unknown write target",
|
|
181
|
+
"cannot tell what this write touches",
|
|
182
|
+
`the ${input.tool} tool's arguments name no file, so the scope of the write is unknown`,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
for (const target of targets) {
|
|
186
|
+
const hit = checkWrite(target, undefined, project)
|
|
187
|
+
if (hit) {
|
|
188
|
+
refuse(input.tool, hit.name, hit.name, `${hit.why}\n file: ${truncate(hit.segment)}`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
44
191
|
}
|
|
45
192
|
} catch (err) {
|
|
46
193
|
// Our own denial must not be re-wrapped into a confusing failure.
|
|
47
194
|
if (err instanceof Error && err.message.startsWith(MARKER)) throw err
|
|
48
195
|
// Anything unexpected fails closed. An undecidable guard is not a pass.
|
|
196
|
+
write(telemetryDir, {
|
|
197
|
+
kind: "guard",
|
|
198
|
+
tool: input.tool,
|
|
199
|
+
rule: "guard failed closed",
|
|
200
|
+
})
|
|
49
201
|
throw new Error(
|
|
50
202
|
`${MARKER} (guard failed closed): ${
|
|
51
203
|
err instanceof Error ? err.message : String(err)
|
package/src/health.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The spent-route file.
|
|
2
|
+
//
|
|
3
|
+
// A route is "spent" when the provider says the free tier is exhausted. The
|
|
4
|
+
// router records that, and the effort hook reads it, so the two have to agree
|
|
5
|
+
// on WHERE the file is - and that is the project root, not the worktree.
|
|
6
|
+
//
|
|
7
|
+
// The worktree is opencode's git root, and for a directory that is not a git
|
|
8
|
+
// repository it is the FILESYSTEM ROOT. The router wrote the file under the
|
|
9
|
+
// worktree while the effort hook read it from the worktree, and in a
|
|
10
|
+
// non-git project the two were not even looking at the same place.
|
|
11
|
+
//
|
|
12
|
+
// Plain JS with JSDoc types, like the rest of src/: the CLI and the tests run
|
|
13
|
+
// under plain node, and the plugin loads .ts.
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} Health
|
|
20
|
+
* @property {Record<string, { at: string, reason: string }>} unhealthy
|
|
21
|
+
* @property {string} [active]
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} dir the project root
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function healthPath(dir) {
|
|
29
|
+
return join(dir, ".space", "health.json")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} dir the project root
|
|
34
|
+
* @returns {Health}
|
|
35
|
+
*/
|
|
36
|
+
export function readHealth(dir) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(healthPath(dir), "utf8"))
|
|
39
|
+
} catch {
|
|
40
|
+
return { unhealthy: {} }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} dir the project root
|
|
46
|
+
* @param {Health} health
|
|
47
|
+
*/
|
|
48
|
+
export function writeHealth(dir, health) {
|
|
49
|
+
try {
|
|
50
|
+
if (!existsSync(join(dir, ".space"))) mkdirSync(join(dir, ".space"), { recursive: true })
|
|
51
|
+
writeFileSync(healthPath(dir), JSON.stringify(health, null, 2))
|
|
52
|
+
} catch {
|
|
53
|
+
// Best effort. A health file that cannot be written means no failover,
|
|
54
|
+
// and failing to write it must not break the session.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The models a previous session found spent.
|
|
60
|
+
*
|
|
61
|
+
* Exported so the effort hook resolves a lane's variant against the same set
|
|
62
|
+
* of spent routes the router used. A lane that failed over to a model with
|
|
63
|
+
* fewer effort levels must lose its variant, or the config asks for an effort
|
|
64
|
+
* the new model does not publish.
|
|
65
|
+
*
|
|
66
|
+
* The health file is keyed by the qualified route, `opencode/<model>`. The
|
|
67
|
+
* lane chains list bare model ids, so the prefix is dropped here. Comparing
|
|
68
|
+
* the two directly would never match, and a spent route would look healthy.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} dir the project root
|
|
71
|
+
* @returns {Set<string>}
|
|
72
|
+
*/
|
|
73
|
+
export function unhealthyModels(dir) {
|
|
74
|
+
const health = readHealth(dir)
|
|
75
|
+
const out = new Set()
|
|
76
|
+
for (const key of Object.keys(health.unhealthy ?? {})) {
|
|
77
|
+
out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key)
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,13 +12,15 @@
|
|
|
12
12
|
|
|
13
13
|
import { mkdirSync, writeFileSync } from "node:fs"
|
|
14
14
|
import { join } from "node:path"
|
|
15
|
-
import type { Plugin } from "
|
|
15
|
+
import type { Plugin } from "./plugin.js"
|
|
16
16
|
import { GuardPlugin } from "./guard.js"
|
|
17
17
|
import { PrunePlugin } from "./prune.js"
|
|
18
|
-
import { RouterPlugin
|
|
18
|
+
import { RouterPlugin } from "./router.js"
|
|
19
19
|
import { TelemetryPlugin } from "./telemetry.js"
|
|
20
20
|
import { applyEffort, planEffort } from "./effort.js"
|
|
21
21
|
import { profileIdFromConfig } from "./profile.js"
|
|
22
|
+
import { projectRoot } from "./project.js"
|
|
23
|
+
import { unhealthyModels } from "./health.js"
|
|
22
24
|
|
|
23
25
|
export interface HarnessOptions {
|
|
24
26
|
/** Profile id. Defaults to the one recorded in the config by `oc-harness use`. */
|
|
@@ -62,6 +64,10 @@ export const HarnessPlugin: Plugin = async (ctx, options?: HarnessOptions) => {
|
|
|
62
64
|
// the hook. The config file does, so read the id from there.
|
|
63
65
|
const id = opts.profile ?? config?.harness?.profile ?? profileIdFromConfig()
|
|
64
66
|
if (!id) return
|
|
67
|
+
// The PROJECT root, not the worktree: the worktree is the filesystem
|
|
68
|
+
// root in a project that is not a git repository, so the spent-route
|
|
69
|
+
// file the router writes is somewhere else entirely.
|
|
70
|
+
const project = projectRoot(ctx.directory, ctx.worktree)
|
|
65
71
|
try {
|
|
66
72
|
// The same set of spent routes the router used. A lane that failed over
|
|
67
73
|
// to a model with fewer effort levels must not keep the variant the
|
|
@@ -74,12 +80,17 @@ export const HarnessPlugin: Plugin = async (ctx, options?: HarnessOptions) => {
|
|
|
74
80
|
typeof config?.harness?.sessionModel === "string"
|
|
75
81
|
? config.harness.sessionModel
|
|
76
82
|
: undefined
|
|
77
|
-
applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(
|
|
83
|
+
applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(project) }), {
|
|
78
84
|
sessionModel,
|
|
79
85
|
})
|
|
80
86
|
// Record it where the prune hook looks, so pruning picks up this
|
|
81
87
|
// profile's thresholds without access to the resolved config.
|
|
82
|
-
|
|
88
|
+
//
|
|
89
|
+
// Under the PROJECT root, not the worktree: the worktree is the
|
|
90
|
+
// filesystem root in a project that is not a git repository, and the
|
|
91
|
+
// write fails there with a permission error that this catch swallows.
|
|
92
|
+
// The pruner then finds no active profile and does nothing at all.
|
|
93
|
+
const dir = join(project, ".space")
|
|
83
94
|
mkdirSync(dir, { recursive: true })
|
|
84
95
|
writeFileSync(join(dir, "active"), `${id}\n`)
|
|
85
96
|
} catch {
|
package/src/lane-usage.js
CHANGED
|
@@ -25,25 +25,50 @@ import { dirname, join, resolve } from "node:path"
|
|
|
25
25
|
* @typedef {object} LaneUsage
|
|
26
26
|
* @property {number} requests distinct assistant messages that used the lane
|
|
27
27
|
* @property {number[]} ms durations of the requests that completed
|
|
28
|
+
* @property {number[]} ctx the context each request carried, in tokens
|
|
29
|
+
* @property {number[]} reasoning the reasoning each request spent, in tokens
|
|
28
30
|
* @property {Set<string>} models the models the lane was seen on
|
|
29
31
|
*/
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
* The telemetry
|
|
34
|
+
* The telemetry files for the project containing `startDir`.
|
|
35
|
+
*
|
|
36
|
+
* ALL of them, oldest first. The plugin rotates the file when it passes 4 MB
|
|
37
|
+
* and renames the old one to `telemetry.jsonl.1` - and the reader used to
|
|
38
|
+
* look at `telemetry.jsonl` alone, so a rotation silently threw away the
|
|
39
|
+
* history and every lane reported "never used". The number that decides
|
|
40
|
+
* whether the fleet stays at ten lanes was disappearing on a schedule.
|
|
33
41
|
*
|
|
34
42
|
* @param {string} startDir
|
|
35
|
-
* @returns {string
|
|
43
|
+
* @returns {string[]} the paths, or an empty array when the project has none
|
|
36
44
|
*/
|
|
37
|
-
export function
|
|
45
|
+
export function findTelemetryFiles(startDir) {
|
|
38
46
|
let dir = resolve(startDir)
|
|
39
|
-
for (let i = 0; i <
|
|
40
|
-
const
|
|
41
|
-
|
|
47
|
+
for (let i = 0; i < 12; i++) {
|
|
48
|
+
const space = join(dir, ".space")
|
|
49
|
+
const files = []
|
|
50
|
+
for (const suffix of [".2", ".1", ""]) {
|
|
51
|
+
const f = join(space, `telemetry.jsonl${suffix}`)
|
|
52
|
+
if (existsSync(f)) files.push(f)
|
|
53
|
+
}
|
|
54
|
+
// Oldest first: `.2` was rotated twice, `.1` once, and the plain file is
|
|
55
|
+
// the newest.
|
|
56
|
+
if (files.length) return files
|
|
42
57
|
const parent = dirname(dir)
|
|
43
58
|
if (parent === dir) break
|
|
44
59
|
dir = parent
|
|
45
60
|
}
|
|
46
|
-
return
|
|
61
|
+
return []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The telemetry file for the project containing `startDir`.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} startDir
|
|
68
|
+
* @returns {string | null} the path, or null when the project has no telemetry
|
|
69
|
+
*/
|
|
70
|
+
export function findTelemetryFile(startDir) {
|
|
71
|
+
return findTelemetryFiles(startDir)[0] ?? null
|
|
47
72
|
}
|
|
48
73
|
|
|
49
74
|
/**
|
|
@@ -63,24 +88,34 @@ export function findTelemetryFile(startDir) {
|
|
|
63
88
|
* three, so counting them would put a known-wrong figure next a known-right
|
|
64
89
|
* one.
|
|
65
90
|
*
|
|
66
|
-
* @param {string | null} file
|
|
67
|
-
* @returns {{ lanes: Record<string, LaneUsage>, records: number, ignored: number }}
|
|
91
|
+
* @param {string | string[] | null} file one file, or several oldest first
|
|
92
|
+
* @returns {{ lanes: Record<string, LaneUsage>, records: number, requests: number, ignored: number }}
|
|
68
93
|
*/
|
|
69
94
|
export function readLaneUsage(file) {
|
|
70
95
|
/** @type {Record<string, LaneUsage>} */
|
|
71
96
|
const lanes = {}
|
|
72
|
-
/** @type {Map<string, { lane: string, ms?: number, model?: string }>} */
|
|
97
|
+
/** @type {Map<string, { lane: string, ms?: number, model?: string, ctx?: number, reasoning?: number }>} */
|
|
73
98
|
const byId = new Map()
|
|
74
99
|
let records = 0
|
|
75
100
|
let ignored = 0
|
|
76
101
|
|
|
77
|
-
|
|
102
|
+
const files = Array.isArray(file) ? file : file ? [file] : []
|
|
103
|
+
const readable = files.filter((f) => existsSync(f))
|
|
104
|
+
if (!readable.length) return { lanes, records, requests: 0, ignored }
|
|
78
105
|
|
|
79
106
|
let text = ""
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
107
|
+
for (const f of readable) {
|
|
108
|
+
try {
|
|
109
|
+
const chunk = readFileSync(f, "utf8")
|
|
110
|
+
// A file that does not end in a newline would otherwise be glued to
|
|
111
|
+
// the next one, and the two records that meet in the middle would
|
|
112
|
+
// parse as neither.
|
|
113
|
+
if (text && !text.endsWith("\n")) text += "\n"
|
|
114
|
+
text += chunk
|
|
115
|
+
} catch {
|
|
116
|
+
// A file we cannot read is a file we cannot count. The rest of the
|
|
117
|
+
// history is still worth reading.
|
|
118
|
+
}
|
|
84
119
|
}
|
|
85
120
|
|
|
86
121
|
for (const line of text.split("\n")) {
|
|
@@ -102,7 +137,13 @@ export function readLaneUsage(file) {
|
|
|
102
137
|
if (rec?.kind !== "lane") continue
|
|
103
138
|
const lane = rec.agent
|
|
104
139
|
const id = rec.id
|
|
105
|
-
if (typeof lane !== "string" || !lane)
|
|
140
|
+
if (typeof lane !== "string" || !lane) {
|
|
141
|
+
// A record with no lane cannot be attributed to one, so it is not
|
|
142
|
+
// counted - and it is counted as ignored, so a malformed file is
|
|
143
|
+
// visible in the tally rather than silently shrinking the numbers.
|
|
144
|
+
ignored++
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
106
147
|
if (typeof id !== "string" || !id) {
|
|
107
148
|
ignored++
|
|
108
149
|
continue
|
|
@@ -117,18 +158,31 @@ export function readLaneUsage(file) {
|
|
|
117
158
|
lane,
|
|
118
159
|
ms: typeof rec.ms === "number" ? rec.ms : prev?.ms,
|
|
119
160
|
model: typeof rec.model === "string" ? rec.model : prev?.model,
|
|
161
|
+
ctx: typeof rec.ctx === "number" ? rec.ctx : prev?.ctx,
|
|
162
|
+
reasoning: typeof rec.reasoning === "number" ? rec.reasoning : prev?.reasoning,
|
|
120
163
|
})
|
|
121
164
|
}
|
|
122
165
|
|
|
123
166
|
for (const rec of byId.values()) {
|
|
124
167
|
const usage =
|
|
125
|
-
lanes[rec.lane] ?? (lanes[rec.lane] = {
|
|
168
|
+
lanes[rec.lane] ?? (lanes[rec.lane] = {
|
|
169
|
+
requests: 0,
|
|
170
|
+
ms: [],
|
|
171
|
+
ctx: [],
|
|
172
|
+
reasoning: [],
|
|
173
|
+
models: new Set(),
|
|
174
|
+
})
|
|
126
175
|
usage.requests++
|
|
127
176
|
if (typeof rec.ms === "number") usage.ms.push(rec.ms)
|
|
177
|
+
if (typeof rec.ctx === "number") usage.ctx.push(rec.ctx)
|
|
178
|
+
if (typeof rec.reasoning === "number") usage.reasoning.push(rec.reasoning)
|
|
128
179
|
if (rec.model) usage.models.add(rec.model)
|
|
129
180
|
}
|
|
130
181
|
|
|
131
|
-
|
|
182
|
+
// `records` is the raw count and `requests` is the count that means
|
|
183
|
+
// something: one record is written when a message opens and again
|
|
184
|
+
// when it closes, so the raw count is twice the number of requests.
|
|
185
|
+
return { lanes, records, requests: byId.size, ignored }
|
|
132
186
|
}
|
|
133
187
|
|
|
134
188
|
/**
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The plugin type, checked against the real one.
|
|
2
|
+
//
|
|
3
|
+
// `src/plugin.ts` declares the shape structurally, so the published
|
|
4
|
+
// declarations do not make a consumer install `@opencode-ai/plugin` - a
|
|
5
|
+
// package that pulls in `effect`, `zod` and `@opencode-ai/sdk`.
|
|
6
|
+
//
|
|
7
|
+
// A structural type that drifts from the real one is a silent breakage: the
|
|
8
|
+
// kit would install, and opencode would not call the hooks. So the real
|
|
9
|
+
// type is checked HERE, in the repository, where the package is installed.
|
|
10
|
+
// A drift is a failed build.
|
|
11
|
+
//
|
|
12
|
+
// This file is not in the exports map, so a consumer never loads it, and
|
|
13
|
+
// the declarations it produces are never reached from `dist/index.d.ts`.
|
|
14
|
+
|
|
15
|
+
import type { Plugin as OpencodePlugin } from "@opencode-ai/plugin"
|
|
16
|
+
|
|
17
|
+
import type { Plugin } from "./plugin.js"
|
|
18
|
+
|
|
19
|
+
import { GuardPlugin } from "./guard.js"
|
|
20
|
+
import { PrunePlugin } from "./prune.js"
|
|
21
|
+
import { RouterPlugin } from "./router.js"
|
|
22
|
+
import { TelemetryPlugin } from "./telemetry.js"
|
|
23
|
+
import { HarnessPlugin } from "./index.js"
|
|
24
|
+
|
|
25
|
+
// The kit's own plugins have to be opencode plugins. If opencode's type
|
|
26
|
+
// changes shape, this stops compiling, and the fix is a real one rather than
|
|
27
|
+
// a guess.
|
|
28
|
+
const _guard: OpencodePlugin = GuardPlugin
|
|
29
|
+
const _prune: OpencodePlugin = PrunePlugin
|
|
30
|
+
const _router: OpencodePlugin = RouterPlugin
|
|
31
|
+
const _telemetry: OpencodePlugin = TelemetryPlugin
|
|
32
|
+
const _harness: OpencodePlugin = HarnessPlugin
|
|
33
|
+
|
|
34
|
+
// And the other way round: the local type has to be wide enough to hold
|
|
35
|
+
// them, so a plugin written against the local type is still a valid one.
|
|
36
|
+
const _local: Plugin = _harness
|
|
37
|
+
|
|
38
|
+
export type { OpencodePlugin }
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The plugin type, without the plugin package.
|
|
2
|
+
//
|
|
3
|
+
// The kit's plugin functions are typed with opencode's own `Plugin` type,
|
|
4
|
+
// and that type lives in `@opencode-ai/plugin` - a package that depends on
|
|
5
|
+
// `effect`, `zod` and `@opencode-ai/sdk`, and has three peer dependencies of
|
|
6
|
+
// its own.
|
|
7
|
+
//
|
|
8
|
+
// A consumer that installs the kit to compile a config should not have to
|
|
9
|
+
// install all of that. And they do not have to: the published declarations
|
|
10
|
+
// imported the plugin's type, so `npm install opencode-harness-kit` gave a
|
|
11
|
+
// consumer whose type-check failed with "Cannot find module
|
|
12
|
+
// '@opencode-ai/plugin'". The optional peer dependency did not fix it -
|
|
13
|
+
// npm never installs an optional peer on its own.
|
|
14
|
+
//
|
|
15
|
+
// So the type is declared here, structurally: what opencode actually calls.
|
|
16
|
+
//
|
|
17
|
+
// The real type is still checked, in `plugin-type-check.ts`, against the
|
|
18
|
+
// real package - in CI, where the package is installed. A drift between the
|
|
19
|
+
// two is a failed build, not a broken consumer.
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A plugin: a function opencode calls once, which returns the hooks it
|
|
23
|
+
* wants registered.
|
|
24
|
+
*
|
|
25
|
+
* The context and the options are opencode's, and the kit does not name
|
|
26
|
+
* their fields - it uses what it needs and tolerates the rest.
|
|
27
|
+
*/
|
|
28
|
+
export type Plugin = (
|
|
29
|
+
ctx: any,
|
|
30
|
+
options?: any,
|
|
31
|
+
) => Promise<Record<string, any>>
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The hooks a plugin returns.
|
|
35
|
+
*
|
|
36
|
+
* opencode calls each one by name; the kit registers `config`, `tool.execute.before`
|
|
37
|
+
* and `chat.message`. A hook that is not in this list is not a hook.
|
|
38
|
+
*/
|
|
39
|
+
export type Hooks = Record<string, (...args: any[]) => any>
|