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/index.ts CHANGED
@@ -15,7 +15,7 @@ import { join } from "node:path"
15
15
  import type { Plugin } from "@opencode-ai/plugin"
16
16
  import { GuardPlugin } from "./guard.js"
17
17
  import { PrunePlugin } from "./prune.js"
18
- import { RouterPlugin } from "./router.js"
18
+ import { RouterPlugin, unhealthyModels } 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"
@@ -63,7 +63,20 @@ export const HarnessPlugin: Plugin = async (ctx, options?: HarnessOptions) => {
63
63
  const id = opts.profile ?? config?.harness?.profile ?? profileIdFromConfig()
64
64
  if (!id) return
65
65
  try {
66
- applyEffort(config, planEffort(id))
66
+ // The same set of spent routes the router used. A lane that failed over
67
+ // to a model with fewer effort levels must not keep the variant the
68
+ // previous model had.
69
+ //
70
+ // The session model is read before the effort plan is applied, because
71
+ // the router may have moved it onto a fallback while the base route was
72
+ // spent. Pinning the base back here would undo that failover.
73
+ const sessionModel =
74
+ typeof config?.harness?.sessionModel === "string"
75
+ ? config.harness.sessionModel
76
+ : undefined
77
+ applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(ctx.worktree) }), {
78
+ sessionModel,
79
+ })
67
80
  // Record it where the prune hook looks, so pruning picks up this
68
81
  // profile's thresholds without access to the resolved config.
69
82
  const dir = join(ctx.worktree, ".space")
@@ -0,0 +1,142 @@
1
+ // Reading the lane telemetry back.
2
+ //
3
+ // The plugin writes it; this reads it. It is the other half of the "which
4
+ // lanes earn their place" question, and it is separated from the CLI so it
5
+ // can be tested without spawning a process.
6
+ //
7
+ // Two rules make the numbers mean something:
8
+ //
9
+ // 1. A CALL is one assistant message, not one event. opencode emits
10
+ // `message.updated` several times per message, and a counter that read
11
+ // every event reported three calls for every call made.
12
+ //
13
+ // 2. The file is found by walking UP from the working directory. The plugin
14
+ // writes to the project root, so reading `process.cwd()` alone reported
15
+ // "never called" for every lane whenever the command was run from a
16
+ // subdirectory - which is the normal way to run it.
17
+ //
18
+ // Plain JS with JSDoc types, like the rest of src/: the plugin loads .ts but
19
+ // the CLI has to run under plain node.
20
+
21
+ import { existsSync, readFileSync } from "node:fs"
22
+ import { dirname, join, resolve } from "node:path"
23
+
24
+ /**
25
+ * @typedef {object} LaneUsage
26
+ * @property {number} requests distinct assistant messages that used the lane
27
+ * @property {number[]} ms durations of the requests that completed
28
+ * @property {Set<string>} models the models the lane was seen on
29
+ */
30
+
31
+ /**
32
+ * The telemetry file for the project containing `startDir`.
33
+ *
34
+ * @param {string} startDir
35
+ * @returns {string | null} the path, or null when the project has no telemetry
36
+ */
37
+ export function findTelemetryFile(startDir) {
38
+ let dir = resolve(startDir)
39
+ for (let i = 0; i < 6; i++) {
40
+ const file = join(dir, ".space", "telemetry.jsonl")
41
+ if (existsSync(file)) return file
42
+ const parent = dirname(dir)
43
+ if (parent === dir) break
44
+ dir = parent
45
+ }
46
+ return null
47
+ }
48
+
49
+ /**
50
+ * The lane usage recorded in a telemetry file.
51
+ *
52
+ * Records are merged by message id: a lane record is written when the message
53
+ * appears and again when it completes, and only the second one carries a
54
+ * duration. A message is one request, and it contributes one duration.
55
+ *
56
+ * A REQUEST is one assistant message, which is one round trip to the
57
+ * provider. A lane that makes three tool calls makes three requests, so this
58
+ * is a count of provider traffic rather than of delegations - which is the
59
+ * number that decides whether a free route's quota can carry the fleet.
60
+ *
61
+ * Records without a message id are ignored. They come from kit versions
62
+ * before the counter was fixed, and their number is inflated by a factor of
63
+ * three, so counting them would put a known-wrong figure next a known-right
64
+ * one.
65
+ *
66
+ * @param {string | null} file
67
+ * @returns {{ lanes: Record<string, LaneUsage>, records: number, ignored: number }}
68
+ */
69
+ export function readLaneUsage(file) {
70
+ /** @type {Record<string, LaneUsage>} */
71
+ const lanes = {}
72
+ /** @type {Map<string, { lane: string, ms?: number, model?: string }>} */
73
+ const byId = new Map()
74
+ let records = 0
75
+ let ignored = 0
76
+
77
+ if (!file || !existsSync(file)) return { lanes, records, ignored }
78
+
79
+ let text = ""
80
+ try {
81
+ text = readFileSync(file, "utf8")
82
+ } catch {
83
+ return { lanes, records, ignored }
84
+ }
85
+
86
+ for (const line of text.split("\n")) {
87
+ if (!line.trim()) continue
88
+ let rec
89
+ try {
90
+ rec = JSON.parse(line)
91
+ } catch {
92
+ ignored++
93
+ continue
94
+ }
95
+ if (rec?.kind === "lane-call") {
96
+ // A record from the kit version whose counter was inflated threefold. It
97
+ // names a lane, but it cannot be correlated with a message, so counting
98
+ // it would double-count every call it shares with a `lane` record.
99
+ ignored++
100
+ continue
101
+ }
102
+ if (rec?.kind !== "lane") continue
103
+ const lane = rec.agent
104
+ const id = rec.id
105
+ if (typeof lane !== "string" || !lane) continue
106
+ if (typeof id !== "string" || !id) {
107
+ ignored++
108
+ continue
109
+ }
110
+ records++
111
+ const prev = byId.get(id)
112
+ // A message is one call. The completed record carries the duration, so it
113
+ // wins over the open one; the open one is kept when the call never
114
+ // finished, because "was it called" is answered by the call existing.
115
+ if (prev && prev.ms !== undefined) continue
116
+ byId.set(id, {
117
+ lane,
118
+ ms: typeof rec.ms === "number" ? rec.ms : prev?.ms,
119
+ model: typeof rec.model === "string" ? rec.model : prev?.model,
120
+ })
121
+ }
122
+
123
+ for (const rec of byId.values()) {
124
+ const usage =
125
+ lanes[rec.lane] ?? (lanes[rec.lane] = { requests: 0, ms: [], models: new Set() })
126
+ usage.requests++
127
+ if (typeof rec.ms === "number") usage.ms.push(rec.ms)
128
+ if (rec.model) usage.models.add(rec.model)
129
+ }
130
+
131
+ return { lanes, records, ignored }
132
+ }
133
+
134
+ /**
135
+ * The mean of a set of durations, rounded.
136
+ * @param {number[]} ms
137
+ * @returns {number | null}
138
+ */
139
+ export function averageMs(ms) {
140
+ if (!ms.length) return null
141
+ return Math.round(ms.reduce((a, b) => a + b, 0) / ms.length)
142
+ }