opencode-harness-kit 0.6.3 → 0.7.0
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 +303 -10
- package/README.md +244 -14
- package/bin/oc-harness.mjs +234 -28
- package/dist/budget.d.ts +247 -0
- package/dist/config.d.ts +34 -1
- package/dist/effort.d.ts +12 -1
- package/dist/guard.d.ts +5 -0
- package/dist/health.d.ts +12 -1
- package/dist/index.js +556 -149
- package/dist/profile.d.ts +2 -1
- package/dist/prune.d.ts +22 -0
- package/dist/rules.d.ts +6 -2
- package/docs/EXIT-CODES.md +18 -4
- package/docs/REVIEW-VERDICT.md +73 -0
- package/eval/RESULTS.md +130 -7
- package/eval/ab.mjs +591 -68
- package/eval/compare.mjs +267 -0
- package/eval/verify.mjs +9 -0
- package/lanes.json +18 -9
- package/package.json +6 -2
- package/profiles/index.json +1 -12
- package/prompts/reviewer.md +24 -0
- package/retention.yml +0 -8
- package/src/budget.js +418 -0
- package/src/config.js +172 -6
- package/src/driver/run.mjs +780 -42
- package/src/effort.js +17 -3
- package/src/guard.ts +304 -1
- package/src/health.js +13 -1
- package/src/index.ts +23 -12
- package/src/profile.js +1 -1
- package/src/prune.ts +21 -1
- package/src/rules.js +145 -0
- package/src/tasks.js +31 -0
package/src/budget.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// The token budget: per session, and across the sessions that share a window.
|
|
2
|
+
//
|
|
3
|
+
// The free tier is a shared window: every request in every session draws on
|
|
4
|
+
// it, and when it runs out the provider answers with a limit error instead of
|
|
5
|
+
// tokens. Nothing in opencode counts the window, so a long chat session can
|
|
6
|
+
// spend it without ever showing a number - one real session made 599 requests
|
|
7
|
+
// and 12,025,980 input tokens, and the first sign of trouble was the provider
|
|
8
|
+
// refusing the next one.
|
|
9
|
+
//
|
|
10
|
+
// This is the counter. It counts the context a request re-sent: the uncached
|
|
11
|
+
// input, plus the prompt cache it read, plus the cache it wrote. That is the
|
|
12
|
+
// number the provider's window is spent on, and it is NOT `tokens.input`:
|
|
13
|
+
// with prompt caching the uncached part is a few hundred tokens against tens
|
|
14
|
+
// of thousands of `cache.read`, so a counter built on `input` alone undercounts
|
|
15
|
+
// a real session by five to forty times and never fires. Output is capped by the
|
|
16
|
+
// model and is a small fraction of the bill, so it is not counted.
|
|
17
|
+
//
|
|
18
|
+
// The limits are environment variables, not config keys: the guard runs
|
|
19
|
+
// inside opencode, and the environment is the one thing that reaches it
|
|
20
|
+
// without a round trip through the config file.
|
|
21
|
+
//
|
|
22
|
+
// OC_HARNESS_BUDGET_WARN warn once at this many context tokens (default 2M)
|
|
23
|
+
// OC_HARNESS_BUDGET_STOP block tool calls at this many (default 5M)
|
|
24
|
+
// OC_HARNESS_TURN_STOP block after one request re-sent this much
|
|
25
|
+
// (default 300k): the count that catches a loop the
|
|
26
|
+
// per-session total is too large to catch
|
|
27
|
+
//
|
|
28
|
+
// and, for the window every session shares:
|
|
29
|
+
//
|
|
30
|
+
// OC_HARNESS_BUDGET_WINDOW_MINUTES how far back to sum (default 60)
|
|
31
|
+
// OC_HARNESS_BUDGET_WINDOW_WARN warn once at this many (default 4M)
|
|
32
|
+
// OC_HARNESS_BUDGET_WINDOW_STOP block tool calls at this many (default 8M)
|
|
33
|
+
// OC_HARNESS_BUDGET_WINDOW_STICKY `0` lets a stop end when the sum rolls
|
|
34
|
+
// over; default on holds it for 6h
|
|
35
|
+
// OC_HARNESS_BUDGET_FILE the ledger path (tests, benchmarks)
|
|
36
|
+
//
|
|
37
|
+
// Zero disables that threshold. A stop below the warn is not a warn: the warn
|
|
38
|
+
// is clamped down to the stop, so the warning always comes first.
|
|
39
|
+
|
|
40
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
41
|
+
import { homedir } from "node:os"
|
|
42
|
+
import { dirname, join } from "node:path"
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_WARN_TOKENS = 2_000_000
|
|
45
|
+
export const DEFAULT_STOP_TOKENS = 5_000_000
|
|
46
|
+
|
|
47
|
+
// What one request may re-send before it is a loop rather than a turn. A
|
|
48
|
+
// request that re-sends 58,000 tokens costs the same on its fifth retry as on
|
|
49
|
+
// its first, so the per-session total above does not see a loop until it has
|
|
50
|
+
// spent far more than a person can use: the session that prompted this kit
|
|
51
|
+
// made 599 requests at 20,077 tokens each. The per-turn count stops the loop
|
|
52
|
+
// while the ledger still has room, and it is counted PER SESSION, not from the
|
|
53
|
+
// shared ledger: one step-finish reports one request's context, and the
|
|
54
|
+
// ledger's sum over an hour is a different number.
|
|
55
|
+
export const DEFAULT_TURN_STOP_TOKENS = 300_000
|
|
56
|
+
|
|
57
|
+
// The provider's window is not per session and not per project: every session
|
|
58
|
+
// on the account draws on the same one, and it is what actually runs out. A
|
|
59
|
+
// per-session count cannot see that - three parallel sessions each under their
|
|
60
|
+
// own stop still spend three times the budget. These defaults are the shared
|
|
61
|
+
// tripwire, kept in a file every session reads and appends to.
|
|
62
|
+
export const DEFAULT_WINDOW_MINUTES = 60
|
|
63
|
+
export const DEFAULT_WINDOW_WARN_TOKENS = 4_000_000
|
|
64
|
+
export const DEFAULT_WINDOW_STOP_TOKENS = 8_000_000
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A whole, non-negative token count, or the fallback.
|
|
68
|
+
*
|
|
69
|
+
* @param {any} raw
|
|
70
|
+
* @param {number} fallback
|
|
71
|
+
* @returns {number}
|
|
72
|
+
*/
|
|
73
|
+
function count(raw, fallback) {
|
|
74
|
+
if (raw === undefined || raw === null || raw === "") return fallback
|
|
75
|
+
const n = Number(raw)
|
|
76
|
+
if (!Number.isFinite(n) || n < 0) return fallback
|
|
77
|
+
return Math.floor(n)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What one request cost, in context tokens.
|
|
82
|
+
*
|
|
83
|
+
* A `step-finish` part reports four separate numbers: `input` is the part of
|
|
84
|
+
* the prompt that was not cached, `cache.read` is the cached prefix the model
|
|
85
|
+
* still had to read, `cache.write` is the prefix stored for the next request,
|
|
86
|
+
* and `output`/`reasoning` are what the model wrote. The first three are the
|
|
87
|
+
* context; only the first was counted before, which is why a step that cost
|
|
88
|
+
* 37,324 tokens was recorded as 844.
|
|
89
|
+
*
|
|
90
|
+
* `cache.write` is charged once, on the request that stored it, so summing it
|
|
91
|
+
* here is not double counting the reads that follow. Nor does it overlap
|
|
92
|
+
* `input`: across 605 measured steps the reported `total` was exactly
|
|
93
|
+
* `input + cache.read + cache.write + output + reasoning` every time, so the
|
|
94
|
+
* parts are disjoint and the three that are counted are the whole context.
|
|
95
|
+
*
|
|
96
|
+
* A value that is not a number contributes zero: a missing field must not turn
|
|
97
|
+
* the whole count into NaN, because a NaN comparison never fires a threshold.
|
|
98
|
+
*
|
|
99
|
+
* @param {any} tokens a `step-finish` part's `tokens`
|
|
100
|
+
* @returns {number}
|
|
101
|
+
*/
|
|
102
|
+
export function requestTokens(tokens) {
|
|
103
|
+
/** @param {unknown} raw @returns {number} */
|
|
104
|
+
const n = (raw) => {
|
|
105
|
+
const x = Number(raw)
|
|
106
|
+
return Number.isFinite(x) && x > 0 ? Math.floor(x) : 0
|
|
107
|
+
}
|
|
108
|
+
return n(tokens?.input) + n(tokens?.cache?.read) + n(tokens?.cache?.write)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The limits this process should use.
|
|
113
|
+
*
|
|
114
|
+
* @param {Record<string, string | undefined>} [env]
|
|
115
|
+
* @returns {{ warnAt: number, stopAt: number, turnStopAt: number }}
|
|
116
|
+
*/
|
|
117
|
+
export function budgetLimits(env = process.env) {
|
|
118
|
+
const warnAt = count(env?.OC_HARNESS_BUDGET_WARN, DEFAULT_WARN_TOKENS)
|
|
119
|
+
const stopAt = count(env?.OC_HARNESS_BUDGET_STOP, DEFAULT_STOP_TOKENS)
|
|
120
|
+
const turnStopAt = count(env?.OC_HARNESS_TURN_STOP, DEFAULT_TURN_STOP_TOKENS)
|
|
121
|
+
return {
|
|
122
|
+
warnAt: stopAt > 0 && warnAt > stopAt ? stopAt : warnAt,
|
|
123
|
+
stopAt,
|
|
124
|
+
turnStopAt,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A running total for one session.
|
|
130
|
+
*
|
|
131
|
+
* `add` returns the thresholds this call CROSSED, so the caller can warn once
|
|
132
|
+
* rather than once per request. A request that lands exactly on a threshold
|
|
133
|
+
* crosses it, and a request of zero tokens - a retry that never reached the
|
|
134
|
+
* provider - moves nothing.
|
|
135
|
+
*
|
|
136
|
+
* @param {{ warnAt?: number, stopAt?: number }} [limits]
|
|
137
|
+
*/
|
|
138
|
+
export function createBudget({ warnAt = DEFAULT_WARN_TOKENS, stopAt = DEFAULT_STOP_TOKENS } = {}) {
|
|
139
|
+
let total = 0
|
|
140
|
+
let warned = false
|
|
141
|
+
let stopped = false
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
/**
|
|
145
|
+
* @param {any} input tokens this request cost
|
|
146
|
+
* @returns {{ warn: boolean, stop: boolean }}
|
|
147
|
+
*/
|
|
148
|
+
add(input) {
|
|
149
|
+
const n = Number(input)
|
|
150
|
+
if (!Number.isFinite(n) || n <= 0) return { warn: false, stop: false }
|
|
151
|
+
total += Math.floor(n)
|
|
152
|
+
let warn = false
|
|
153
|
+
let stop = false
|
|
154
|
+
if (!warned && warnAt > 0 && total >= warnAt) {
|
|
155
|
+
warned = true
|
|
156
|
+
warn = true
|
|
157
|
+
}
|
|
158
|
+
if (!stopped && stopAt > 0 && total >= stopAt) {
|
|
159
|
+
stopped = true
|
|
160
|
+
stop = true
|
|
161
|
+
}
|
|
162
|
+
return { warn, stop }
|
|
163
|
+
},
|
|
164
|
+
get total() {
|
|
165
|
+
return total
|
|
166
|
+
},
|
|
167
|
+
get warned() {
|
|
168
|
+
return warned
|
|
169
|
+
},
|
|
170
|
+
get stopped() {
|
|
171
|
+
return stopped
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A count with separators, for a message a human reads.
|
|
178
|
+
*
|
|
179
|
+
* @param {number} n
|
|
180
|
+
* @returns {string}
|
|
181
|
+
*/
|
|
182
|
+
export function tokens(n) {
|
|
183
|
+
return Math.floor(n).toLocaleString("en-US")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- the shared window ------------------------------------------------------
|
|
187
|
+
//
|
|
188
|
+
// A file, not memory: the sessions that share the window are separate
|
|
189
|
+
// processes, and the one that has to warn is often not the one that spent.
|
|
190
|
+
// The file is a list of `{ t, n }` entries, pruned to the longest window any
|
|
191
|
+
// caller asks about, and written by rename so a reader never sees half a line.
|
|
192
|
+
|
|
193
|
+
/** How long an entry is kept, regardless of the window being asked about. */
|
|
194
|
+
const KEEP_MS = 6 * 60 * 60 * 1000
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Where the shared ledger lives.
|
|
198
|
+
*
|
|
199
|
+
* `XDG_STATE_HOME` is the right place for state that survives a reboot and is
|
|
200
|
+
* not configuration; falling back to `~/.local/state` is what the spec says to
|
|
201
|
+
* do. `OC_HARNESS_BUDGET_FILE` overrides both, which is what the tests use and
|
|
202
|
+
* what a user on a shared account would want.
|
|
203
|
+
*
|
|
204
|
+
* @param {Record<string, string | undefined>} [env]
|
|
205
|
+
* @returns {string}
|
|
206
|
+
*/
|
|
207
|
+
export function ledgerPath(env = process.env) {
|
|
208
|
+
const override = env?.OC_HARNESS_BUDGET_FILE
|
|
209
|
+
if (override) return override
|
|
210
|
+
const state = env?.XDG_STATE_HOME || join(env?.HOME || homedir(), ".local", "state")
|
|
211
|
+
return join(state, "opencode-harness-kit", "budget.json")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The entries in the ledger, or none.
|
|
216
|
+
*
|
|
217
|
+
* Three answers, not two, because "I could not read the count" and "the count
|
|
218
|
+
* is zero" are different facts and only one of them means there is room left:
|
|
219
|
+
*
|
|
220
|
+
* - no file -> `[]`: nothing has been spent yet.
|
|
221
|
+
* - a file that parses -> its entries.
|
|
222
|
+
* - a file that does not parse -> `null`.
|
|
223
|
+
*
|
|
224
|
+
* It used to return `[]` for the third case, which made a corrupt or
|
|
225
|
+
* half-written ledger indistinguishable from an empty window: the shared stop
|
|
226
|
+
* silently disappeared. `sharedWindowSpent()` in `guard.ts` repairs the file
|
|
227
|
+
* instead (see `repairSpend`): a ledger that cannot be read must not lock the
|
|
228
|
+
* window forever, because the only way out is a hand-delete the user may not
|
|
229
|
+
* know about. Nothing here throws.
|
|
230
|
+
*
|
|
231
|
+
* A `stop` entry is a marker, not a spend: `{ t, n: 0, stop: true }` records
|
|
232
|
+
* that the window was locked at `t`, so the lock can outlive the sliding sum
|
|
233
|
+
* that tripped it.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} path
|
|
236
|
+
* @returns {{ t: number, n: number, stop?: boolean }[] | null}
|
|
237
|
+
*/
|
|
238
|
+
export function readSpend(path) {
|
|
239
|
+
let raw
|
|
240
|
+
try {
|
|
241
|
+
if (!existsSync(path)) return []
|
|
242
|
+
raw = readFileSync(path, "utf8")
|
|
243
|
+
} catch {
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
let parsed
|
|
247
|
+
try {
|
|
248
|
+
parsed = JSON.parse(raw)
|
|
249
|
+
} catch {
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
if (!Array.isArray(parsed)) return null
|
|
253
|
+
return parsed.filter(
|
|
254
|
+
(e) =>
|
|
255
|
+
e &&
|
|
256
|
+
Number.isFinite(e.t) &&
|
|
257
|
+
Number.isFinite(e.n) &&
|
|
258
|
+
(e.n > 0 || (e.stop === true && e.n === 0)),
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Keep the entries that are still inside the keep window, oldest first.
|
|
264
|
+
*
|
|
265
|
+
* @param {{ t: number, n: number, stop?: boolean }[]} entries
|
|
266
|
+
* @param {number} now
|
|
267
|
+
* @returns {{ t: number, n: number, stop?: boolean }[]}
|
|
268
|
+
*/
|
|
269
|
+
export function pruneSpend(entries, now) {
|
|
270
|
+
const floor = now - KEEP_MS
|
|
271
|
+
return entries.filter((e) => e.t >= floor).sort((a, b) => a.t - b.t)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Move an unreadable ledger aside and start a fresh one.
|
|
276
|
+
*
|
|
277
|
+
* The old code read a corrupt file as "spent" and failed closed, which is the
|
|
278
|
+
* right answer for a single tool call and the wrong answer for the machine:
|
|
279
|
+
* the only exit was deleting a file the user had never heard of, so the next
|
|
280
|
+
* session was blocked for six hours by a truncated JSON write. The evidence
|
|
281
|
+
* still matters - a corrupt ledger is a bug worth reading - so the broken file
|
|
282
|
+
* is renamed to `<path>.corrupt` rather than deleted, and an empty ledger
|
|
283
|
+
* replaces it. The count cannot be recovered, so the new file starts at zero;
|
|
284
|
+
* if the old file had a stop marker, `.corrupt` still holds it.
|
|
285
|
+
*
|
|
286
|
+
* Returns false when the repair could not be written; the caller then falls
|
|
287
|
+
* back to the old fail-closed answer rather than pretending the window is
|
|
288
|
+
* empty.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} path
|
|
291
|
+
* @returns {boolean}
|
|
292
|
+
*/
|
|
293
|
+
export function repairSpend(path) {
|
|
294
|
+
try {
|
|
295
|
+
if (existsSync(path)) renameSync(path, `${path}.corrupt`)
|
|
296
|
+
writeSpend(path, [])
|
|
297
|
+
return true
|
|
298
|
+
} catch {
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Write the ledger, atomically.
|
|
305
|
+
*
|
|
306
|
+
* Read-modify-write without a lock: two sessions finishing a step in the same
|
|
307
|
+
* millisecond can lose one entry. That is a few thousand tokens on a budget of
|
|
308
|
+
* millions, and a lock file in this path is a worse failure mode - a crashed
|
|
309
|
+
* session would leave the window blocked for everyone.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} path
|
|
312
|
+
* @param {{ t: number, n: number, stop?: boolean }[]} entries
|
|
313
|
+
*/
|
|
314
|
+
export function writeSpend(path, entries) {
|
|
315
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
316
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
317
|
+
writeFileSync(tmp, JSON.stringify(entries))
|
|
318
|
+
renameSync(tmp, path)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Add one request's cost to the shared ledger.
|
|
323
|
+
*
|
|
324
|
+
* A ledger that cannot be read is repaired first, so the first write after a
|
|
325
|
+
* corruption appends to a fresh file instead of overwriting the broken one.
|
|
326
|
+
* The repair does not invent a stop: the count is unknown, and a marker that
|
|
327
|
+
* holds for six hours is the failure this repair exists to end.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} path
|
|
330
|
+
* @param {number} n
|
|
331
|
+
* @param {number} [now]
|
|
332
|
+
* @returns {{ t: number, n: number, stop?: boolean }[]} the pruned ledger
|
|
333
|
+
*/
|
|
334
|
+
export function recordSpend(path, n, now = Date.now()) {
|
|
335
|
+
const previous = readSpend(path)
|
|
336
|
+
if (previous === null) repairSpend(path)
|
|
337
|
+
const entries = pruneSpend(previous ?? [], now)
|
|
338
|
+
if (Number.isFinite(n) && n > 0) entries.push({ t: now, n: Math.floor(n) })
|
|
339
|
+
writeSpend(path, entries)
|
|
340
|
+
return entries
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Record that the window was locked, so the lock survives the window it was
|
|
345
|
+
* measured in.
|
|
346
|
+
*
|
|
347
|
+
* The sum is over the last hour, and the provider's window is not: it stayed
|
|
348
|
+
* shut for hours after the stop fired. A lock that releases itself on a timer
|
|
349
|
+
* is a lock that was only partly on, so the marker holds until `KEEP_MS`.
|
|
350
|
+
*
|
|
351
|
+
* @param {string} path
|
|
352
|
+
* @param {number} [now]
|
|
353
|
+
* @returns {{ t: number, n: number, stop?: boolean }[]} the pruned ledger
|
|
354
|
+
*/
|
|
355
|
+
export function recordStop(path, now = Date.now()) {
|
|
356
|
+
const previous = readSpend(path)
|
|
357
|
+
if (previous === null) repairSpend(path)
|
|
358
|
+
const entries = pruneSpend(previous ?? [], now)
|
|
359
|
+
entries.push({ t: now, n: 0, stop: true })
|
|
360
|
+
writeSpend(path, entries)
|
|
361
|
+
return entries
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Whether a stop marker is still inside the keep window.
|
|
366
|
+
*
|
|
367
|
+
* @param {{ t: number, n: number, stop?: boolean }[]} entries
|
|
368
|
+
* @param {number} now
|
|
369
|
+
* @returns {boolean}
|
|
370
|
+
*/
|
|
371
|
+
export function stopRecorded(entries, now) {
|
|
372
|
+
const floor = now - KEEP_MS
|
|
373
|
+
return entries.some((e) => e.stop === true && e.t >= floor)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Tokens spent in the last `windowMs`, across every session on this machine.
|
|
378
|
+
*
|
|
379
|
+
* @param {{ t: number, n: number, stop?: boolean }[]} entries
|
|
380
|
+
* @param {number} now
|
|
381
|
+
* @param {number} windowMs
|
|
382
|
+
* @returns {number}
|
|
383
|
+
*/
|
|
384
|
+
export function windowSpend(entries, now, windowMs) {
|
|
385
|
+
const floor = now - windowMs
|
|
386
|
+
let total = 0
|
|
387
|
+
for (const e of entries) if (e.t >= floor) total += e.n
|
|
388
|
+
return total
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The shared-window thresholds.
|
|
393
|
+
*
|
|
394
|
+
* `OC_HARNESS_BUDGET_WINDOW_MINUTES` sets the window (default 60). Zero or a
|
|
395
|
+
* value that is not a positive number falls back to the default: a window of
|
|
396
|
+
* zero would sum nothing and silently disable the tripwire.
|
|
397
|
+
*
|
|
398
|
+
* `OC_HARNESS_BUDGET_WINDOW_STICKY` (default on) makes a stop hold until its
|
|
399
|
+
* marker ages out, instead of ending when the entries that tripped it roll out
|
|
400
|
+
* of the window. `0` restores the sum-only behaviour, which unlocks the
|
|
401
|
+
* window on a timer the provider does not keep.
|
|
402
|
+
*
|
|
403
|
+
* @param {Record<string, string | undefined>} [env]
|
|
404
|
+
* @returns {{ windowMs: number, warnAt: number, stopAt: number, sticky: boolean }}
|
|
405
|
+
*/
|
|
406
|
+
export function windowLimits(env = process.env) {
|
|
407
|
+
const rawMinutes = Number(env?.OC_HARNESS_BUDGET_WINDOW_MINUTES)
|
|
408
|
+
const minutes =
|
|
409
|
+
Number.isFinite(rawMinutes) && rawMinutes > 0 ? Math.floor(rawMinutes) : DEFAULT_WINDOW_MINUTES
|
|
410
|
+
const warnAt = count(env?.OC_HARNESS_BUDGET_WINDOW_WARN, DEFAULT_WINDOW_WARN_TOKENS)
|
|
411
|
+
const stopAt = count(env?.OC_HARNESS_BUDGET_WINDOW_STOP, DEFAULT_WINDOW_STOP_TOKENS)
|
|
412
|
+
return {
|
|
413
|
+
windowMs: minutes * 60_000,
|
|
414
|
+
warnAt: stopAt > 0 && warnAt > stopAt ? stopAt : warnAt,
|
|
415
|
+
stopAt,
|
|
416
|
+
sticky: env?.OC_HARNESS_BUDGET_WINDOW_STICKY !== "0",
|
|
417
|
+
}
|
|
418
|
+
}
|
package/src/config.js
CHANGED
|
@@ -29,8 +29,8 @@ import {
|
|
|
29
29
|
loadLanes,
|
|
30
30
|
} from "./profile.js"
|
|
31
31
|
|
|
32
|
-
import { join } from "node:path"
|
|
33
|
-
import { readFileSync } from "node:fs"
|
|
32
|
+
import { join, resolve } from "node:path"
|
|
33
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
34
34
|
import { homedir } from "node:os"
|
|
35
35
|
import { BASH_DENYLIST } from "./denylist.js"
|
|
36
36
|
|
|
@@ -79,6 +79,61 @@ export function kitVersion() {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** Where opencode's user config lives when nothing overrides it. */
|
|
83
|
+
export const DEFAULT_CONFIG_DIR = join(homedir(), ".config/opencode")
|
|
84
|
+
|
|
85
|
+
/** Where this kit itself lives - the parent of the bundle (or src) directory. */
|
|
86
|
+
export function kitRoot() {
|
|
87
|
+
// `../package.json` from the module's own location is exactly where
|
|
88
|
+
// kitVersion() reads the version from: in dev, `../` of src/; in a bundle,
|
|
89
|
+
// `../` of dist/. Either way it is the kit's root, which is the right
|
|
90
|
+
// second base for a relative skills path that points INTO the kit.
|
|
91
|
+
return new URL("..", import.meta.url).pathname
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The skills.paths entries of a base config, with the dead ones dropped.
|
|
96
|
+
*
|
|
97
|
+
* A `paths` entry that never resolves is not a configuration - it is noise
|
|
98
|
+
* the file carries for every reader to puzzle over, and a reader who DOES
|
|
99
|
+
* puzzle over it gets a wrong model of the world: this kit's config shipped
|
|
100
|
+
* `["../skills"]` which had dl resolved to `~/.config/skills` - a directory
|
|
101
|
+
* that has never existed on this machine - for release after release.
|
|
102
|
+
*
|
|
103
|
+
* A relative entry resolves against the opencode config directory; an
|
|
104
|
+
* absolute one stands as written; a relative one is also checked against the
|
|
105
|
+
* kit root, because an entry pointing INTO the kit was always a plausible
|
|
106
|
+
* intent and the kit knows where its own skills are. Nothing that resolves
|
|
107
|
+
* is kept; word everything else is dropped and named in the header, the same
|
|
108
|
+
* way dropped bash rules and removed lanes are.
|
|
109
|
+
*
|
|
110
|
+
* @param {any} skills
|
|
111
|
+
* @param {string} configDir
|
|
112
|
+
* @param {string} [kitRoot] where the kit itself lives, when overridable
|
|
113
|
+
* @returns {{ skills: any, dropped: string[] }}
|
|
114
|
+
*/
|
|
115
|
+
export function cleanSkillPaths(skills, configDir, kitRoot) {
|
|
116
|
+
const paths = skills?.paths
|
|
117
|
+
if (!Array.isArray(paths) || !paths.length) return { skills, dropped: [] }
|
|
118
|
+
const kept = []
|
|
119
|
+
const dropped = []
|
|
120
|
+
for (const p of paths) {
|
|
121
|
+
if (typeof p !== "string" || !p.trim()) {
|
|
122
|
+
dropped.push(String(p))
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
const candidates =
|
|
126
|
+
p.startsWith("/") || p.startsWith("~") ? [p] : [resolve(configDir, p), ...(kitRoot ? [resolve(kitRoot, p)] : [])]
|
|
127
|
+
const live = candidates.some((c) => {
|
|
128
|
+
const real = c.startsWith("~") ? join(homedir(), c.slice(1)) : c
|
|
129
|
+
return existsSync(real)
|
|
130
|
+
})
|
|
131
|
+
if (live) kept.push(p)
|
|
132
|
+
else dropped.push(p)
|
|
133
|
+
}
|
|
134
|
+
return { skills: { ...skills, paths: kept }, dropped }
|
|
135
|
+
}
|
|
136
|
+
|
|
82
137
|
/**
|
|
83
138
|
* The memory plugin this kit can turn on.
|
|
84
139
|
*
|
|
@@ -316,7 +371,8 @@ function userBashRules(base) {
|
|
|
316
371
|
|
|
317
372
|
/**
|
|
318
373
|
* @param {Profile} p
|
|
319
|
-
* @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string
|
|
374
|
+
* @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string>,
|
|
375
|
+
* configDir?: string, kitRoot?: string, skills?: boolean }} [opts]
|
|
320
376
|
* @returns {string}
|
|
321
377
|
*/
|
|
322
378
|
export function generateConfig(p, opts = {}) {
|
|
@@ -359,6 +415,16 @@ export function generateConfig(p, opts = {}) {
|
|
|
359
415
|
...lane.permission,
|
|
360
416
|
}
|
|
361
417
|
}
|
|
418
|
+
// The skill list is the largest fixed cost in the prompt. opencode
|
|
419
|
+
// writes every discovered skill's name and description into the system
|
|
420
|
+
// message, so a machine with a large skill collection pays it on every
|
|
421
|
+
// request, before a word of the conversation - and a long chat pays it
|
|
422
|
+
// hundreds of times. The lanes carry their instructions in prompts/, so
|
|
423
|
+
// `--no-skills` omits the block for them; `skills: true` on a lane keeps
|
|
424
|
+
// it for that lane, and nothing changes when the flag is not passed.
|
|
425
|
+
if (opts.skills === false && lane.skills !== true) {
|
|
426
|
+
existing.tools = { ...(existing.tools ?? {}), skill: false }
|
|
427
|
+
}
|
|
362
428
|
}
|
|
363
429
|
|
|
364
430
|
// A primary agent may hand work to a lane. The rule is per-agent, and it
|
|
@@ -373,6 +439,50 @@ export function generateConfig(p, opts = {}) {
|
|
|
373
439
|
agent[role] = existing
|
|
374
440
|
}
|
|
375
441
|
|
|
442
|
+
// A lane the profile no longer manages gets no pin from the loop above -
|
|
443
|
+
// the loop only walks the profile's own roles - so a fleet lane left over
|
|
444
|
+
// from a previous profile survived every later `oc-harness use`, still
|
|
445
|
+
// carrying the previous profile's pinned model and kit prompt, and a model
|
|
446
|
+
// that may no longer match the file's retention header.
|
|
447
|
+
//
|
|
448
|
+
// The entry is removed only when the KIT still owns it: its prompt is the
|
|
449
|
+
// compiler's own lane file. An entry a user has rewritten - a different
|
|
450
|
+
// prompt file, or no prompt at all - is user data, and it is left alone:
|
|
451
|
+
// dropping it here would delete work a `use` cannot recreate.
|
|
452
|
+
//
|
|
453
|
+
// Every removal is named in the header alongside the dropped bash rules.
|
|
454
|
+
const removedLanes = []
|
|
455
|
+
for (const name of Object.keys(agent)) {
|
|
456
|
+
if (roles[name] !== undefined) continue
|
|
457
|
+
if (!lanes[name]) continue
|
|
458
|
+
const prompt = agent[name]?.prompt
|
|
459
|
+
if (typeof prompt === "string" && prompt.endsWith(`/prompts/${name}.md}`)) {
|
|
460
|
+
delete agent[name]
|
|
461
|
+
removedLanes.push(name)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// A second bundle hid in the same map: the ECC subagents, whose prompts
|
|
466
|
+
// live at `prompts/agents/<name>.txt`. They are not lanes, so the loop
|
|
467
|
+
// above ignored them, and the live config carried 25 of them - architect,
|
|
468
|
+
// code-reviewer, e2e-runner and the rest. Their descriptions are loaded on
|
|
469
|
+
// every request, which is the 6k-token floor this removes; their prompts
|
|
470
|
+
// are read only when one is delegated to.
|
|
471
|
+
//
|
|
472
|
+
// Ownership is the same rule as above: only an entry whose prompt is the
|
|
473
|
+
// bundle's own `{file:...prompts/agents/<name>.txt}` is removed. An agent
|
|
474
|
+
// the user wrote, or a lane, is untouched.
|
|
475
|
+
const removedBundled = []
|
|
476
|
+
for (const name of Object.keys(agent)) {
|
|
477
|
+
if (roles[name] !== undefined) continue
|
|
478
|
+
if (lanes[name]) continue
|
|
479
|
+
const prompt = agent[name]?.prompt
|
|
480
|
+
if (typeof prompt === "string" && /\{file:[^}]*prompts\/agents\/[^}]+\}/.test(prompt)) {
|
|
481
|
+
delete agent[name]
|
|
482
|
+
removedBundled.push(name)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
376
486
|
// The user's own bash rules are merged in, not replaced.
|
|
377
487
|
//
|
|
378
488
|
// The map used to be built from the kit's two lists alone, so every rule
|
|
@@ -423,6 +533,15 @@ export function generateConfig(p, opts = {}) {
|
|
|
423
533
|
delete base_.variant
|
|
424
534
|
delete base_.tool_output
|
|
425
535
|
|
|
536
|
+
// The skills paths are cleaned before the config is built, so the header
|
|
537
|
+
// can name what was dropped: a dead entry is dropped from the file and
|
|
538
|
+
// announced, the same way dropped bash rules and removed lanes are. The
|
|
539
|
+
// kit root comes from the bundle's own location - the same place
|
|
540
|
+
// kitVersion() reads its package.json from - so a relative entry pointing
|
|
541
|
+
// INTO the kit ("skills", "./skills") also stays alive.
|
|
542
|
+
const configDir = opts.configDir ?? DEFAULT_CONFIG_DIR
|
|
543
|
+
const skillPaths = cleanSkillPaths(base.skills ?? {}, configDir, opts.kitRoot ?? kitRoot())
|
|
544
|
+
|
|
426
545
|
const out = {
|
|
427
546
|
$schema: "https://opencode.ai/config.json",
|
|
428
547
|
...base_,
|
|
@@ -480,11 +599,20 @@ export function generateConfig(p, opts = {}) {
|
|
|
480
599
|
// numbers if this ever needs arguing again.
|
|
481
600
|
tool_output: { max_lines: 2000, max_bytes: 51200 },
|
|
482
601
|
|
|
483
|
-
skills:
|
|
602
|
+
// The skills paths: only the entries that resolve where they would be
|
|
603
|
+
// read from. A dead one is dropped from the file and named in the
|
|
604
|
+
// header - a path to nothing is noise every reader pays for.
|
|
605
|
+
skills: skillPaths.skills ?? {},
|
|
484
606
|
|
|
485
607
|
experimental: {
|
|
486
608
|
batch_tool: true,
|
|
487
|
-
|
|
609
|
+
// A denial is a stop, not a prompt. With this on, opencode hands the
|
|
610
|
+
// guard's refusal back to the model and continues the same loop; the
|
|
611
|
+
// measured session made 138 requests and 7,559,016 context tokens in 29
|
|
612
|
+
// minutes on six `shared token window` denials, each retry carrying
|
|
613
|
+
// the whole prompt. Off, the denial ends the turn and the error is what
|
|
614
|
+
// the user sees.
|
|
615
|
+
continue_loop_on_deny: false,
|
|
488
616
|
...(base.experimental ?? {}),
|
|
489
617
|
},
|
|
490
618
|
|
|
@@ -532,6 +660,10 @@ export function generateConfig(p, opts = {}) {
|
|
|
532
660
|
instructionsPath,
|
|
533
661
|
unhealthy,
|
|
534
662
|
dropped: own.dropped,
|
|
663
|
+
removedLanes,
|
|
664
|
+
removedBundled,
|
|
665
|
+
skillsDropped: skillPaths.dropped,
|
|
666
|
+
skills: opts.skills,
|
|
535
667
|
})
|
|
536
668
|
}
|
|
537
669
|
|
|
@@ -539,7 +671,8 @@ export function generateConfig(p, opts = {}) {
|
|
|
539
671
|
* @param {Record<string, any>} cfg
|
|
540
672
|
* @param {Profile} p
|
|
541
673
|
* @param {{ comments: boolean, instructionsPath?: string, unhealthy?: Set<string>,
|
|
542
|
-
* dropped?: string[]
|
|
674
|
+
* dropped?: string[], removedLanes?: string[], removedBundled?: string[],
|
|
675
|
+
* skillsDropped?: string[], skills?: boolean }} opts
|
|
543
676
|
* @returns {string}
|
|
544
677
|
*/
|
|
545
678
|
function render(cfg, p, opts) {
|
|
@@ -589,6 +722,12 @@ function render(cfg, p, opts) {
|
|
|
589
722
|
`${c}output cap ${p.limits.output.toLocaleString("en-US")} tokens`,
|
|
590
723
|
`${c}reasoning ${levels.length ? levels.join(" < ") : "(not tunable)"}`,
|
|
591
724
|
`${c}retention ${effective.retention}${effective.retention === "may-train" ? " <-- prompts may train a model" : ""}`,
|
|
725
|
+
...(opts.skills === false
|
|
726
|
+
? [
|
|
727
|
+
`${c}skills off (--no-skills): the skill list is not in the prompt`,
|
|
728
|
+
`${c} (every skill's name and description, on every request)`,
|
|
729
|
+
]
|
|
730
|
+
: []),
|
|
592
731
|
...(effective.retention !== p.retention
|
|
593
732
|
? [
|
|
594
733
|
`${c} base ${p.provider}/${p.model} is ${p.retention}, but ${effective.worst} makes the whole file ${effective.retention}`,
|
|
@@ -612,6 +751,33 @@ function render(cfg, p, opts) {
|
|
|
612
751
|
...opts.dropped.map((d) => `${c} ${d}`),
|
|
613
752
|
]
|
|
614
753
|
: []),
|
|
754
|
+
...(opts.removedLanes?.length
|
|
755
|
+
? [
|
|
756
|
+
`${c}`,
|
|
757
|
+
`${c}Lanes this profile does not manage, removed from the agent map:`,
|
|
758
|
+
...opts.removedLanes.map((r) => `${c} ${r}`),
|
|
759
|
+
`${c}A lane whose prompt you had rewritten is left alone instead - pin the`,
|
|
760
|
+
`${c}lane back with a prompt of your own if it was yours.`,
|
|
761
|
+
]
|
|
762
|
+
: []),
|
|
763
|
+
...(opts.removedBundled?.length
|
|
764
|
+
? [
|
|
765
|
+
`${c}`,
|
|
766
|
+
`${c}Bundled subagents removed from the agent map (prompt prompts/agents/*):`,
|
|
767
|
+
...opts.removedBundled.map((r) => `${c} ${r}`),
|
|
768
|
+
`${c}Their descriptions were loaded on every request; hand-written agents`,
|
|
769
|
+
`${c}are left alone.`,
|
|
770
|
+
]
|
|
771
|
+
: []),
|
|
772
|
+
...(opts.skillsDropped?.length
|
|
773
|
+
? [
|
|
774
|
+
`${c}`,
|
|
775
|
+
`${c}Skills paths that do not resolve, dropped from skills.paths:`,
|
|
776
|
+
...opts.skillsDropped.map((s) => `${c} ${s}`),
|
|
777
|
+
`${c}A path to nothing is noise every reader pays for; the kit's own`,
|
|
778
|
+
`${c}skills arrive as per-file links in the default skills directory.`,
|
|
779
|
+
]
|
|
780
|
+
: []),
|
|
615
781
|
`${c}`,
|
|
616
782
|
`${c}Derived numbers, do not hand-edit:`,
|
|
617
783
|
`${c} compaction.reserved ${reservedTokens(p, { unhealthy }).toLocaleString("en-US")} (${Math.round((p.effort?.reserveShare ?? 0.12) * 100)}% of the narrowest input cap in play, held back for reasoning growth)`,
|