opencode-harness-kit 0.2.0 → 0.3.3
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 +301 -0
- package/GLOBAL.md +50 -13
- package/MIGRATION.md +140 -0
- package/README.md +276 -122
- package/bin/oc-harness.mjs +571 -135
- package/dist/index.js +453 -159
- package/docs/KNOWN-LIMITS.md +189 -4
- package/docs/RESEARCH.md +80 -5
- package/docs/ROUTING.md +254 -0
- package/lanes.json +136 -0
- package/lanes.schema.json +55 -0
- package/package.json +13 -11
- package/profiles/beam-free.json +51 -0
- package/profiles/big-pickle.json +45 -0
- package/profiles/deepseek-v4.1-flash-free.json +49 -0
- package/profiles/fleet-all.json +139 -0
- package/profiles/fleet-safe.json +139 -0
- package/profiles/glm-5.3-flash-free.json +49 -0
- package/profiles/index.json +120 -37
- package/profiles/longcat-2.5-preview-free.json +14 -10
- package/profiles/mimo-v2.6-flash-free.json +44 -0
- package/profiles/minimax-m3.1-flash-free.json +51 -0
- package/profiles/nemotron-3-ultra-free.json +44 -0
- package/profiles/nemotron-3.5-lightning-free.json +44 -0
- package/profiles/nemotron-3.5-super-vl-free.json +52 -0
- package/profiles/schema.json +30 -11
- package/{agents → prompts}/build.md +2 -35
- package/prompts/doc.md +42 -0
- package/{agents → prompts}/locate.md +1 -16
- package/prompts/longform.md +34 -0
- package/{agents → prompts}/plan.md +2 -25
- package/{agents → prompts}/reviewer.md +2 -22
- package/prompts/schema.md +30 -0
- package/prompts/sweep.md +29 -0
- package/{agents → prompts}/verify.md +6 -29
- package/prompts/vision.md +33 -0
- package/retention.yml +75 -57
- package/skills/space-build/SKILL.md +16 -0
- package/skills/space-finish/SKILL.md +0 -10
- package/skills/space-review/SKILL.md +8 -0
- package/src/config.js +94 -69
- package/src/driver/run.mjs +237 -0
- package/src/effort.js +59 -22
- package/src/index.ts +15 -2
- package/src/lane-usage.js +142 -0
- package/src/profile.js +615 -221
- package/src/router.ts +148 -39
- package/src/tasks.js +248 -0
- package/src/telemetry.ts +98 -0
- package/docs/demo/demo.gif +0 -0
- package/docs/demo/demo.mp4 +0 -0
- package/docs/demo/render.py +0 -146
- package/examples/muse-spark-1.3-contributor-free.json +0 -41
- package/profiles/deepseek-v4-flash-free.json +0 -41
- package/profiles/muse-split-free.json +0 -64
- package/profiles/space-bunny-free.json +0 -42
- package/profiles/x-preview-f-free.json +0 -41
package/src/router.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// Model fallback across a profile's
|
|
1
|
+
// Model fallback across a profile's lanes.
|
|
2
2
|
//
|
|
3
3
|
// The honest version of this feature: opencode has no hook that can change a
|
|
4
4
|
// live session's model. `chat.params` only sees temperature and sampling, the
|
|
5
5
|
// model on `chat.message` is read-only, and `config` runs once at startup. So
|
|
6
6
|
// failover here means two things, and only two:
|
|
7
7
|
//
|
|
8
|
-
// 1. At startup, if a health file marks
|
|
9
|
-
// the next
|
|
8
|
+
// 1. At startup, if a health file marks a route unhealthy, the lane that
|
|
9
|
+
// used it moves to the next rung of its own chain. A fleet has ten lanes
|
|
10
|
+
// and ten different models, so one spent route must not take the other
|
|
11
|
+
// nine down with it.
|
|
10
12
|
// 2. When a request fails, tell the agent plainly that the route is spent and
|
|
11
13
|
// a new session is needed, instead of letting it retry into a wall.
|
|
12
14
|
//
|
|
@@ -25,6 +27,9 @@ import {
|
|
|
25
27
|
profileIdFromConfig,
|
|
26
28
|
reservedTokens,
|
|
27
29
|
resolveEffort,
|
|
30
|
+
resolveLaneChain,
|
|
31
|
+
resolveLaneModel,
|
|
32
|
+
resolveRoles,
|
|
28
33
|
} from "./profile.js"
|
|
29
34
|
|
|
30
35
|
interface Health {
|
|
@@ -53,6 +58,29 @@ function writeHealth(worktree: string, h: Health) {
|
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The models a previous session found spent.
|
|
63
|
+
*
|
|
64
|
+
* Exported so the effort hook resolves a lane's variant against the model the
|
|
65
|
+
* lane actually ended up on. A lane that failed over from a model with a
|
|
66
|
+
* `none` effort level to one without levels has to lose its variant, or the
|
|
67
|
+
* config asks for an effort the new model does not publish.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} worktree
|
|
70
|
+
* @returns {Set<string>}
|
|
71
|
+
*/
|
|
72
|
+
export function unhealthyModels(worktree: string): Set<string> {
|
|
73
|
+
const health = readHealth(worktree)
|
|
74
|
+
// The health file is keyed by the qualified route, `opencode/<model>`. The
|
|
75
|
+
// lane chains list bare model ids, so the prefix is dropped here. Comparing
|
|
76
|
+
// the two directly would never match, and a spent route would look healthy.
|
|
77
|
+
const out = new Set<string>()
|
|
78
|
+
for (const key of Object.keys(health.unhealthy ?? {})) {
|
|
79
|
+
out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key)
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/** Patterns that mean "this route is spent", not "this request was bad". */
|
|
57
85
|
const SPENT = [
|
|
58
86
|
/FreeUsageLimitError/i,
|
|
@@ -72,8 +100,8 @@ function classify(text: string): string | null {
|
|
|
72
100
|
const WARN_TEXT =
|
|
73
101
|
"[opencode-harness-kit] This session's model route is rate limited or withdrawn. " +
|
|
74
102
|
"No plugin can swap a live session's model, so stop retrying and ask the human to start a " +
|
|
75
|
-
"new session. Run `oc-harness list` to see the
|
|
76
|
-
"check whether the profile still matches live metadata."
|
|
103
|
+
"new session. Run `oc-harness list` to see the lanes and their chains, or `oc-harness doctor` " +
|
|
104
|
+
"to check whether the profile still matches live metadata."
|
|
77
105
|
|
|
78
106
|
export const RouterPlugin: Plugin = async ({ worktree }) => {
|
|
79
107
|
let failedSessions = 0
|
|
@@ -85,32 +113,75 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
|
|
|
85
113
|
try {
|
|
86
114
|
const cfg = config as Record<string, any>
|
|
87
115
|
const health = readHealth(worktree)
|
|
116
|
+
const unhealthy = unhealthyModels(worktree)
|
|
88
117
|
|
|
89
118
|
// config.harness is stripped by schema validation, so the active
|
|
90
119
|
// profile comes from the config file on disk.
|
|
91
120
|
const activeId = cfg.harness?.profile ?? profileIdFromConfig()
|
|
92
|
-
if (activeId)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
121
|
+
if (!activeId) return
|
|
122
|
+
const id: string = activeId
|
|
123
|
+
let profile
|
|
124
|
+
try {
|
|
125
|
+
profile = loadProfile(id)
|
|
126
|
+
} catch {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 1. The session's own model. When it is spent, the profile's
|
|
131
|
+
// fallback queue names the profiles that can take over.
|
|
132
|
+
//
|
|
133
|
+
// A fleet keeps its lanes. Every lane has its own chain, and step 2
|
|
134
|
+
// moves a lane down that chain when its route is spent, so
|
|
135
|
+
// overwriting the lanes here would collapse ten jobs onto one
|
|
136
|
+
// model and throw away the chain that was supposed to survive the
|
|
137
|
+
// outage. Only the session model moves.
|
|
138
|
+
const baseKey = `opencode/${profile.model}`
|
|
139
|
+
if (health.unhealthy?.[baseKey]) {
|
|
140
|
+
const queue = [id, ...(profile.fallback ?? [])]
|
|
141
|
+
for (const candidate of queue) {
|
|
142
|
+
if (health.unhealthy?.[`opencode/${candidate}`]) continue
|
|
143
|
+
let fallback
|
|
98
144
|
try {
|
|
99
|
-
|
|
100
|
-
for (const candidate of queue) {
|
|
101
|
-
if (!health.unhealthy[`opencode/${candidate}`]) {
|
|
102
|
-
chosen = candidate
|
|
103
|
-
break
|
|
104
|
-
}
|
|
105
|
-
}
|
|
145
|
+
fallback = loadProfile(candidate)
|
|
106
146
|
} catch {
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
if (chosen !== id) {
|
|
110
|
-
applyProfileToConfig(cfg, chosen)
|
|
111
|
-
health.active = chosen
|
|
112
|
-
writeHealth(worktree, health)
|
|
147
|
+
continue
|
|
113
148
|
}
|
|
149
|
+
const key = `opencode/${fallback.model}`
|
|
150
|
+
if (health.unhealthy?.[key]) continue
|
|
151
|
+
const fleet = isFleet(profile)
|
|
152
|
+
applySessionModel(cfg, fallback, {
|
|
153
|
+
keepLanes: fleet,
|
|
154
|
+
// The lanes did not change, so the numbers derived from them did
|
|
155
|
+
// not change either.
|
|
156
|
+
profileId: fleet ? profile.id : fallback.id,
|
|
157
|
+
reserved: fleet ? reservedTokens(profile) : undefined,
|
|
158
|
+
threshold: fleet ? pruneThreshold(profile) : undefined,
|
|
159
|
+
})
|
|
160
|
+
health.active = candidate
|
|
161
|
+
writeHealth(worktree, health)
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 2. Every lane. A lane whose model is spent moves to the next rung of
|
|
167
|
+
// its own chain, so one dead route costs one lane, not the fleet.
|
|
168
|
+
const roles = resolveRoles(profile, { unhealthy })
|
|
169
|
+
for (const [lane, level] of Object.entries(roles)) {
|
|
170
|
+
if (level === undefined) continue
|
|
171
|
+
const agent = cfg.agent?.[lane]
|
|
172
|
+
if (!agent) continue
|
|
173
|
+
const resolved = resolveLaneModel(profile, lane, { unhealthy })
|
|
174
|
+
if (resolved.model === profile.model && !profile.roleModels?.[lane]) continue
|
|
175
|
+
const wantModel = `opencode/${resolved.model}`
|
|
176
|
+
if (agent.model !== wantModel) {
|
|
177
|
+
agent.model = wantModel
|
|
178
|
+
// The variant has to be resolved against the model the lane ended
|
|
179
|
+
// up on. A lane that failed over to a model with fewer effort
|
|
180
|
+
// levels has to lose the variant the old model had.
|
|
181
|
+
const requested = (profile.roles?.[lane] ?? null) as any
|
|
182
|
+
const next = resolveEffort(resolved.levels, requested)
|
|
183
|
+
if (next === null) delete agent.variant
|
|
184
|
+
else agent.variant = next
|
|
114
185
|
}
|
|
115
186
|
}
|
|
116
187
|
} catch {
|
|
@@ -160,30 +231,68 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
|
|
|
160
231
|
}
|
|
161
232
|
}
|
|
162
233
|
|
|
163
|
-
/**
|
|
164
|
-
|
|
234
|
+
/**
|
|
235
|
+
* Whether a profile runs more than one model behind its lanes.
|
|
236
|
+
* @param {any} profile
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
function isFleet(profile: any): boolean {
|
|
240
|
+
return Object.keys(profile?.roleModels ?? {}).length > 0
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Move the SESSION onto another profile's model.
|
|
245
|
+
*
|
|
246
|
+
* A fleet keeps its lanes. The lanes have their own chains, and the config
|
|
247
|
+
* hook has already moved each one down its chain past a spent route, so
|
|
248
|
+
* overwriting them here would collapse ten jobs onto one model and discard
|
|
249
|
+
* the chain that was supposed to survive the outage.
|
|
250
|
+
*
|
|
251
|
+
* A profile with no lanes of its own is a single-model profile: there is
|
|
252
|
+
* nothing to keep, so the fallback takes the lanes too.
|
|
253
|
+
*
|
|
254
|
+
* The session model is published on `harness.sessionModel` so the effort hook
|
|
255
|
+
* - which runs after this one - pins the session to the model that was chosen
|
|
256
|
+
* here rather than back to the base model that is spent.
|
|
257
|
+
*/
|
|
258
|
+
function applySessionModel(
|
|
259
|
+
cfg: Record<string, any>,
|
|
260
|
+
profile: any,
|
|
261
|
+
opts: {
|
|
262
|
+
keepLanes?: boolean
|
|
263
|
+
profileId?: string
|
|
264
|
+
reserved?: number
|
|
265
|
+
threshold?: number
|
|
266
|
+
} = {},
|
|
267
|
+
) {
|
|
165
268
|
try {
|
|
166
|
-
const
|
|
167
|
-
const model = `${p.provider}/${p.model}`
|
|
269
|
+
const model = `${profile.provider}/${profile.model}`
|
|
168
270
|
cfg.model = model
|
|
169
271
|
cfg.small_model = model
|
|
170
|
-
const variant =
|
|
171
|
-
if (
|
|
272
|
+
const variant = profile.effort?.default
|
|
273
|
+
if (profile.effort?.levels?.length && variant) cfg.variant = variant
|
|
172
274
|
else delete cfg.variant
|
|
173
275
|
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
276
|
+
if (!opts.keepLanes) {
|
|
277
|
+
for (const [lane, want] of Object.entries(profile.roles ?? {})) {
|
|
278
|
+
const agent = cfg.agent ?? (cfg.agent = {})
|
|
279
|
+
const existing = agent[lane] ?? (agent[lane] = {})
|
|
280
|
+
existing.model = model
|
|
281
|
+
const level = resolveEffort(profile.effort?.levels ?? [], want as any)
|
|
282
|
+
if (level) existing.variant = level
|
|
283
|
+
else delete existing.variant
|
|
284
|
+
}
|
|
181
285
|
}
|
|
182
286
|
if (cfg.compaction) {
|
|
183
287
|
cfg.compaction.prune = true
|
|
184
|
-
cfg.compaction.reserved = reservedTokens(
|
|
288
|
+
cfg.compaction.reserved = opts.reserved ?? reservedTokens(profile)
|
|
289
|
+
}
|
|
290
|
+
cfg.harness = {
|
|
291
|
+
...(cfg.harness ?? {}),
|
|
292
|
+
profile: opts.profileId ?? profile.id,
|
|
293
|
+
threshold: opts.threshold ?? pruneThreshold(profile),
|
|
294
|
+
sessionModel: model,
|
|
185
295
|
}
|
|
186
|
-
cfg.harness = { ...(cfg.harness ?? {}), profile: profileId, threshold: pruneThreshold(p) }
|
|
187
296
|
} catch {
|
|
188
297
|
// Leave the config alone if the fallback profile cannot be read.
|
|
189
298
|
}
|
package/src/tasks.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Parse and rewrite `.space/tasks.md`, the one artifact the whole loop reads.
|
|
2
|
+
//
|
|
3
|
+
// The driver reads it to decide what to do next, so a mistake here is a mistake
|
|
4
|
+
// in the loop itself rather than in a report about the loop. It is kept pure and
|
|
5
|
+
// line-based on purpose: every byte of the file that the loop does not own --
|
|
6
|
+
// the goal, the constraints, the decisions, any prose a human added -- survives
|
|
7
|
+
// a rewrite untouched.
|
|
8
|
+
|
|
9
|
+
export const TASK_FIELDS = ["files", "change", "verify", "status"]
|
|
10
|
+
|
|
11
|
+
const SECTIONS = ["Constraints", "Decisions", "Risks", "Tasks"]
|
|
12
|
+
|
|
13
|
+
const headingRe = /^##\s+(.+?)\s*$/
|
|
14
|
+
const taskRe = /^\s*-\s*\[([ xX])\]\s*(.*)$/
|
|
15
|
+
const fieldRe = new RegExp(`^\\s+-\\s+(${TASK_FIELDS.join("|")})\\s*:\\s*(.*)$`)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} Task
|
|
19
|
+
* @property {number} index position in the file, zero based
|
|
20
|
+
* @property {boolean} checked the checkbox was ticked
|
|
21
|
+
* @property {string} title the task on one line
|
|
22
|
+
* @property {string} files which files it is allowed to touch
|
|
23
|
+
* @property {string} change what it is supposed to change
|
|
24
|
+
* @property {string} verify the command that proves it
|
|
25
|
+
* @property {string} status pending | done | blocked
|
|
26
|
+
* @property {number} start first line of the task block
|
|
27
|
+
* @property {number} end last line of the task block
|
|
28
|
+
* @property {string} [key] indexed by field name when a block is written
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {object} TaskFile
|
|
33
|
+
* @property {string} goal
|
|
34
|
+
* @property {string} constraints
|
|
35
|
+
* @property {string} decisions
|
|
36
|
+
* @property {string} risks
|
|
37
|
+
* @property {Task[]} tasks
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read a task file into its sections and tasks.
|
|
42
|
+
*
|
|
43
|
+
* Only the Tasks section is parsed structurally. Everything else is kept as raw
|
|
44
|
+
* lines so that rendering a document back never reformats a decision someone
|
|
45
|
+
* wrote in prose.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} text
|
|
48
|
+
* @returns {TaskFile}
|
|
49
|
+
*/
|
|
50
|
+
export function parseTasks(text) {
|
|
51
|
+
const lines = String(text ?? "").split("\n")
|
|
52
|
+
|
|
53
|
+
const goal = (() => {
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (!line.trim()) continue
|
|
56
|
+
if (line.startsWith("# ")) return line.slice(2).trim()
|
|
57
|
+
return line.trim() // a file with no heading still has a goal on line 1
|
|
58
|
+
}
|
|
59
|
+
return ""
|
|
60
|
+
})()
|
|
61
|
+
|
|
62
|
+
/** @type {Record<string, string[]>} */
|
|
63
|
+
const sections = {}
|
|
64
|
+
let current = ""
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
const h = line.match(headingRe)
|
|
67
|
+
if (h) {
|
|
68
|
+
current = h[1]
|
|
69
|
+
if (!sections[current]) sections[current] = []
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (current) sections[current].push(line)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @type {Task[]} */
|
|
76
|
+
const tasks = []
|
|
77
|
+
for (let i = 0; i < lines.length; i++) {
|
|
78
|
+
const m = lines[i].match(taskRe)
|
|
79
|
+
if (!m) continue
|
|
80
|
+
// A checkbox outside the Tasks section is prose, not a task.
|
|
81
|
+
if (!inSection(lines, i, "Tasks")) continue
|
|
82
|
+
|
|
83
|
+
/** @type {Record<string, any> & Task} */
|
|
84
|
+
const t = {
|
|
85
|
+
index: tasks.length,
|
|
86
|
+
checked: m[1].toLowerCase() === "x",
|
|
87
|
+
title: m[2].trim(),
|
|
88
|
+
files: "",
|
|
89
|
+
change: "",
|
|
90
|
+
verify: "",
|
|
91
|
+
status: "",
|
|
92
|
+
start: i,
|
|
93
|
+
end: i,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let last = null
|
|
97
|
+
let j = i + 1
|
|
98
|
+
for (; j < lines.length; j++) {
|
|
99
|
+
if (taskRe.test(lines[j]) || headingRe.test(lines[j])) break
|
|
100
|
+
const f = lines[j].match(fieldRe)
|
|
101
|
+
if (f) {
|
|
102
|
+
t[f[1]] = f[2].trim()
|
|
103
|
+
last = f[1]
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
// A wrapped value continues the field above it, indented deeper.
|
|
107
|
+
if (last && lines[j].trim() && /^\s{4,}\S/.test(lines[j])) {
|
|
108
|
+
t[last] = `${t[last]} ${lines[j].trim()}`.trim()
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (!lines[j].trim()) continue
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
t.end = j - 1
|
|
115
|
+
if (!t.status) t.status = t.checked ? "done" : "pending"
|
|
116
|
+
tasks.push(t)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
goal,
|
|
121
|
+
constraints: raw(sections, "Constraints"),
|
|
122
|
+
decisions: raw(sections, "Decisions"),
|
|
123
|
+
risks: raw(sections, "Risks"),
|
|
124
|
+
tasks,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string[]} lines
|
|
130
|
+
* @param {number} idx
|
|
131
|
+
* @param {string} name
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
function inSection(lines, idx, name) {
|
|
135
|
+
for (let i = idx; i >= 0; i--) {
|
|
136
|
+
const h = lines[i].match(headingRe)
|
|
137
|
+
if (h) return h[1] === name
|
|
138
|
+
}
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {Record<string, string[]>} sections
|
|
144
|
+
* @param {string} name
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
function raw(sections, name) {
|
|
148
|
+
return (sections[name] ?? []).join("\n").trim()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The next task the loop is allowed to touch. Blocked work is never retried.
|
|
153
|
+
*
|
|
154
|
+
* @param {TaskFile} parsed
|
|
155
|
+
* @returns {Task | null}
|
|
156
|
+
*/
|
|
157
|
+
export function nextPending(parsed) {
|
|
158
|
+
return parsed.tasks.find((t) => !isDone(t) && !isBlocked(t)) ?? null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {Task} t */
|
|
162
|
+
export const isDone = (t) => t.checked || t.status === "done"
|
|
163
|
+
|
|
164
|
+
/** @param {Task} t */
|
|
165
|
+
export const isBlocked = (t) => t.status === "blocked"
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Record the outcome of a task: flip the checkbox, set the status, and paste the
|
|
169
|
+
* verify output that was actually observed.
|
|
170
|
+
*
|
|
171
|
+
* The verify text is written under the `verify:` line rather than replacing it,
|
|
172
|
+
* because the contract asks for the command and the real output together.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} text
|
|
175
|
+
* @param {Task} task
|
|
176
|
+
* @param {{ status: string, verifyOutput?: string }} outcome
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
export function markTask(text, task, { status, verifyOutput }) {
|
|
180
|
+
const lines = String(text).split("\n")
|
|
181
|
+
const boxLine = lines[task.start] ?? ""
|
|
182
|
+
lines[task.start] = boxLine.replace(
|
|
183
|
+
/^\s*-\s*\[[ xX]\]/,
|
|
184
|
+
status === "done" ? "- [x]" : "- [ ]",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
let statusLine = -1
|
|
188
|
+
for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
|
|
189
|
+
const f = lines[i]?.match(fieldRe)
|
|
190
|
+
if (f && f[1] === "status") {
|
|
191
|
+
statusLine = i
|
|
192
|
+
break
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (statusLine >= 0) lines[statusLine] = lines[statusLine].replace(
|
|
196
|
+
/(\s-\s+status\s*:).*$/,
|
|
197
|
+
`$1 ${status}`,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if (verifyOutput) {
|
|
201
|
+
let verifyLine = -1
|
|
202
|
+
for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
|
|
203
|
+
const f = lines[i]?.match(fieldRe)
|
|
204
|
+
if (f && f[1] === "verify") {
|
|
205
|
+
verifyLine = i
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const verifySource = verifyLine >= 0 ? lines[verifyLine] : undefined
|
|
210
|
+
if (verifySource) {
|
|
211
|
+
const indent = (verifySource.match(/^\s*/) ?? [""])[0] + " "
|
|
212
|
+
const quoted = String(verifyOutput)
|
|
213
|
+
.trimEnd()
|
|
214
|
+
.split("\n")
|
|
215
|
+
.map((l) => `${indent}${l}`)
|
|
216
|
+
lines.splice(verifyLine + 1, 0, ...quoted)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return lines.join("\n")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* One task rendered as the prompt handed to the agent that will do it.
|
|
225
|
+
*
|
|
226
|
+
* @param {Task} task
|
|
227
|
+
* @returns {string}
|
|
228
|
+
*/
|
|
229
|
+
export function taskPrompt(task) {
|
|
230
|
+
const out = [task.title]
|
|
231
|
+
if (task.files) out.push("", `Files: ${task.files}`)
|
|
232
|
+
if (task.change) out.push("", `Change: ${task.change}`)
|
|
233
|
+
if (task.verify) out.push("", `Verify: ${task.verify}`)
|
|
234
|
+
return out.join("\n")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The summary the driver prints when it stops, whatever stopped it.
|
|
239
|
+
*
|
|
240
|
+
* @param {TaskFile} parsed
|
|
241
|
+
* @returns {{ done: number, blocked: number, pending: number, total: number }}
|
|
242
|
+
*/
|
|
243
|
+
export function summarize(parsed) {
|
|
244
|
+
const done = parsed.tasks.filter(isDone).length
|
|
245
|
+
const blocked = parsed.tasks.filter(isBlocked).length
|
|
246
|
+
const pending = parsed.tasks.filter((t) => !isDone(t) && !isBlocked(t)).length
|
|
247
|
+
return { done, blocked, pending, total: parsed.tasks.length }
|
|
248
|
+
}
|
package/src/telemetry.ts
CHANGED
|
@@ -8,6 +8,29 @@
|
|
|
8
8
|
// What is worth recording: context size per request (to see pruning working),
|
|
9
9
|
// latency by role and effort (to see tiering paying off), tool denials (to see
|
|
10
10
|
// the guard being useful rather than annoying), and provider rate limits.
|
|
11
|
+
//
|
|
12
|
+
// A fleet adds one more question, and it is the one that decides whether the
|
|
13
|
+
// fleet stays at ten lanes or is cut down: which lane is actually used. Ten
|
|
14
|
+
// lanes that are never called are ten descriptions in the model\'s context and
|
|
15
|
+
// ten models on the free tier doing nothing. `oc-harness lanes` reads these
|
|
16
|
+
// records and reports the ones that were never called.
|
|
17
|
+
//
|
|
18
|
+
// WHAT A CALL IS
|
|
19
|
+
//
|
|
20
|
+
// One assistant message is one call. opencode emits `message.updated` several
|
|
21
|
+
// times for a single message - created, streamed, completed - so writing a
|
|
22
|
+
// record per event counted every call three times. A lane record therefore
|
|
23
|
+
// carries the message id, and a lane is counted once per id.
|
|
24
|
+
//
|
|
25
|
+
// Two records are written per message, at most: an open one when the message
|
|
26
|
+
// appears, and a closed one when it completes. The closed record is the only
|
|
27
|
+
// one with a duration, so "how long does this lane take" and "was this lane
|
|
28
|
+
// called at all" are answered from the same file without guessing.
|
|
29
|
+
//
|
|
30
|
+
// The `chat.message` hook is deliberately not used for this. Measured on
|
|
31
|
+
// OpenCode 1.19, its `agent` field is undefined, so it cannot attribute a
|
|
32
|
+
// call to a lane; and a second counter that cannot be correlated with the
|
|
33
|
+
// first would double-count every call.
|
|
11
34
|
|
|
12
35
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
13
36
|
import { appendFileSync, mkdirSync, existsSync, statSync, renameSync } from "node:fs"
|
|
@@ -45,6 +68,17 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
|
|
|
45
68
|
const dir = join(worktree, ".space")
|
|
46
69
|
const started = Date.now()
|
|
47
70
|
|
|
71
|
+
// The message id of every lane record already written, and of every one
|
|
72
|
+
// already written with a duration.
|
|
73
|
+
//
|
|
74
|
+
// opencode emits `message.updated` several times for a single assistant
|
|
75
|
+
// message: once when the message is created, once per streaming update, and
|
|
76
|
+
// once when it completes. Writing a record per event counted one call three
|
|
77
|
+
// times, which made the "cut the lanes nobody calls" counter wrong by a
|
|
78
|
+
// factor of three. A lane is counted once per message id instead.
|
|
79
|
+
const opened = new Set<string>()
|
|
80
|
+
const closed = new Set<string>()
|
|
81
|
+
|
|
48
82
|
return {
|
|
49
83
|
event: async ({ event }) => {
|
|
50
84
|
// Events are provider-specific and opencode may add shapes we have never
|
|
@@ -56,6 +90,30 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
|
|
|
56
90
|
// they get logged even though the event shape is provider-specific.
|
|
57
91
|
if (/error|rate|limit|quota/i.test(type)) {
|
|
58
92
|
write(dir, { kind: "provider-event", type, detail: truncate(safeStringify(event)) })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// An assistant message is the only place the kit can see which lane ran,
|
|
97
|
+
// on which model, and how long it took. That record is what makes "cut
|
|
98
|
+
// the lanes nobody uses" a measurement instead of a guess.
|
|
99
|
+
if (type === "message.updated") {
|
|
100
|
+
const lane = laneOf(event)
|
|
101
|
+
if (!lane || !lane.id) return
|
|
102
|
+
if (lane.ms !== undefined) {
|
|
103
|
+
// The completed event. Written once, and only if the open record
|
|
104
|
+
// did not already carry a duration.
|
|
105
|
+
if (closed.has(lane.id)) return
|
|
106
|
+
closed.add(lane.id)
|
|
107
|
+
write(dir, { kind: "lane", ...lane })
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
// The open event: the lane was called. Written even if the call never
|
|
111
|
+
// finishes, because "this lane is useless" and "this lane is slow"
|
|
112
|
+
// are different questions and only the first one is settled by a
|
|
113
|
+
// record with no duration.
|
|
114
|
+
if (opened.has(lane.id)) return
|
|
115
|
+
opened.add(lane.id)
|
|
116
|
+
write(dir, { kind: "lane", ...lane })
|
|
59
117
|
}
|
|
60
118
|
},
|
|
61
119
|
|
|
@@ -77,6 +135,46 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
|
|
|
77
135
|
}
|
|
78
136
|
}
|
|
79
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The lane, model and duration of one assistant message, read defensively.
|
|
140
|
+
*
|
|
141
|
+
* The event shape is opencode's, not ours, and it has changed between
|
|
142
|
+
* releases. Every field is optional and a shape we do not recognise yields
|
|
143
|
+
* null rather than a wrong number.
|
|
144
|
+
*
|
|
145
|
+
* The message id is what makes the record countable: one message is one call.
|
|
146
|
+
*/
|
|
147
|
+
function laneOf(event: unknown): {
|
|
148
|
+
id?: string
|
|
149
|
+
agent?: string
|
|
150
|
+
model?: string
|
|
151
|
+
provider?: string
|
|
152
|
+
ms?: number
|
|
153
|
+
} | null {
|
|
154
|
+
const e = event as { properties?: any } | undefined
|
|
155
|
+
const info = e?.properties?.info ?? e?.properties
|
|
156
|
+
if (!info || typeof info !== "object") return null
|
|
157
|
+
if (info.role && info.role !== "assistant") return null
|
|
158
|
+
|
|
159
|
+
const id = typeof info.id === "string" ? info.id : undefined
|
|
160
|
+
const agent = typeof info.agent === "string" ? info.agent : undefined
|
|
161
|
+
const modelId = info.model?.modelID ?? info.modelID
|
|
162
|
+
const providerId = info.model?.providerID ?? info.providerID
|
|
163
|
+
const time = info.time ?? {}
|
|
164
|
+
const created = typeof time.created === "number" ? time.created : undefined
|
|
165
|
+
const completed = typeof time.completed === "number" ? time.completed : undefined
|
|
166
|
+
const ms = created !== undefined && completed !== undefined ? completed - created : undefined
|
|
167
|
+
|
|
168
|
+
if (!id || !agent) return null
|
|
169
|
+
return {
|
|
170
|
+
id,
|
|
171
|
+
agent,
|
|
172
|
+
model: typeof modelId === "string" ? modelId : undefined,
|
|
173
|
+
provider: typeof providerId === "string" ? providerId : undefined,
|
|
174
|
+
ms: typeof ms === "number" && ms >= 0 ? ms : undefined,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
80
178
|
function extractRule(output: string): string {
|
|
81
179
|
const m = output.match(/blocked:\s*([^\n]+)/)
|
|
82
180
|
return m ? m[1].trim() : "unknown"
|
package/docs/demo/demo.gif
DELETED
|
Binary file
|
package/docs/demo/demo.mp4
DELETED
|
Binary file
|