opencode-harness-kit 0.2.0 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +301 -0
- package/GLOBAL.md +50 -13
- package/MIGRATION.md +140 -0
- package/README.md +276 -122
- package/bin/oc-harness.mjs +571 -135
- package/dist/index.js +453 -159
- package/docs/KNOWN-LIMITS.md +189 -4
- package/docs/RESEARCH.md +80 -5
- package/docs/ROUTING.md +254 -0
- package/lanes.json +136 -0
- package/lanes.schema.json +55 -0
- package/package.json +13 -11
- package/profiles/beam-free.json +51 -0
- package/profiles/big-pickle.json +45 -0
- package/profiles/deepseek-v4.1-flash-free.json +49 -0
- package/profiles/fleet-all.json +139 -0
- package/profiles/fleet-safe.json +139 -0
- package/profiles/glm-5.3-flash-free.json +49 -0
- package/profiles/index.json +120 -37
- package/profiles/longcat-2.5-preview-free.json +14 -10
- package/profiles/mimo-v2.6-flash-free.json +44 -0
- package/profiles/minimax-m3.1-flash-free.json +51 -0
- package/profiles/nemotron-3-ultra-free.json +44 -0
- package/profiles/nemotron-3.5-lightning-free.json +44 -0
- package/profiles/nemotron-3.5-super-vl-free.json +52 -0
- package/profiles/schema.json +30 -11
- package/{agents → prompts}/build.md +2 -35
- package/prompts/doc.md +42 -0
- package/{agents → prompts}/locate.md +1 -16
- package/prompts/longform.md +34 -0
- package/{agents → prompts}/plan.md +2 -25
- package/{agents → prompts}/reviewer.md +2 -22
- package/prompts/schema.md +30 -0
- package/prompts/sweep.md +29 -0
- package/{agents → prompts}/verify.md +6 -29
- package/prompts/vision.md +33 -0
- package/retention.yml +75 -57
- package/skills/space-build/SKILL.md +16 -0
- package/skills/space-finish/SKILL.md +0 -10
- package/skills/space-review/SKILL.md +8 -0
- package/src/config.js +94 -69
- package/src/driver/run.mjs +237 -0
- package/src/effort.js +59 -22
- package/src/index.ts +15 -2
- package/src/lane-usage.js +142 -0
- package/src/profile.js +615 -221
- package/src/router.ts +148 -39
- package/src/tasks.js +248 -0
- package/src/telemetry.ts +98 -0
- package/docs/demo/demo.gif +0 -0
- package/docs/demo/demo.mp4 +0 -0
- package/docs/demo/render.py +0 -146
- package/examples/muse-spark-1.3-contributor-free.json +0 -41
- package/profiles/deepseek-v4-flash-free.json +0 -41
- package/profiles/muse-split-free.json +0 -64
- package/profiles/space-bunny-free.json +0 -42
- package/profiles/x-preview-f-free.json +0 -41
package/src/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
|
-
*
|
|
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
|
-
*
|
|
46
|
-
*
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
*
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (!
|
|
190
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
for (const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
262
|
-
*
|
|
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
|
|
283
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
300
|
+
* @param {string} id
|
|
301
|
+
* @returns {RetentionRecord | undefined}
|
|
383
302
|
*/
|
|
384
303
|
export function retentionFor(id) {
|
|
385
304
|
const table = loadRetention()
|
|
@@ -387,82 +306,557 @@ export function retentionFor(id) {
|
|
|
387
306
|
}
|
|
388
307
|
|
|
389
308
|
/**
|
|
390
|
-
*
|
|
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.
|
|
405
|
+
*
|
|
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]
|
|
424
|
+
* @returns {string[]}
|
|
425
|
+
*/
|
|
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.
|
|
391
641
|
*
|
|
392
642
|
* @param {Profile} p
|
|
643
|
+
* @param {{ unhealthy?: Set<string> }} [opts]
|
|
393
644
|
* @returns {string[]}
|
|
394
645
|
*/
|
|
395
|
-
export function modelsInPlay(p) {
|
|
646
|
+
export function modelsInPlay(p, opts = {}) {
|
|
396
647
|
const out = [p.model]
|
|
397
|
-
for (const
|
|
398
|
-
|
|
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
|
|
405
|
-
* on. A
|
|
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
|
|
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(
|
|
670
|
+
limits = loadProfile(resolved.model)?.limits ?? null
|
|
417
671
|
} catch {
|
|
418
672
|
limits = null
|
|
419
673
|
}
|
|
420
|
-
return { role, model:
|
|
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
|
|
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 =
|
|
439
|
-
if (
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
worst =
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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}`
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Parse JSONC the way OpenCode does: comments and trailing commas are legal.
|
|
750
|
+
*
|
|
751
|
+
* A regex that strips `//` cannot be used here. The generated config carries
|
|
752
|
+
* `"$schema": "https://opencode.ai/config.json"`, so a naive strip corrupts the
|
|
753
|
+
* URL; and a trailing comma -- ordinary in a hand-edited `.jsonc` -- makes
|
|
754
|
+
* `JSON.parse` throw, which used to surface as an empty object and silently
|
|
755
|
+
* dropped every hand-tuned key in `oc-harness use`. This walks the text once,
|
|
756
|
+
* tracking string state, so both are handled correctly.
|
|
757
|
+
*
|
|
758
|
+
* @param {string} text
|
|
759
|
+
* @returns {Record<string, unknown> | null} null when the text is not valid JSONC
|
|
760
|
+
*/
|
|
761
|
+
export function parseJsonc(text) {
|
|
762
|
+
let out = ""
|
|
763
|
+
let i = 0
|
|
764
|
+
let inString = false
|
|
765
|
+
let escaped = false
|
|
766
|
+
/** True once a `,` has been emitted that a `}` or `]` may turn out to cancel. */
|
|
767
|
+
let pendingComma = false
|
|
768
|
+
|
|
769
|
+
const n = text.length
|
|
770
|
+
while (i < n) {
|
|
771
|
+
const ch = text[i]
|
|
772
|
+
|
|
773
|
+
if (inString) {
|
|
774
|
+
out += ch
|
|
775
|
+
if (escaped) escaped = false
|
|
776
|
+
else if (ch === "\\") escaped = true
|
|
777
|
+
else if (ch === '"') inString = false
|
|
778
|
+
i++
|
|
779
|
+
continue
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (ch === '"') {
|
|
783
|
+
if (pendingComma) {
|
|
784
|
+
out += ","
|
|
785
|
+
pendingComma = false
|
|
786
|
+
}
|
|
787
|
+
out += ch
|
|
788
|
+
inString = true
|
|
789
|
+
i++
|
|
790
|
+
continue
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
794
|
+
while (i < n && text[i] !== "\n") i++
|
|
795
|
+
continue
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
799
|
+
i += 2
|
|
800
|
+
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++
|
|
801
|
+
i += 2
|
|
802
|
+
continue
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (ch === ",") {
|
|
806
|
+
// A second comma before any value is malformed. Repairing `,,,` into one
|
|
807
|
+
// separator would mean guessing at a file we are about to rewrite, which
|
|
808
|
+
// is the exact failure this parser exists to prevent.
|
|
809
|
+
if (pendingComma) return null
|
|
810
|
+
// Hold the comma back: if the next significant character closes an
|
|
811
|
+
// object or array, this comma was illegal trailing punctuation.
|
|
812
|
+
pendingComma = true
|
|
813
|
+
i++
|
|
814
|
+
continue
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
|
|
818
|
+
out += ch
|
|
819
|
+
i++
|
|
820
|
+
continue
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (ch === "}" || ch === "]") {
|
|
824
|
+
// A closer cancels any comma held back before it.
|
|
825
|
+
pendingComma = false
|
|
826
|
+
out += ch
|
|
827
|
+
i++
|
|
828
|
+
continue
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Every other significant character settles a comma held back across
|
|
832
|
+
// whitespace or a comment. Miss this on the string branch below and a
|
|
833
|
+
// comment after `1, // note` swallows the separator entirely.
|
|
834
|
+
if (pendingComma) {
|
|
835
|
+
out += ","
|
|
836
|
+
pendingComma = false
|
|
837
|
+
}
|
|
838
|
+
out += ch
|
|
839
|
+
i++
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
try {
|
|
843
|
+
const parsed = JSON.parse(out)
|
|
844
|
+
return parsed && typeof parsed === "object" ? parsed : null
|
|
845
|
+
} catch {
|
|
846
|
+
// An empty or comments-only file is not malformed JSONC, it is an empty
|
|
847
|
+
// config, and `oc-harness use` has to be able to fill one in. Only report
|
|
848
|
+
// a parse failure when there was actually something to parse.
|
|
849
|
+
if (!out.trim()) return {}
|
|
850
|
+
return null
|
|
851
|
+
}
|
|
456
852
|
}
|
|
457
853
|
|
|
458
854
|
/**
|
|
459
855
|
* Read the active profile id from the config file on disk.
|
|
460
856
|
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
* file survives; the in-memory config does not. Verified against the 1.18.35
|
|
465
|
-
* schema.
|
|
857
|
+
* Returns null when the file is absent, unreadable, or not valid JSONC. Callers
|
|
858
|
+
* fall back to the profile named in the `config` hook, so a null here degrades
|
|
859
|
+
* to the default profile rather than failing.
|
|
466
860
|
*
|
|
467
861
|
* @returns {string | null}
|
|
468
862
|
*/
|
|
@@ -473,10 +867,10 @@ export function profileIdFromConfig() {
|
|
|
473
867
|
const file = configFilePath()
|
|
474
868
|
if (!existsSync(file)) return null
|
|
475
869
|
try {
|
|
476
|
-
const
|
|
477
|
-
const
|
|
478
|
-
const
|
|
479
|
-
|
|
870
|
+
const parsed = parseJsonc(readFileSync(file, "utf8"))
|
|
871
|
+
const harness = parsed?.harness
|
|
872
|
+
const id =
|
|
873
|
+
harness && typeof harness === "object" ? /** @type {{profile?: unknown}} */ (harness).profile : null
|
|
480
874
|
return typeof id === "string" && id ? id : null
|
|
481
875
|
} catch {
|
|
482
876
|
return null
|