opencode-harness-kit 0.0.0-stage → 0.2.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/GLOBAL.md +110 -0
- package/LICENSE +21 -0
- package/README.md +464 -2
- package/agents/build.md +66 -0
- package/agents/locate.md +48 -0
- package/agents/plan.md +61 -0
- package/agents/reviewer.md +62 -0
- package/agents/verify.md +54 -0
- package/bin/baseline.mjs +367 -0
- package/bin/git-hooks/pre-commit +30 -0
- package/bin/oc-harness.mjs +1025 -0
- package/dist/index.js +1038 -0
- package/docs/KNOWN-LIMITS.md +188 -0
- package/docs/RESEARCH.md +210 -0
- package/docs/demo/demo.gif +0 -0
- package/docs/demo/demo.mp4 +0 -0
- package/docs/demo/render.py +146 -0
- package/examples/muse-spark-1.3-contributor-free.json +41 -0
- package/package.json +81 -4
- package/profiles/deepseek-v4-flash-free.json +41 -0
- package/profiles/index.json +78 -0
- package/profiles/longcat-2.5-preview-free.json +41 -0
- package/profiles/muse-split-free.json +64 -0
- package/profiles/schema.json +161 -0
- package/profiles/space-bunny-free.json +42 -0
- package/profiles/x-preview-f-free.json +41 -0
- package/retention.yml +110 -0
- package/skills/space-build/SKILL.md +54 -0
- package/skills/space-finish/SKILL.md +63 -0
- package/skills/space-plan/SKILL.md +101 -0
- package/skills/space-plan/reference/task-template.md +47 -0
- package/skills/space-review/SKILL.md +53 -0
- package/src/config.js +358 -0
- package/src/effort.js +98 -0
- package/src/guard.ts +59 -0
- package/src/index.ts +80 -0
- package/src/profile.js +484 -0
- package/src/prune.ts +258 -0
- package/src/router.ts +192 -0
- package/src/rules.js +481 -0
- package/src/telemetry.ts +98 -0
package/src/profile.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// Profile loading, effort resolution, and retention lookup.
|
|
2
|
+
//
|
|
3
|
+
// The rule this file exists to enforce: no plugin in src/ names a model. Every
|
|
4
|
+
// model-specific decision comes from a profile JSON, so switching models means
|
|
5
|
+
// editing data, not code.
|
|
6
|
+
//
|
|
7
|
+
// Plain JS with JSDoc types on purpose: the opencode plugin loads .ts, but the
|
|
8
|
+
// CLI has to run under plain node, so the shared core stays dependency-free.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs"
|
|
11
|
+
import { dirname, join, resolve } from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
export const ROOT = resolve(HERE, "..")
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {"zero-retention" | "may-train" | "unknown"} Retention
|
|
19
|
+
* @typedef {string | null} EffortRequest
|
|
20
|
+
* @typedef {{ model: string, levels?: string[] }} RoleModel
|
|
21
|
+
* @typedef {object} Profile
|
|
22
|
+
* @property {string} id
|
|
23
|
+
* @property {string} [title]
|
|
24
|
+
* @property {string} provider
|
|
25
|
+
* @property {string} model
|
|
26
|
+
* @property {string | null} [family]
|
|
27
|
+
* @property {string} [verified]
|
|
28
|
+
* @property {Retention} retention
|
|
29
|
+
* @property {string} [notes]
|
|
30
|
+
* @property {{ context: number, input?: number, output: number }} limits
|
|
31
|
+
* @property {{ reasoning?: boolean, temperature?: boolean, toolCall?: boolean,
|
|
32
|
+
* structuredOutput?: boolean, attachment?: boolean }} [supports]
|
|
33
|
+
* @property {{ levels?: string[], default?: string | null, reserveShare?: number }} [effort]
|
|
34
|
+
* @property {Record<string, EffortRequest>} [roles]
|
|
35
|
+
* @property {Record<string, RoleModel>} [roleModels]
|
|
36
|
+
* @property {{ enabled?: boolean, keepRecentToolCalls?: number,
|
|
37
|
+
* keepRecentTurns?: number, keepUserMessages?: boolean }} [prune]
|
|
38
|
+
* @property {string[]} [fallback]
|
|
39
|
+
* @typedef {{ retention: Retention, note?: string, verified?: string, source?: string }} RetentionRecord
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export class ProfileError extends Error {}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Profiles live in profiles/ (verified) and examples/ (contrarian, still
|
|
46
|
+
* installable). schema.json and index.json are not profiles.
|
|
47
|
+
*/
|
|
48
|
+
export function profileDirs() {
|
|
49
|
+
return [join(ROOT, "profiles"), join(ROOT, "examples")]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {string} id @returns {string | null} */
|
|
53
|
+
export function profilePath(id) {
|
|
54
|
+
for (const dir of profileDirs()) {
|
|
55
|
+
const full = join(dir, `${id}.json`)
|
|
56
|
+
if (existsSync(full)) return full
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @returns {string[]} */
|
|
62
|
+
export function listProfileIds() {
|
|
63
|
+
const out = []
|
|
64
|
+
for (const dir of profileDirs()) {
|
|
65
|
+
if (!existsSync(dir)) continue
|
|
66
|
+
for (const f of readdirSync(dir)) {
|
|
67
|
+
if (!f.endsWith(".json")) continue
|
|
68
|
+
if (f === "schema.json" || f === "index.json") continue
|
|
69
|
+
out.push(f.replace(/\.json$/, ""))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out.sort()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {string} id @returns {Profile} */
|
|
76
|
+
export function loadProfile(id) {
|
|
77
|
+
const path = profilePath(id)
|
|
78
|
+
if (!path) {
|
|
79
|
+
throw new ProfileError(`no profile named "${id}". Run: oc-harness list`)
|
|
80
|
+
}
|
|
81
|
+
let raw
|
|
82
|
+
try {
|
|
83
|
+
raw = JSON.parse(readFileSync(path, "utf8"))
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
86
|
+
throw new ProfileError(`cannot read ${path}: ${msg}`)
|
|
87
|
+
}
|
|
88
|
+
validate(raw, path)
|
|
89
|
+
return normalize(raw)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fill in derived fields so nothing downstream has to guess.
|
|
94
|
+
* @param {Profile} p @returns {Profile}
|
|
95
|
+
*/
|
|
96
|
+
export function normalize(p) {
|
|
97
|
+
return {
|
|
98
|
+
...p,
|
|
99
|
+
limits: { ...p.limits, input: p.limits.input ?? p.limits.context },
|
|
100
|
+
effort: {
|
|
101
|
+
levels: p.effort?.levels ?? [],
|
|
102
|
+
default: p.effort?.default ?? null,
|
|
103
|
+
reserveShare: p.effort?.reserveShare ?? 0.12,
|
|
104
|
+
},
|
|
105
|
+
prune: {
|
|
106
|
+
enabled: p.prune?.enabled ?? false,
|
|
107
|
+
keepRecentToolCalls: p.prune?.keepRecentToolCalls ?? 6,
|
|
108
|
+
keepRecentTurns: p.prune?.keepRecentTurns ?? 3,
|
|
109
|
+
keepUserMessages: p.prune?.keepUserMessages ?? true,
|
|
110
|
+
},
|
|
111
|
+
fallback: p.fallback ?? [],
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const VALID_RETENTION = ["zero-retention", "may-train", "unknown"]
|
|
116
|
+
|
|
117
|
+
/** @param {Profile} p @param {string} [path] */
|
|
118
|
+
export function validate(p, path = "<memory>") {
|
|
119
|
+
const where = p?.id ?? path
|
|
120
|
+
if (!p?.id) throw new ProfileError(`${path}: missing "id"`)
|
|
121
|
+
if (!p.provider) throw new ProfileError(`${where}: missing "provider"`)
|
|
122
|
+
if (!p.model) throw new ProfileError(`${where}: missing "model"`)
|
|
123
|
+
if (!VALID_RETENTION.includes(p.retention)) {
|
|
124
|
+
throw new ProfileError(
|
|
125
|
+
`${where}: retention must be one of ${VALID_RETENTION.join(", ")}`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
if (!p.limits?.context || p.limits.context <= 0) {
|
|
129
|
+
throw new ProfileError(`${where}: limits.context must be a positive integer`)
|
|
130
|
+
}
|
|
131
|
+
if (!p.limits?.output || p.limits.output <= 0) {
|
|
132
|
+
throw new ProfileError(`${where}: limits.output must be a positive integer`)
|
|
133
|
+
}
|
|
134
|
+
const share = p.effort?.reserveShare
|
|
135
|
+
if (share !== undefined && (share < 0 || share > 0.5)) {
|
|
136
|
+
throw new ProfileError(
|
|
137
|
+
`${where}: effort.reserveShare must be between 0 and 0.5`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
|
|
141
|
+
if (!SPLITTABLE_ROLES.includes(role)) {
|
|
142
|
+
throw new ProfileError(
|
|
143
|
+
`${where}: roleModels.${role} is not one of ${SPLITTABLE_ROLES.join(", ")}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
// resolveRoles walks `roles`, so a split for a role nobody asks for never
|
|
147
|
+
// reaches an agent. That reads as "split installed" while doing nothing.
|
|
148
|
+
if (!(role in (p.roles ?? {}))) {
|
|
149
|
+
throw new ProfileError(
|
|
150
|
+
`${where}: roleModels.${role} has no matching entry in roles, so it would never apply`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
if (!rm?.model || typeof rm.model !== "string") {
|
|
154
|
+
throw new ProfileError(`${where}: roleModels.${role} needs a model`)
|
|
155
|
+
}
|
|
156
|
+
if (rm.levels !== undefined && !Array.isArray(rm.levels)) {
|
|
157
|
+
throw new ProfileError(
|
|
158
|
+
`${where}: roleModels.${role}.levels must be an array`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
// retention.yml is the only place retention is decided, so a role model
|
|
162
|
+
// missing from it is a profile we cannot honestly write a header for.
|
|
163
|
+
if (!retentionFor(rm.model)) {
|
|
164
|
+
throw new ProfileError(
|
|
165
|
+
`${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Canonical ordering for reasoning effort, used to snap a requested level onto
|
|
172
|
+
// whatever the target model actually offers.
|
|
173
|
+
const CANON = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Pick the available level closest to what a role asked for.
|
|
177
|
+
*
|
|
178
|
+
* A role asking for `max` on a model topping out at `xhigh` gets `xhigh`. A
|
|
179
|
+
* model with no tunable effort resolves to null, meaning "emit no variant"
|
|
180
|
+
* rather than "emit a level the model does not have".
|
|
181
|
+
*
|
|
182
|
+
* @param {string[]} levels
|
|
183
|
+
* @param {EffortRequest} requested
|
|
184
|
+
* @returns {string | null}
|
|
185
|
+
*/
|
|
186
|
+
export function resolveEffort(levels, requested) {
|
|
187
|
+
/** @type {string | null} */
|
|
188
|
+
if (!requested) return null
|
|
189
|
+
if (!levels || levels.length === 0) return null
|
|
190
|
+
if (levels.includes(requested)) return requested
|
|
191
|
+
|
|
192
|
+
const want = CANON.indexOf(requested)
|
|
193
|
+
if (want < 0) {
|
|
194
|
+
// Unknown label. Snap to the top level, which is the safe reading of a
|
|
195
|
+
// role that asked for something strong.
|
|
196
|
+
return levels[levels.length - 1] ?? null
|
|
197
|
+
}
|
|
198
|
+
let best = null
|
|
199
|
+
let bestDist = Infinity
|
|
200
|
+
for (const level of levels) {
|
|
201
|
+
const have = CANON.indexOf(level)
|
|
202
|
+
if (have < 0) continue
|
|
203
|
+
const d = Math.abs(have - want)
|
|
204
|
+
if (d < bestDist) {
|
|
205
|
+
bestDist = d
|
|
206
|
+
best = level
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return best ?? levels[levels.length - 1] ?? null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Model and effort levels for one role, or null when the role stays on the
|
|
214
|
+
* profile's base model.
|
|
215
|
+
*
|
|
216
|
+
* @param {Profile} p
|
|
217
|
+
* @param {string} role
|
|
218
|
+
* @returns {{ model: string, levels: string[] } | null}
|
|
219
|
+
*/
|
|
220
|
+
export function resolveRoleModel(p, role) {
|
|
221
|
+
const rm = p.roleModels?.[role]
|
|
222
|
+
if (!rm) return null
|
|
223
|
+
return { model: rm.model, levels: rm.levels ?? [] }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Effort levels a role's own model offers. Falls back to the profile's when the
|
|
228
|
+
* role is not split out, so an unsplit profile behaves exactly as before.
|
|
229
|
+
*
|
|
230
|
+
* @param {Profile} p
|
|
231
|
+
* @param {string} role
|
|
232
|
+
* @returns {string[]}
|
|
233
|
+
*/
|
|
234
|
+
function levelsForRole(p, role) {
|
|
235
|
+
const rm = resolveRoleModel(p, role)
|
|
236
|
+
if (rm) return rm.levels
|
|
237
|
+
return p.effort?.levels ?? []
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {Profile} p
|
|
242
|
+
* @returns {Record<string, string | null>}
|
|
243
|
+
*/
|
|
244
|
+
export function resolveRoles(p) {
|
|
245
|
+
const out = /** @type {Record<string, string | null>} */ ({})
|
|
246
|
+
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
247
|
+
// Resolve against the levels of the model this role actually runs on. A
|
|
248
|
+
// role asking for `max` on a model topping out at `xhigh` gets `xhigh`,
|
|
249
|
+
// which is only correct if the profile knows the role's real model.
|
|
250
|
+
out[role] = resolveEffort(levelsForRole(p, role), want)
|
|
251
|
+
}
|
|
252
|
+
return out
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** @param {Profile} p @returns {string | null} */
|
|
256
|
+
export function resolveDefaultVariant(p) {
|
|
257
|
+
return resolveEffort(p.effort?.levels ?? [], p.effort?.default ?? null)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Tokens held back from the input ceiling for reasoning growth and in-flight
|
|
262
|
+
* tool output. Scaled from the model, never hardcoded.
|
|
263
|
+
* @param {Profile} p @returns {number}
|
|
264
|
+
*/
|
|
265
|
+
// The roles a profile may split onto another model. Kept in step with
|
|
266
|
+
// `roleModels` in profiles/schema.json, which nothing validates at runtime.
|
|
267
|
+
const SPLITTABLE_ROLES = ["plan", "build", "reviewer", "locate", "verify"]
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The model whose limits govern context management: the one with the widest
|
|
271
|
+
* input cap, not necessarily the base model.
|
|
272
|
+
*
|
|
273
|
+
* A split profile derives `reserved` and `prune` from a single `limits`, but
|
|
274
|
+
* the roles do not share it. Sizing for the narrowest input cap silently
|
|
275
|
+
* throttles every wider role to under half its real window, which is the whole
|
|
276
|
+
* point of running them on a bigger model. Sizing for the widest means no role
|
|
277
|
+
* can be under-reserved, which is the direction that has to be safe.
|
|
278
|
+
*
|
|
279
|
+
* @param {Profile} p
|
|
280
|
+
* @returns {number}
|
|
281
|
+
*/
|
|
282
|
+
export function inputCap(p) {
|
|
283
|
+
let cap = p.limits.input ?? p.limits.context
|
|
284
|
+
for (const s of roleSplitModels(p)) {
|
|
285
|
+
const other = s.limits?.input ?? s.limits?.context
|
|
286
|
+
if (typeof other === "number" && other > cap) cap = other
|
|
287
|
+
}
|
|
288
|
+
return cap
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @param {Profile} p
|
|
293
|
+
* @returns {number}
|
|
294
|
+
*/
|
|
295
|
+
export function reservedTokens(p) {
|
|
296
|
+
const share = p.effort?.reserveShare ?? 0.12
|
|
297
|
+
return Math.max(8000, Math.round(inputCap(p) * share))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Context budget above which pruning should already be working.
|
|
302
|
+
* @param {Profile} p @returns {number}
|
|
303
|
+
*/
|
|
304
|
+
export function pruneThreshold(p) {
|
|
305
|
+
return inputCap(p) - reservedTokens(p)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Minimal YAML reader for retention.yml: a two-level map of small records,
|
|
310
|
+
* including folded block scalars (`note: >-`). Handling indentation, folded
|
|
311
|
+
* blocks and plain scalars costs about forty lines, which is less than a
|
|
312
|
+
* dependency for a file this shape.
|
|
313
|
+
*
|
|
314
|
+
* @returns {Record<string, RetentionRecord>}
|
|
315
|
+
*/
|
|
316
|
+
export function loadRetention() {
|
|
317
|
+
const file = join(ROOT, "retention.yml")
|
|
318
|
+
if (!existsSync(file)) return {}
|
|
319
|
+
const out = /** @type {Record<string, any>} */ ({})
|
|
320
|
+
const lines = readFileSync(file, "utf8").split("\n")
|
|
321
|
+
|
|
322
|
+
let current = null
|
|
323
|
+
let i = 0
|
|
324
|
+
|
|
325
|
+
while (i < lines.length) {
|
|
326
|
+
const raw = lines[i]
|
|
327
|
+
const line = raw.trim()
|
|
328
|
+
const indent = raw.length - raw.trimStart().length
|
|
329
|
+
|
|
330
|
+
if (!line || line.startsWith("#")) {
|
|
331
|
+
i++
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
// A model key sits at two-space indent under `models:`. Keys are written
|
|
335
|
+
// provider-qualified already, so do not re-prefix them.
|
|
336
|
+
if (indent === 2 && line.endsWith(":")) {
|
|
337
|
+
const key = line.slice(0, -1).trim()
|
|
338
|
+
current = key.includes("/") ? key : `opencode/${key}`
|
|
339
|
+
out[current] = { retention: "unknown" }
|
|
340
|
+
i++
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
if (!current) {
|
|
344
|
+
i++
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const field = line.match(/^(retention|verified|source|note):\s*(.*)$/)
|
|
349
|
+
if (field) {
|
|
350
|
+
const name = field[1]
|
|
351
|
+
const rest = field[2].trim()
|
|
352
|
+
if (/^[>|][-+]?$/.test(rest)) {
|
|
353
|
+
// Folded or literal block: gather every following line indented deeper
|
|
354
|
+
// than its key, then collapse whitespace into a one-line note.
|
|
355
|
+
const parts = []
|
|
356
|
+
let j = i + 1
|
|
357
|
+
for (; j < lines.length; j++) {
|
|
358
|
+
const l = lines[j]
|
|
359
|
+
if (!l.trim()) {
|
|
360
|
+
parts.push("")
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
if (l.length - l.trimStart().length <= indent) break
|
|
364
|
+
parts.push(l.trim())
|
|
365
|
+
}
|
|
366
|
+
out[current][name] = parts.join(" ").replace(/\s+/g, " ").trim()
|
|
367
|
+
i = j
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
out[current][name] = rest.replace(/^["']|["']$/g, "")
|
|
371
|
+
i++
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
i++
|
|
375
|
+
}
|
|
376
|
+
return out
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Retention as declared in retention.yml, which wins over the profile copy.
|
|
381
|
+
* Accepts either a bare profile id or an already-qualified provider/model.
|
|
382
|
+
* @param {string} id @returns {RetentionRecord | undefined}
|
|
383
|
+
*/
|
|
384
|
+
export function retentionFor(id) {
|
|
385
|
+
const table = loadRetention()
|
|
386
|
+
return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`]
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Every model a profile can put behind the user, base plus any split roles.
|
|
391
|
+
*
|
|
392
|
+
* @param {Profile} p
|
|
393
|
+
* @returns {string[]}
|
|
394
|
+
*/
|
|
395
|
+
export function modelsInPlay(p) {
|
|
396
|
+
const out = [p.model]
|
|
397
|
+
for (const rm of Object.values(p.roleModels ?? {})) {
|
|
398
|
+
if (!out.includes(rm.model)) out.push(rm.model)
|
|
399
|
+
}
|
|
400
|
+
return out
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The split roles, each with the limits of the model that role actually runs
|
|
405
|
+
* on. A role on a different model has a different output cap, and the header
|
|
406
|
+
* has to say so: reading "output cap 524,288" and assuming `build` can spend
|
|
407
|
+
* all of it is wrong whenever build is split onto a smaller model.
|
|
408
|
+
*
|
|
409
|
+
* @param {Profile} p
|
|
410
|
+
* @returns {{ role: string, model: string, levels: string[], limits: Profile["limits"] | null }[]}
|
|
411
|
+
*/
|
|
412
|
+
export function roleSplitModels(p) {
|
|
413
|
+
return Object.entries(p.roleModels ?? {}).map(([role, rm]) => {
|
|
414
|
+
let limits = null
|
|
415
|
+
try {
|
|
416
|
+
limits = loadProfile(rm.model)?.limits ?? null
|
|
417
|
+
} catch {
|
|
418
|
+
limits = null
|
|
419
|
+
}
|
|
420
|
+
return { role, model: rm.model, levels: rm.levels ?? [], limits }
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Ordered safest-first: a profile is only as private as its least private model.
|
|
425
|
+
const RETENTION_SEVERITY = ["zero-retention", "unknown", "may-train"]
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The retention that actually applies once every role is considered, which is
|
|
429
|
+
* what a config header has to print. `retention.yml` is the source of truth.
|
|
430
|
+
*
|
|
431
|
+
* @param {Profile} p
|
|
432
|
+
* @returns {{ retention: string, worst: string | null }}
|
|
433
|
+
*/
|
|
434
|
+
export function effectiveRetention(p) {
|
|
435
|
+
let worst = null
|
|
436
|
+
let retention = "zero-retention"
|
|
437
|
+
for (const id of modelsInPlay(p)) {
|
|
438
|
+
const declared = retentionFor(id)?.retention ?? "unknown"
|
|
439
|
+
if (declared === "may-train") {
|
|
440
|
+
return { retention: "may-train", worst: id }
|
|
441
|
+
}
|
|
442
|
+
if (!worst || RETENTION_SEVERITY.indexOf(declared) > RETENTION_SEVERITY.indexOf(worst)) {
|
|
443
|
+
worst = declared
|
|
444
|
+
}
|
|
445
|
+
retention = worst
|
|
446
|
+
}
|
|
447
|
+
return { retention, worst }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Where opencode keeps its config, honouring the same env var it does. */
|
|
451
|
+
export function configFilePath() {
|
|
452
|
+
const dir =
|
|
453
|
+
process.env.OPENCODE_CONFIG_DIR ??
|
|
454
|
+
join(process.env.HOME ?? "", ".config", "opencode")
|
|
455
|
+
return join(dir, "opencode.jsonc")
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Read the active profile id from the config file on disk.
|
|
460
|
+
*
|
|
461
|
+
* This exists because the `config` hook cannot be the source. OpenCode's
|
|
462
|
+
* Config schema sets `additionalProperties: false`, so a custom `harness` key
|
|
463
|
+
* written into the file is stripped from the object handed to the hook. The
|
|
464
|
+
* file survives; the in-memory config does not. Verified against the 1.18.35
|
|
465
|
+
* schema.
|
|
466
|
+
*
|
|
467
|
+
* @returns {string | null}
|
|
468
|
+
*/
|
|
469
|
+
export function profileIdFromConfig() {
|
|
470
|
+
const fromEnv = process.env.OPENCODE_HARNESS_PROFILE
|
|
471
|
+
if (fromEnv) return fromEnv.trim() || null
|
|
472
|
+
|
|
473
|
+
const file = configFilePath()
|
|
474
|
+
if (!existsSync(file)) return null
|
|
475
|
+
try {
|
|
476
|
+
const text = readFileSync(file, "utf8")
|
|
477
|
+
const json = text.slice(text.indexOf("{"))
|
|
478
|
+
const parsed = JSON.parse(json)
|
|
479
|
+
const id = parsed?.harness?.profile
|
|
480
|
+
return typeof id === "string" && id ? id : null
|
|
481
|
+
} catch {
|
|
482
|
+
return null
|
|
483
|
+
}
|
|
484
|
+
}
|