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.
Files changed (57) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/GLOBAL.md +50 -13
  3. package/MIGRATION.md +140 -0
  4. package/README.md +276 -122
  5. package/bin/oc-harness.mjs +571 -135
  6. package/dist/index.js +453 -159
  7. package/docs/KNOWN-LIMITS.md +189 -4
  8. package/docs/RESEARCH.md +80 -5
  9. package/docs/ROUTING.md +254 -0
  10. package/lanes.json +136 -0
  11. package/lanes.schema.json +55 -0
  12. package/package.json +13 -11
  13. package/profiles/beam-free.json +51 -0
  14. package/profiles/big-pickle.json +45 -0
  15. package/profiles/deepseek-v4.1-flash-free.json +49 -0
  16. package/profiles/fleet-all.json +139 -0
  17. package/profiles/fleet-safe.json +139 -0
  18. package/profiles/glm-5.3-flash-free.json +49 -0
  19. package/profiles/index.json +120 -37
  20. package/profiles/longcat-2.5-preview-free.json +14 -10
  21. package/profiles/mimo-v2.6-flash-free.json +44 -0
  22. package/profiles/minimax-m3.1-flash-free.json +51 -0
  23. package/profiles/nemotron-3-ultra-free.json +44 -0
  24. package/profiles/nemotron-3.5-lightning-free.json +44 -0
  25. package/profiles/nemotron-3.5-super-vl-free.json +52 -0
  26. package/profiles/schema.json +30 -11
  27. package/{agents → prompts}/build.md +2 -35
  28. package/prompts/doc.md +42 -0
  29. package/{agents → prompts}/locate.md +1 -16
  30. package/prompts/longform.md +34 -0
  31. package/{agents → prompts}/plan.md +2 -25
  32. package/{agents → prompts}/reviewer.md +2 -22
  33. package/prompts/schema.md +30 -0
  34. package/prompts/sweep.md +29 -0
  35. package/{agents → prompts}/verify.md +6 -29
  36. package/prompts/vision.md +33 -0
  37. package/retention.yml +75 -57
  38. package/skills/space-build/SKILL.md +16 -0
  39. package/skills/space-finish/SKILL.md +0 -10
  40. package/skills/space-review/SKILL.md +8 -0
  41. package/src/config.js +94 -69
  42. package/src/driver/run.mjs +237 -0
  43. package/src/effort.js +59 -22
  44. package/src/index.ts +15 -2
  45. package/src/lane-usage.js +142 -0
  46. package/src/profile.js +615 -221
  47. package/src/router.ts +148 -39
  48. package/src/tasks.js +248 -0
  49. package/src/telemetry.ts +98 -0
  50. package/docs/demo/demo.gif +0 -0
  51. package/docs/demo/demo.mp4 +0 -0
  52. package/docs/demo/render.py +0 -146
  53. package/examples/muse-spark-1.3-contributor-free.json +0 -41
  54. package/profiles/deepseek-v4-flash-free.json +0 -41
  55. package/profiles/muse-split-free.json +0 -64
  56. package/profiles/space-bunny-free.json +0 -42
  57. 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 queue.
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 the preferred model unhealthy, pick
9
- // the next healthy profile before the session begins.
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 fallback queue, or `oc-harness doctor` to " +
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
- const id: string = activeId
94
- const mark = health.unhealthy[`opencode/${id}`]
95
- if (mark) {
96
- // Walk the queue for the first profile that is not marked down.
97
- let chosen: string | null = id
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
- const queue = [id, ...(loadProfile(id).fallback ?? [])]
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
- // A broken profile must not stop opencode from starting.
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
- /** Point the config at another profile, including its derived numbers. */
164
- function applyProfileToConfig(cfg: Record<string, any>, profileId: string) {
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 p = loadProfile(profileId)
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 = p.effort?.default
171
- if (p.effort?.levels?.length && variant) cfg.variant = variant
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
- for (const [role, want] of Object.entries(p.roles ?? {})) {
175
- const agent = cfg.agent ?? (cfg.agent = {})
176
- const existing = agent[role] ?? (agent[role] = {})
177
- existing.model = model
178
- const level = resolveEffort(p.effort?.levels ?? [], want)
179
- if (level) existing.variant = level
180
- else delete existing.variant
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(p)
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"
Binary file
Binary file