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/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
|
-
|
|
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
|
+
}
|