opencode-harness-kit 0.2.1 → 0.3.4

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 (51) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/GLOBAL.md +50 -13
  3. package/MIGRATION.md +140 -0
  4. package/README.md +233 -116
  5. package/bin/oc-harness.mjs +474 -96
  6. package/dist/index.js +378 -155
  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 +12 -8
  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-review/SKILL.md +8 -0
  40. package/src/config.js +94 -69
  41. package/src/effort.js +59 -22
  42. package/src/index.ts +15 -2
  43. package/src/lane-usage.js +142 -0
  44. package/src/profile.js +502 -212
  45. package/src/router.ts +148 -39
  46. package/src/telemetry.ts +98 -0
  47. package/examples/muse-spark-1.3-contributor-free.json +0 -41
  48. package/profiles/deepseek-v4-flash-free.json +0 -41
  49. package/profiles/muse-split-free.json +0 -64
  50. package/profiles/space-bunny-free.json +0 -42
  51. package/profiles/x-preview-f-free.json +0 -41
package/src/profile.js CHANGED
@@ -1,4 +1,4 @@
1
- // Profile loading, effort resolution, and retention lookup.
1
+ // Profile loading, lane resolution, effort resolution, and retention lookup.
2
2
  //
3
3
  // The rule this file exists to enforce: no plugin in src/ names a model. Every
4
4
  // model-specific decision comes from a profile JSON, so switching models means
@@ -6,18 +6,58 @@
6
6
  //
7
7
  // Plain JS with JSDoc types on purpose: the opencode plugin loads .ts, but the
8
8
  // CLI has to run under plain node, so the shared core stays dependency-free.
9
+ //
10
+ // Three ideas the fleet profiles are built on:
11
+ //
12
+ // 1. A LANE is a job. `lanes.json` says what each of the ten jobs is for and
13
+ // nothing about models. A profile says which lanes it enables and which
14
+ // model serves each one.
15
+ //
16
+ // 2. A lane's model is a CHAIN, not a pin. Free routes get withdrawn in
17
+ // days, so every lane lists the models that could serve it, best first.
18
+ // The first rung the profile's retention ceiling allows is the one that
19
+ // runs it.
20
+ //
21
+ // 3. Context management is sized by the NARROWEST model in play. A reserve
22
+ // sized by the widest one would leave a 200k lane with 74k usable
23
+ // tokens, which is worse than a thin buffer on a 1M model.
9
24
 
10
25
  import { readFileSync, existsSync, readdirSync } from "node:fs"
11
26
  import { dirname, join, resolve } from "node:path"
27
+ import { homedir } from "node:os"
12
28
  import { fileURLToPath } from "node:url"
13
29
 
14
30
  const HERE = dirname(fileURLToPath(import.meta.url))
15
- export const ROOT = resolve(HERE, "..")
16
31
 
17
32
  /**
18
- * @typedef {"zero-retention" | "may-train" | "unknown"} Retention
33
+ * Where the kit's data lives.
34
+ *
35
+ * The bundle is one directory under the package root, so the root is normally
36
+ * its parent. But the plugin is also shipped as a file that somebody copied
37
+ * into a config directory, and a copied bundle has no `profiles/` beside it -
38
+ * every profile lookup then fails with "no profile named ...", which reads as
39
+ * a broken install rather than a moved file. Walking up until the data is
40
+ * found covers a bundle that was copied into a subdirectory.
41
+ */
42
+ /** @param {string} start @returns {string} */
43
+ function findRoot(start) {
44
+ let dir = start
45
+ for (let i = 0; i < 6; i++) {
46
+ if (existsSync(join(dir, "profiles"))) return dir
47
+ const parent = dirname(dir)
48
+ if (parent === dir) break
49
+ dir = parent
50
+ }
51
+ return resolve(start, "..")
52
+ }
53
+
54
+ export const ROOT = findRoot(resolve(HERE, ".."))
55
+
56
+ /**
57
+ * @typedef {"zero-retention" | "retained" | "may-train" | "unknown"} Retention
19
58
  * @typedef {string | null} EffortRequest
20
- * @typedef {{ model: string, levels?: string[] }} RoleModel
59
+ * @typedef {{ model: string, levels?: string[], chain?: string[] }} RoleModel
60
+ * @typedef {{ description: string, mode?: string, steps?: number, permission?: Record<string, any>, prompt: string }} Lane
21
61
  * @typedef {object} Profile
22
62
  * @property {string} id
23
63
  * @property {string} [title]
@@ -26,6 +66,7 @@ export const ROOT = resolve(HERE, "..")
26
66
  * @property {string | null} [family]
27
67
  * @property {string} [verified]
28
68
  * @property {Retention} retention
69
+ * @property {Retention} [maxRetention]
29
70
  * @property {string} [notes]
30
71
  * @property {{ context: number, input?: number, output: number }} limits
31
72
  * @property {{ reasoning?: boolean, temperature?: boolean, toolCall?: boolean,
@@ -42,11 +83,36 @@ export const ROOT = resolve(HERE, "..")
42
83
  export class ProfileError extends Error {}
43
84
 
44
85
  /**
45
- * Profiles live in profiles/ (verified) and examples/ (contrarian, still
46
- * installable). schema.json and index.json are not profiles.
86
+ * The lanes a profile may enable. Five are workflow positions in the plan ->
87
+ * build -> review -> finish loop; five are capability lanes that exist because
88
+ * one free model can do a job none of the other nine can.
89
+ */
90
+ export const LANE_NAMES = [
91
+ "build",
92
+ "plan",
93
+ "reviewer",
94
+ "locate",
95
+ "verify",
96
+ "vision",
97
+ "doc",
98
+ "schema",
99
+ "sweep",
100
+ "longform",
101
+ ]
102
+
103
+ /** The lanes that are part of the plan -> build -> review -> finish loop. */
104
+ export const WORKFLOW_LANES = ["build", "plan", "reviewer", "locate", "verify"]
105
+
106
+ // Retention, ordered safest-first. A fleet is labelled with the worst level
107
+ // among the models it actually resolves to.
108
+ const RETENTION_SEVERITY = ["zero-retention", "retained", "unknown", "may-train"]
109
+
110
+ /**
111
+ * Profiles live in profiles/ (verified). schema.json and index.json are not
112
+ * profiles.
47
113
  */
48
114
  export function profileDirs() {
49
- return [join(ROOT, "profiles"), join(ROOT, "examples")]
115
+ return [join(ROOT, "profiles")]
50
116
  }
51
117
 
52
118
  /** @param {string} id @returns {string | null} */
@@ -102,6 +168,7 @@ export function normalize(p) {
102
168
  default: p.effort?.default ?? null,
103
169
  reserveShare: p.effort?.reserveShare ?? 0.12,
104
170
  },
171
+ maxRetention: p.maxRetention ?? p.retention,
105
172
  prune: {
106
173
  enabled: p.prune?.enabled ?? false,
107
174
  keepRecentToolCalls: p.prune?.keepRecentToolCalls ?? 6,
@@ -112,198 +179,49 @@ export function normalize(p) {
112
179
  }
113
180
  }
114
181
 
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
- }
182
+ // --- lanes -----------------------------------------------------------------
170
183
 
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"]
184
+ /** @type {Record<string, Lane> | null} */
185
+ let laneCache = null
174
186
 
175
187
  /**
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}
188
+ * The lane table: what each job is for, and nothing about models.
189
+ * @returns {Record<string, Lane>}
185
190
  */
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
191
+ export function loadLanes() {
192
+ if (laneCache) return laneCache
193
+ const file = join(ROOT, "lanes.json")
194
+ if (!existsSync(file)) {
195
+ throw new ProfileError(`lane table missing: ${file}`)
197
196
  }
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
197
+ const parsed = JSON.parse(readFileSync(file, "utf8"))
198
+ const lanes = parsed.lanes ?? {}
199
+ for (const [name, lane] of Object.entries(lanes)) {
200
+ if (!lane.description) {
201
+ throw new ProfileError(`lanes.json: lane "${name}" has no description`)
202
+ }
203
+ if (!lane.prompt) {
204
+ throw new ProfileError(`lanes.json: lane "${name}" has no prompt file`)
207
205
  }
208
206
  }
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)
207
+ laneCache = lanes
208
+ return lanes
258
209
  }
259
210
 
260
211
  /**
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}
212
+ * @param {string} name
213
+ * @returns {Lane | null}
281
214
  */
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
215
+ export function laneMeta(name) {
216
+ return loadLanes()[name] ?? null
289
217
  }
290
218
 
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))
219
+ /** @returns {string[]} */
220
+ export function laneNames() {
221
+ return Object.keys(loadLanes())
298
222
  }
299
223
 
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
- }
224
+ // --- retention --------------------------------------------------------------
307
225
 
308
226
  /**
309
227
  * Minimal YAML reader for retention.yml: a two-level map of small records,
@@ -379,7 +297,8 @@ export function loadRetention() {
379
297
  /**
380
298
  * Retention as declared in retention.yml, which wins over the profile copy.
381
299
  * Accepts either a bare profile id or an already-qualified provider/model.
382
- * @param {string} id @returns {RetentionRecord | undefined}
300
+ * @param {string} id
301
+ * @returns {RetentionRecord | undefined}
383
302
  */
384
303
  export function retentionFor(id) {
385
304
  const table = loadRetention()
@@ -387,72 +306,443 @@ export function retentionFor(id) {
387
306
  }
388
307
 
389
308
  /**
390
- * Every model a profile can put behind the user, base plus any split roles.
309
+ * The retention of a model id, qualified or not.
310
+ * @param {string} model
311
+ * @returns {Retention}
312
+ */
313
+ export function modelRetention(model) {
314
+ const declared = retentionFor(model)?.retention ?? "unknown"
315
+ return /** @type {Retention} */ (
316
+ ["zero-retention", "retained", "may-train", "unknown"].includes(declared)
317
+ ? declared
318
+ : "unknown"
319
+ )
320
+ }
321
+
322
+ /**
323
+ * Whether a model's retention is at or below a ceiling.
324
+ *
325
+ * `unknown` is never allowed: a lane that would run on a model nobody has
326
+ * stated the terms of is worse than a lane that runs on a worse model with
327
+ * known terms.
328
+ *
329
+ * @param {string} model
330
+ * @param {Retention} ceiling
331
+ * @returns {boolean}
332
+ */
333
+ export function retentionAllowed(model, ceiling) {
334
+ const level = modelRetention(model)
335
+ if (level === "unknown") return false
336
+ const max = RETENTION_SEVERITY.indexOf(ceiling)
337
+ const have = RETENTION_SEVERITY.indexOf(level)
338
+ if (max < 0) return false
339
+ return have <= max
340
+ }
341
+
342
+ // --- lane resolution --------------------------------------------------------
343
+
344
+ /**
345
+ * The ordered models that may serve a lane.
346
+ *
347
+ * A lane with no `roleModels` entry runs on the profile's base model, which
348
+ * is the single-model profile shape. A lane with a `chain` walks it.
349
+ *
350
+ * @param {Profile} p
351
+ * @param {string} role
352
+ * @returns {string[]}
353
+ */
354
+ export function resolveLaneChain(p, role) {
355
+ const rm = p.roleModels?.[role]
356
+ if (!rm) return [p.model]
357
+ const chain = rm.chain?.length ? rm.chain : [rm.model]
358
+ return chain
359
+ }
360
+
361
+ /**
362
+ * The model that actually serves a lane right now.
363
+ *
364
+ * The first rung of the chain that is allowed by the retention ceiling and is
365
+ * not marked unhealthy wins. When no rung qualifies, the lane falls back to
366
+ * the profile's base model, so a lane is never left without a model.
367
+ *
368
+ * @param {Profile} p
369
+ * @param {string} role
370
+ * @param {{ unhealthy?: Set<string> }} [opts]
371
+ * @returns {{ model: string, levels: string[] }}
372
+ */
373
+ export function resolveLaneModel(p, role, opts = {}) {
374
+ const unhealthy = opts.unhealthy ?? new Set()
375
+ const ceiling = /** @type {Retention} */ (p.maxRetention ?? p.retention)
376
+ for (const candidate of resolveLaneChain(p, role)) {
377
+ if (unhealthy.has(candidate)) continue
378
+ if (!retentionAllowed(candidate, ceiling)) continue
379
+ return { model: candidate, levels: levelsOfModel(candidate) }
380
+ }
381
+ // Nothing in the chain qualifies. Use the first rung anyway rather than
382
+ // leaving the lane without a model, and let the config header say what the
383
+ // effective retention turned out to be.
384
+ const fallback = resolveLaneChain(p, role)[0] ?? p.model
385
+ return { model: fallback, levels: levelsOfModel(fallback) }
386
+ }
387
+
388
+ /**
389
+ * The effort levels a model publishes, read from that model's own profile.
390
+ *
391
+ * @param {string} model
392
+ * @returns {string[]}
393
+ */
394
+ export function levelsOfModel(model) {
395
+ try {
396
+ return loadProfile(model).effort?.levels ?? []
397
+ } catch {
398
+ return []
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Model and effort levels for one lane, or null when the lane stays on the
404
+ * profile's base model with no chain.
391
405
  *
392
406
  * @param {Profile} p
407
+ * @param {string} role
408
+ * @param {{ unhealthy?: Set<string> }} [opts]
409
+ * @returns {{ model: string, levels: string[] } | null}
410
+ */
411
+ export function resolveRoleModel(p, role, opts = {}) {
412
+ const rm = p.roleModels?.[role]
413
+ if (!rm) return null
414
+ return resolveLaneModel(p, role, opts)
415
+ }
416
+
417
+ /**
418
+ * Effort levels a lane's own model offers. A lane with no chain falls back to
419
+ * the profile's own levels, so an unsplit profile behaves exactly as before.
420
+ *
421
+ * @param {Profile} p
422
+ * @param {string} role
423
+ * @param {{ unhealthy?: Set<string> }} [opts]
393
424
  * @returns {string[]}
394
425
  */
395
- export function modelsInPlay(p) {
426
+ function levelsForRole(p, role, opts) {
427
+ const rm = p.roleModels?.[role]
428
+ if (rm) return resolveLaneModel(p, role, opts).levels
429
+ return p.effort?.levels ?? []
430
+ }
431
+
432
+ /**
433
+ * @param {Profile} p
434
+ * @param {{ unhealthy?: Set<string> }} [opts]
435
+ * @returns {Record<string, string | null>}
436
+ */
437
+ export function resolveRoles(p, opts = {}) {
438
+ const out = /** @type {Record<string, string | null>} */ ({})
439
+ for (const [role, want] of Object.entries(p.roles ?? {})) {
440
+ // Resolve against the levels of the model this role actually runs on. A
441
+ // role asking for `max` on a model topping out at `xhigh` gets `xhigh`,
442
+ // which is only correct if the profile knows the role's real model.
443
+ out[role] = resolveEffort(levelsForRole(p, role, opts), want)
444
+ }
445
+ return out
446
+ }
447
+
448
+ /** @param {Profile} p @returns {string | null} */
449
+ export function resolveDefaultVariant(p) {
450
+ return resolveEffort(p.effort?.levels ?? [], p.effort?.default ?? null)
451
+ }
452
+
453
+ // Canonical ordering for reasoning effort, used to snap a requested level onto
454
+ // whatever the target model actually offers.
455
+ const CANON = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
456
+
457
+ /**
458
+ * Pick the available level closest to what a role asked for.
459
+ *
460
+ * A role asking for `max` on a model topping out at `xhigh` gets `xhigh`. A
461
+ * model with no tunable effort resolves to null, meaning "emit no variant"
462
+ * rather than "emit a level the model does not have".
463
+ *
464
+ * @param {string[]} levels
465
+ * @param {EffortRequest} requested
466
+ * @returns {string | null}
467
+ */
468
+ export function resolveEffort(levels, requested) {
469
+ /** @type {string | null} */
470
+ if (!requested) return null
471
+ if (!levels || levels.length === 0) return null
472
+ if (levels.includes(requested)) return requested
473
+
474
+ const want = CANON.indexOf(requested)
475
+ if (want < 0) {
476
+ // Unknown label. Snap to the top level, which is the safe reading of a
477
+ // role that asked for something strong.
478
+ return levels[levels.length - 1] ?? null
479
+ }
480
+ let best = null
481
+ let bestDist = Infinity
482
+ for (const level of levels) {
483
+ const have = CANON.indexOf(level)
484
+ if (have < 0) continue
485
+ const d = Math.abs(have - want)
486
+ if (d < bestDist) {
487
+ bestDist = d
488
+ best = level
489
+ }
490
+ }
491
+ return best ?? levels[levels.length - 1] ?? null
492
+ }
493
+
494
+ // --- validation -------------------------------------------------------------
495
+
496
+ const VALID_RETENTION = ["zero-retention", "retained", "may-train", "unknown"]
497
+
498
+ /** @param {Profile} p @param {string} [path] */
499
+ export function validate(p, path = "<memory>") {
500
+ const where = p?.id ?? path
501
+ if (!p?.id) throw new ProfileError(`${path}: missing "id"`)
502
+ if (!p.provider) throw new ProfileError(`${where}: missing "provider"`)
503
+ if (!p.model) throw new ProfileError(`${where}: missing "model"`)
504
+ if (!VALID_RETENTION.includes(p.retention)) {
505
+ throw new ProfileError(
506
+ `${where}: retention must be one of ${VALID_RETENTION.join(", ")}`,
507
+ )
508
+ }
509
+ if (p.maxRetention && !VALID_RETENTION.includes(p.maxRetention)) {
510
+ throw new ProfileError(
511
+ `${where}: maxRetention must be one of ${VALID_RETENTION.join(", ")}`,
512
+ )
513
+ }
514
+ if (p.maxRetention === "unknown") {
515
+ throw new ProfileError(`${where}: maxRetention cannot be unknown`)
516
+ }
517
+ if (!p.limits?.context || p.limits.context <= 0) {
518
+ throw new ProfileError(`${where}: limits.context must be a positive integer`)
519
+ }
520
+ if (!p.limits?.output || p.limits.output <= 0) {
521
+ throw new ProfileError(`${where}: limits.output must be a positive integer`)
522
+ }
523
+ const share = p.effort?.reserveShare
524
+ if (share !== undefined && (share < 0 || share > 0.5)) {
525
+ throw new ProfileError(
526
+ `${where}: effort.reserveShare must be between 0 and 0.5`,
527
+ )
528
+ }
529
+ for (const role of Object.keys(p.roles ?? {})) {
530
+ if (!LANE_NAMES.includes(role)) {
531
+ throw new ProfileError(
532
+ `${where}: roles.${role} is not one of ${LANE_NAMES.join(", ")}`,
533
+ )
534
+ }
535
+ if (!laneMeta(role)) {
536
+ throw new ProfileError(
537
+ `${where}: roles.${role} has no lane definition in lanes.json`,
538
+ )
539
+ }
540
+ }
541
+ for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
542
+ if (!LANE_NAMES.includes(role)) {
543
+ throw new ProfileError(
544
+ `${where}: roleModels.${role} is not one of ${LANE_NAMES.join(", ")}`,
545
+ )
546
+ }
547
+ // resolveRoles walks `roles`, so a chain for a role nobody asks for never
548
+ // reaches an agent. That reads as "installed" while doing nothing.
549
+ if (!(role in (p.roles ?? {}))) {
550
+ throw new ProfileError(
551
+ `${where}: roleModels.${role} has no matching entry in roles, so it would never apply`,
552
+ )
553
+ }
554
+ if (!rm?.model || typeof rm.model !== "string") {
555
+ throw new ProfileError(`${where}: roleModels.${role} needs a model`)
556
+ }
557
+ if (rm.levels !== undefined && !Array.isArray(rm.levels)) {
558
+ throw new ProfileError(
559
+ `${where}: roleModels.${role}.levels must be an array`,
560
+ )
561
+ }
562
+ if (rm.chain !== undefined) {
563
+ if (!Array.isArray(rm.chain) || rm.chain.length === 0) {
564
+ throw new ProfileError(
565
+ `${where}: roleModels.${role}.chain must be a non-empty array`,
566
+ )
567
+ }
568
+ if (rm.chain[0] !== rm.model) {
569
+ throw new ProfileError(
570
+ `${where}: roleModels.${role}.chain must start with its model (${rm.chain[0]} vs ${rm.model})`,
571
+ )
572
+ }
573
+ for (const rung of rm.chain) {
574
+ if (!retentionFor(rung)) {
575
+ throw new ProfileError(
576
+ `${where}: roleModels.${role}.chain names ${rung}, which has no retention.yml entry`,
577
+ )
578
+ }
579
+ }
580
+ }
581
+ // retention.yml is the only place retention is decided, so a role model
582
+ // missing from it is a profile we cannot honestly write a header for.
583
+ if (!retentionFor(rm.model)) {
584
+ throw new ProfileError(
585
+ `${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`,
586
+ )
587
+ }
588
+ }
589
+ }
590
+
591
+ // --- context sizing ---------------------------------------------------------
592
+
593
+ /**
594
+ * The model whose limits govern context management: the NARROWEST one in play.
595
+ *
596
+ * A fleet mixes models with a 1M window and models with a 200k one, and
597
+ * `compaction.reserved` is a single number for the whole config. Sizing it by
598
+ * the widest model would reserve a sixth of a 1M window - 125k tokens - and
599
+ * leave a 200k lane with 74k usable, which is where the lane stops working.
600
+ * Sizing it by the narrowest model means every lane keeps a workable window,
601
+ * and a lane with a wide window simply compacts a little later.
602
+ *
603
+ * @param {Profile} p
604
+ * @param {{ unhealthy?: Set<string> }} [opts]
605
+ * @returns {number}
606
+ */
607
+ export function inputCap(p, opts = {}) {
608
+ let cap = p.limits.input ?? p.limits.context
609
+ for (const s of roleSplitModels(p, opts)) {
610
+ const other = s.limits?.input ?? s.limits?.context
611
+ if (typeof other === "number" && other < cap) cap = other
612
+ }
613
+ return cap
614
+ }
615
+
616
+ /**
617
+ * Tokens held back from the input ceiling for reasoning growth and in-flight
618
+ * tool output. Scaled from the model, never hardcoded.
619
+ * @param {Profile} p
620
+ * @param {{ unhealthy?: Set<string> }} [opts]
621
+ * @returns {number}
622
+ */
623
+ export function reservedTokens(p, opts = {}) {
624
+ const share = p.effort?.reserveShare ?? 0.12
625
+ return Math.max(8000, Math.round(inputCap(p, opts) * share))
626
+ }
627
+
628
+ /**
629
+ * Context budget above which pruning should already be working.
630
+ * @param {Profile} p
631
+ * @param {{ unhealthy?: Set<string> }} [opts]
632
+ * @returns {number}
633
+ */
634
+ export function pruneThreshold(p, opts = {}) {
635
+ return inputCap(p, opts) - reservedTokens(p, opts)
636
+ }
637
+
638
+ /**
639
+ * Every model a profile can put behind the user: the base model plus the model
640
+ * each lane actually resolves to.
641
+ *
642
+ * @param {Profile} p
643
+ * @param {{ unhealthy?: Set<string> }} [opts]
644
+ * @returns {string[]}
645
+ */
646
+ export function modelsInPlay(p, opts = {}) {
396
647
  const out = [p.model]
397
- for (const rm of Object.values(p.roleModels ?? {})) {
398
- if (!out.includes(rm.model)) out.push(rm.model)
648
+ for (const role of Object.keys(p.roleModels ?? {})) {
649
+ const model = resolveLaneModel(p, role, opts).model
650
+ if (!out.includes(model)) out.push(model)
399
651
  }
400
652
  return out
401
653
  }
402
654
 
403
655
  /**
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
656
+ * The split lanes, each with the limits of the model that lane actually runs
657
+ * on. A lane on a different model has a different output cap, and the header
406
658
  * has to say so: reading "output cap 524,288" and assuming `build` can spend
407
659
  * all of it is wrong whenever build is split onto a smaller model.
408
660
  *
409
661
  * @param {Profile} p
662
+ * @param {{ unhealthy?: Set<string> }} [opts]
410
663
  * @returns {{ role: string, model: string, levels: string[], limits: Profile["limits"] | null }[]}
411
664
  */
412
- export function roleSplitModels(p) {
413
- return Object.entries(p.roleModels ?? {}).map(([role, rm]) => {
665
+ export function roleSplitModels(p, opts = {}) {
666
+ return Object.entries(p.roleModels ?? {}).map(([role]) => {
667
+ const resolved = resolveLaneModel(p, role, opts)
414
668
  let limits = null
415
669
  try {
416
- limits = loadProfile(rm.model)?.limits ?? null
670
+ limits = loadProfile(resolved.model)?.limits ?? null
417
671
  } catch {
418
672
  limits = null
419
673
  }
420
- return { role, model: rm.model, levels: rm.levels ?? [], limits }
674
+ return { role, model: resolved.model, levels: resolved.levels, limits }
421
675
  })
422
676
  }
423
677
 
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
678
  /**
428
- * The retention that actually applies once every role is considered, which is
679
+ * The retention that actually applies once every lane is considered, which is
429
680
  * what a config header has to print. `retention.yml` is the source of truth.
430
681
  *
682
+ * `worst` is the MODEL responsible for that level, not the level itself. The
683
+ * two used to be the same field with two different meanings - a retention
684
+ * class on one path and a model id on the other - and the header prints it as
685
+ * a model name, so a fleet that resolved to a `retained` lane said
686
+ * "but retained makes the whole file retained".
687
+ *
431
688
  * @param {Profile} p
689
+ * @param {{ unhealthy?: Set<string> }} [opts]
432
690
  * @returns {{ retention: string, worst: string | null }}
433
691
  */
434
- export function effectiveRetention(p) {
692
+ export function effectiveRetention(p, opts = {}) {
693
+ /** @type {string | null} */
435
694
  let worst = null
436
695
  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
696
+ for (const id of modelsInPlay(p, opts)) {
697
+ const declared = modelRetention(id)
698
+ if (
699
+ !worst ||
700
+ RETENTION_SEVERITY.indexOf(declared) > RETENTION_SEVERITY.indexOf(retention)
701
+ ) {
702
+ worst = id
703
+ retention = declared
444
704
  }
445
- retention = worst
446
705
  }
447
706
  return { retention, worst }
448
707
  }
449
708
 
709
+ // --- config paths -----------------------------------------------------------
710
+
450
711
  /** Where opencode keeps its config, honouring the same env var it does. */
712
+ export function configDir() {
713
+ return (
714
+ process.env.OPENCODE_CONFIG_DIR ?? join(process.env.HOME ?? "", ".config", "opencode")
715
+ )
716
+ }
717
+
718
+ /** @returns {string} */
451
719
  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")
720
+ return join(configDir(), "opencode.jsonc")
721
+ }
722
+
723
+ /**
724
+ * The config directory written the way opencode's `{file:...}` references
725
+ * want it: a tilde when it is the default, an absolute path otherwise.
726
+ *
727
+ * A relative reference is silently ignored by opencode, so a prompt file that
728
+ * resolves to nothing leaves the lane with no instructions and no error.
729
+ *
730
+ * @returns {string}
731
+ */
732
+ export function configDirRef() {
733
+ const dir = configDir()
734
+ const home = homedir()
735
+ if (dir === join(home, ".config", "opencode")) return "~/.config/opencode"
736
+ return dir
737
+ }
738
+
739
+ /**
740
+ * The reference opencode resolves for a lane's prompt file.
741
+ * @param {string} lane
742
+ * @returns {string}
743
+ */
744
+ export function promptRef(lane) {
745
+ return `{file:${configDirRef()}/prompts/${lane}.md}`
456
746
  }
457
747
 
458
748
  /**