@bd7pil/reeve 0.0.0-stage → 0.1.1

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/src/chain.js ADDED
@@ -0,0 +1,358 @@
1
+ // 链文件(`~/.reeve/chain.json`)↔ Bifrost 的 `governance.routing_rules` 的**唯一映射处**。
2
+ //
3
+ // 为什么需要这一层:链原本写死在链基座里(仓库 `proxy/config.json` / 插件自带的
4
+ // `default-chain.json`),改一次要重启 Bifrost。使用者要的是"面板里调顺序、加回退"——
5
+ // 那就得有一份**只属于 REEVE 的小配置**,由插件读、由面板写,派生出的规则用
6
+ // `/api/routing/rules` 热更(实测:热改能持久、不用重启)。
7
+ //
8
+ // 分工(别越界):
9
+ // · chain.json 只说三件事:每档位「先打谁」(targets)、「打不通再打谁」(fallbacks)。
10
+ // · provider 的 base_url / key / 路径覆盖仍然只在链基座里(那属部署,要重启)。
11
+ // · 派生出的规则 id 固定 `reeve-<tier>`,CEL 固定 `model == "reeve/<tier>"`。
12
+ //
13
+ // 纯函数 + 注入式 IO:全部可在单测里跑(`plugin/test/chain.mjs`)。
14
+
15
+ import { readFile, stat } from 'node:fs/promises'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
18
+
19
+ /** 三个档位(顺序即面板里的展示顺序,也是 priority 10/20/30 的由来)。 */
20
+ export const TIER_ORDER = Object.freeze(['flash', 'pro', 'multimodal'])
21
+
22
+ export const TIER_PRIORITY = Object.freeze({ flash: 10, pro: 20, multimodal: 30 })
23
+
24
+ /** target 的 TTFT 上限(实测列在 targets 上,不是 rule 上)。 */
25
+ export const DEFAULT_TTFT_MS = 30_000
26
+
27
+ export const CHAIN_VERSION = 1
28
+
29
+ /** 与运行时规则做"是否有差异"比对时要看的字段(服务端字段如 config_hash 不算)。 */
30
+ export const RULE_FIELDS = Object.freeze([
31
+ 'name',
32
+ 'cel_expression',
33
+ 'enabled',
34
+ 'targets',
35
+ 'fallbacks',
36
+ 'priority',
37
+ 'scope',
38
+ 'description',
39
+ ])
40
+
41
+ export const TIER_DESC = Object.freeze({
42
+ flash: '便宜、量大:探索 / 检索 / 照文档干活。',
43
+ pro: '强推理:编排 / 规划 / 评审 / 审查。',
44
+ multimodal: '多模态能力档:需要读图 / 读 PDF 的调用。',
45
+ })
46
+
47
+ export function defaultChainPath({ home = homedir() } = {}) {
48
+ return join(home, '.reeve', 'chain.json')
49
+ }
50
+
51
+ export function tierRuleId(tier) {
52
+ return `reeve-${tier}`
53
+ }
54
+
55
+ /** 去掉整行 `//` 注释(链文件与仓库里的 proxy/config.json 同风格:注释只可能是整行)。 */
56
+ export function stripLineComments(text) {
57
+ return String(text)
58
+ .split('\n')
59
+ .filter((l) => !l.trim().startsWith('//'))
60
+ .join('\n')
61
+ }
62
+
63
+ export function parseChainText(text) {
64
+ return JSON.parse(stripLineComments(text))
65
+ }
66
+
67
+ /**
68
+ * `"provider/model"` → `{provider, model}`;`"provider/"`→ `{provider, model:null}`
69
+ * (链基座的约定:同模型换 provider)。认不出就返回 null(由调用方记问题)。
70
+ */
71
+ export function parseTierRef(ref) {
72
+ if (ref && typeof ref === 'object') {
73
+ const provider = typeof ref.provider === 'string' ? ref.provider.trim() : ''
74
+ const model = typeof ref.model === 'string' ? ref.model.trim() : ''
75
+ return provider ? { provider, model: model || null } : null
76
+ }
77
+ if (typeof ref !== 'string') return null
78
+ const s = ref.trim()
79
+ const i = s.indexOf('/')
80
+ if (i <= 0) return null
81
+ const provider = s.slice(0, i).trim()
82
+ const model = s.slice(i + 1).trim()
83
+ return provider ? { provider, model: model || null } : null
84
+ }
85
+
86
+ export function formatTierRef({ provider, model }) {
87
+ return `${provider}/${model ?? ''}`
88
+ }
89
+
90
+ /**
91
+ * 把链文件里的 `tiers` 收拾成规范形(面板写来的、人手写的都过这一关)。
92
+ * 返回 `{ tiers, problems }` —— problems 非空时**也**返回能用的部分(如实报,不静默丢)。
93
+ */
94
+ export function normalizeTiers(raw) {
95
+ const problems = []
96
+ const tiers = {}
97
+ if (raw == null || typeof raw !== 'object') return { tiers, problems }
98
+ for (const tier of TIER_ORDER) {
99
+ const spec = raw?.[tier]
100
+ if (spec == null) continue
101
+ if (typeof spec !== 'object') {
102
+ problems.push(`${tier}: 不是对象`)
103
+ continue
104
+ }
105
+ const targets = []
106
+ for (const t of Array.isArray(spec.targets) ? spec.targets : []) {
107
+ const parsed = parseTierRef(t)
108
+ if (!parsed || !parsed.model) problems.push(`${tier}: 认不出的 target ${JSON.stringify(t)}`)
109
+ else targets.push(parsed)
110
+ }
111
+ const fallbacks = []
112
+ for (const f of Array.isArray(spec.fallbacks) ? spec.fallbacks : []) {
113
+ const parsed = parseTierRef(f)
114
+ if (!parsed) problems.push(`${tier}: 认不出的 fallback ${JSON.stringify(f)}`)
115
+ else fallbacks.push(parsed.model ? formatTierRef(parsed) : `${parsed.provider}/`)
116
+ }
117
+ if (targets.length === 0 && fallbacks.length > 0) {
118
+ // 只有回退、没有首选 —— 等于把第一个回退提到首选(比"整档消失"更接近人的意图)
119
+ const first = parseTierRef(fallbacks.shift())
120
+ if (first) targets.push(first)
121
+ problems.push(`${tier}: 没写 targets,把第一个 fallback 提为首选`)
122
+ }
123
+ if (targets.length === 0) {
124
+ problems.push(`${tier}: 既没有 targets 也没有 fallbacks,跳过`)
125
+ continue
126
+ }
127
+ tiers[tier] = {
128
+ targets,
129
+ fallbacks,
130
+ ...(typeof spec.description === 'string' ? { description: spec.description } : {}),
131
+ ...(Number.isFinite(spec.ttftMs) ? { ttftMs: Number(spec.ttftMs) } : {}),
132
+ }
133
+ }
134
+ return { tiers, problems }
135
+ }
136
+
137
+ /**
138
+ * 一个档位 → 一条 routing rule。
139
+ * `base` 是链基座里同 id 的规则:链文件没写 description / ttftMs 时从它继承
140
+ * (免得每次热更都把人工写好的中文说明冲掉)。
141
+ */
142
+ export function tierToRule(tier, spec, { base = null, priority = null } = {}) {
143
+ const targets = (spec?.targets ?? []).map((t) => ({
144
+ provider: t.provider,
145
+ model: t.model,
146
+ weight: 1,
147
+ ttft_timeout_ms: Number(spec?.ttftMs ?? base?.targets?.[0]?.ttft_timeout_ms ?? DEFAULT_TTFT_MS),
148
+ }))
149
+ // weight 必须合计为 1(多 target 时均分)
150
+ if (targets.length > 1) for (const t of targets) t.weight = 1 / targets.length
151
+ return {
152
+ id: tierRuleId(tier),
153
+ name: `reeve/${tier}`,
154
+ description: spec?.description ?? base?.description ?? TIER_DESC[tier] ?? '',
155
+ enabled: spec?.enabled ?? base?.enabled ?? true,
156
+ cel_expression: `model == "reeve/${tier}"`,
157
+ targets,
158
+ fallbacks: [...(spec?.fallbacks ?? [])],
159
+ scope: spec?.scope ?? base?.scope ?? 'global',
160
+ priority: Number(priority ?? spec?.priority ?? TIER_PRIORITY[tier] ?? base?.priority ?? 100),
161
+ }
162
+ }
163
+
164
+ /** 三个档位 → 规则数组(按 priority 升序)。 */
165
+ export function tiersToRoutingRules(tiers) {
166
+ const out = []
167
+ for (const tier of TIER_ORDER) {
168
+ if (!tiers?.[tier]) continue
169
+ out.push(tierToRule(tier, tiers[tier]))
170
+ }
171
+ return out.sort((a, b) => a.priority - b.priority)
172
+ }
173
+
174
+ /**
175
+ * 把档位合到链基座上:**providers 等一切照旧,只替换 `reeve-*` 那几条规则**。
176
+ * tiers 为空(链文件缺失/坏掉)时原样返回 —— 这就是"回落到现状"。
177
+ */
178
+ export function applyTiers(chain, tiers) {
179
+ const baseRules = chain?.governance?.routing_rules ?? []
180
+ const derived = []
181
+ for (const tier of TIER_ORDER) {
182
+ if (!tiers?.[tier]) continue
183
+ const id = tierRuleId(tier)
184
+ derived.push(tierToRule(tier, tiers[tier], { base: baseRules.find((r) => r?.id === id) ?? null }))
185
+ }
186
+ if (derived.length === 0) return chain ?? {}
187
+ const ids = new Set(derived.map((r) => r.id))
188
+ const kept = baseRules.filter((r) => r?.id && !ids.has(r.id))
189
+ const governance = { ...(chain?.governance ?? {}) }
190
+ governance.routing_rules = [...kept, ...derived].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
191
+ return { ...(chain ?? {}), governance }
192
+ }
193
+
194
+ /**
195
+ * 反向:从链基座(或运行时规则)反推档位 —— 用在"链文件还没建"时给面板/快照一个起点。
196
+ * @param {Array<{id?:string,name?:string,targets?:any[],fallbacks?:any[]}>} rules
197
+ */
198
+ export function tiersFromRules(rules) {
199
+ const tiers = {}
200
+ for (const tier of TIER_ORDER) {
201
+ const id = tierRuleId(tier)
202
+ const rule = (rules ?? []).find((r) => r?.id === id || r?.name === `reeve/${tier}`)
203
+ if (!rule) continue
204
+ const targets = (rule.targets ?? [])
205
+ .map((t) => parseTierRef(t))
206
+ .filter((t) => t && t.model)
207
+ if (targets.length === 0) continue
208
+ const fallbacks = (rule.fallbacks ?? [])
209
+ .map((f) => parseTierRef(f))
210
+ .filter(Boolean)
211
+ .map((f) => (f.model ? formatTierRef(f) : `${f.provider}/`))
212
+ tiers[tier] = {
213
+ targets,
214
+ fallbacks,
215
+ ...(rule.description ? { description: rule.description } : {}),
216
+ ...(rule.targets?.[0]?.ttft_timeout_ms ? { ttftMs: Number(rule.targets[0].ttft_timeout_ms) } : {}),
217
+ }
218
+ }
219
+ return tiers
220
+ }
221
+
222
+ export function tiersFromChainDoc(chain) {
223
+ return tiersFromRules(chain?.governance?.routing_rules)
224
+ }
225
+
226
+ /** 排序后的规范 JSON —— 用于"有没有差异"的比对(键序不同不算差异)。 */
227
+ export function canonicalJson(value) {
228
+ const walk = (v) => {
229
+ if (Array.isArray(v)) return v.map(walk)
230
+ if (v && typeof v === 'object') {
231
+ const out = {}
232
+ for (const k of Object.keys(v).sort()) out[k] = walk(v[k])
233
+ return out
234
+ }
235
+ return v
236
+ }
237
+ return JSON.stringify(walk(value))
238
+ }
239
+
240
+ /** 两条规则在关心字段上是否一致(键序无关)。 */
241
+ export function rulesSame(a, b) {
242
+ return RULE_FIELDS.every((k) => canonicalJson(a?.[k]) === canonicalJson(b?.[k]))
243
+ }
244
+
245
+ /**
246
+ * 期望规则 vs 运行时规则 → 差异(收敛与快照共用一份判断)。
247
+ * @returns {{drifted: boolean, missing: string[], changed: string[], extra: string[]}}
248
+ */
249
+ export function rulesDrift(desired, runtime) {
250
+ const want = new Map((desired ?? []).filter((r) => r?.id).map((r) => [r.id, r]))
251
+ const have = new Map((runtime ?? []).filter((r) => r?.id).map((r) => [r.id, r]))
252
+ const missing = []
253
+ const changed = []
254
+ const extra = []
255
+ for (const [id, rule] of want) {
256
+ const got = have.get(id)
257
+ if (!got) missing.push(id)
258
+ else if (!rulesSame(got, rule)) changed.push(id)
259
+ }
260
+ for (const id of have.keys()) if (!want.has(id)) extra.push(id)
261
+ return { drifted: missing.length + changed.length + extra.length > 0, missing, changed, extra }
262
+ }
263
+
264
+ /**
265
+ * 读链文件。三种结果都要能区分(面板会显示"没有链文件=用基座"):
266
+ * · 不存在 → { exists:false, doc:null, tiers:null }
267
+ * · 存在但读不动/坏 → { exists:true, doc:null, error:'…' }(视为"未变更",不炸)
268
+ * · 存在且能读 → { exists:true, doc, tiers, problems, marker }
269
+ * `marker` 是变更检测的记号(mtime+size)。**先 stat 再读**:万一读的这一刻文件正在被换
270
+ * (面板写是 temp+rename),记号会偏旧 → 下一个 tick 再收敛一遍(幂等,无害);
271
+ * 反过来(记号偏新)就会漏掉一次变更,那才是真 bug。
272
+ */
273
+ export async function readChainFile({ path, readFileImpl = readFile, statImpl = stat } = {}) {
274
+ let st = null
275
+ try {
276
+ st = await statImpl(path)
277
+ } catch {
278
+ return { exists: false, path, doc: null, tiers: null, problems: [], marker: null, mtimeMs: null, error: null }
279
+ }
280
+ const marker = `${Number(st.mtimeMs)}:${Number(st.size)}`
281
+ const base = { exists: true, path, marker, mtimeMs: Number(st.mtimeMs) }
282
+ try {
283
+ const text = await readFileImpl(path, 'utf8')
284
+ const doc = parseChainText(text)
285
+ const { tiers, problems } = normalizeTiers(doc?.tiers)
286
+ return { ...base, doc, tiers, problems, error: null }
287
+ } catch (err) {
288
+ return { ...base, doc: null, tiers: null, problems: [], error: String(err?.message ?? err).slice(0, 160) }
289
+ }
290
+ }
291
+
292
+ /** 面板要写的形状(插件侧只用来生成"初始链文件"的建议内容,不自动写)。 */
293
+ export function buildChainDoc(tiers, { now = Date.now(), version = CHAIN_VERSION } = {}) {
294
+ const out = {}
295
+ for (const tier of TIER_ORDER) {
296
+ const spec = tiers?.[tier]
297
+ if (!spec) continue
298
+ out[tier] = {
299
+ targets: (spec.targets ?? []).map((t) => ({ provider: t.provider, model: t.model })),
300
+ fallbacks: [...(spec.fallbacks ?? [])],
301
+ ...(spec.description ? { description: spec.description } : {}),
302
+ ...(Number.isFinite(spec.ttftMs) ? { ttftMs: Number(spec.ttftMs) } : {}),
303
+ }
304
+ }
305
+ return { version, updatedAt: now, tiers: out }
306
+ }
307
+
308
+ /**
309
+ * 链文件 + 链基座 → **实际该用哪份规则**。四种情形,一个有意的区分:
310
+ *
311
+ * ① 没有链文件 → 用链基座(首次安装的常态,也是"回落现状")
312
+ * ② 链文件好且有三档 → 用它派生(只换 `reeve-*` 三条,providers 来自基座)
313
+ * ③ 链文件坏(JSON 解析不了 / → **当作"未变更"**:留上一份好的(previous),
314
+ * 声明了档位但一条都没活下来) 不要因为一次手滑把线上路由翻回基座
315
+ * ④ 链文件好但没有档位 → 用链基座(`tiers: {}` 是"恢复默认"的合法写法)
316
+ *
317
+ * `effective === null` 只在 ③ 出现 —— 调用方必须"什么都不改",而不是拿 null 当"用基座"。
318
+ *
319
+ * @param {{base: object, from: string, file: object, previous?: object|null}} args
320
+ * @returns {{effective: object|null, tiers: object, source: string, derived: boolean, stale: boolean, problems: string[], error: string|null}}
321
+ */
322
+ export function resolveChain({ base, from, file, previous = null }) {
323
+ const problems = [...(file?.problems ?? [])]
324
+ const error = file?.error ?? null
325
+ const exists = file?.exists === true
326
+ const parsed = exists && file?.doc != null
327
+ const usable = parsed && Object.keys(file?.tiers ?? {}).length > 0
328
+ const declaredTiers = parsed ? Object.keys(file?.doc?.tiers ?? {}).length : 0
329
+
330
+ if (!exists) {
331
+ return { effective: base, tiers: tiersFromChainDoc(base), source: from, derived: false, stale: false, problems, error: null }
332
+ }
333
+ if (usable) {
334
+ return {
335
+ effective: applyTiers(base, file.tiers),
336
+ tiers: file.tiers,
337
+ source: file.path,
338
+ derived: true,
339
+ stale: false,
340
+ problems,
341
+ error: null,
342
+ }
343
+ }
344
+ if (!parsed || declaredTiers > 0) {
345
+ // 坏文件 → 当作未变更
346
+ return {
347
+ effective: null,
348
+ tiers: previous?.tiers ?? tiersFromChainDoc(base),
349
+ source: previous?.source ?? from,
350
+ derived: previous?.derived === true,
351
+ stale: true,
352
+ problems,
353
+ error: error ?? '链文件里的档位一条都没解析出来',
354
+ }
355
+ }
356
+ // 好文件、明确没有档位 → 回落到链基座
357
+ return { effective: base, tiers: tiersFromChainDoc(base), source: from, derived: false, stale: false, problems, error: null }
358
+ }
@@ -0,0 +1,125 @@
1
+ // 并发准入闸门。
2
+ //
3
+ // 挂在 `ctx.tool.hook("execute.before")` —— **子会话创建之前**。
4
+ // 超限时**排队,不失败**。
5
+ //
6
+ // 两条正确性性质(抄 omo-slim,MIT):
7
+ // (a) **嵌套的受管任务必须豁免** —— 它已经持有槽位(它的父任务占着),
8
+ // 再让它排队会**自死锁**。
9
+ // (b) **调度器按目录进程级共享** —— 插件重载后仍保留运行中的槽位与排队票。
10
+
11
+ /** 进程级共享:directory -> Admission。插件重载不清空。 */
12
+ const RUNTIMES = new Map()
13
+
14
+ export function acquireAdmissionRuntime(directory, options) {
15
+ const key = directory || '<default>'
16
+ let runtime = RUNTIMES.get(key)
17
+ if (!runtime) {
18
+ runtime = new Admission(options)
19
+ RUNTIMES.set(key, runtime)
20
+ } else if (options) {
21
+ runtime.configure(options)
22
+ }
23
+ return runtime
24
+ }
25
+
26
+ /** 供测试与诊断用。 */
27
+ export function _runtimes() {
28
+ return RUNTIMES
29
+ }
30
+
31
+ export class Admission {
32
+ /** @type {Map<string, {resolve: Function, reject: Function}>} key -> 排队票 */
33
+ #waiting = new Map()
34
+ /** @type {Set<string>} 正在占槽的任务键 */
35
+ #active = new Set()
36
+ #limits = { default: 0, providers: {}, models: {} }
37
+
38
+ constructor(options = {}) {
39
+ this.configure(options)
40
+ }
41
+
42
+ configure({ limits } = {}) {
43
+ if (!limits) return
44
+ this.#limits = {
45
+ default: normalizeLimit(limits.default),
46
+ providers: limits.providers ?? {},
47
+ models: limits.models ?? {},
48
+ }
49
+ }
50
+
51
+ get activeCount() {
52
+ return this.#active.size
53
+ }
54
+
55
+ get waitingCount() {
56
+ return this.#waiting.size
57
+ }
58
+
59
+ /**
60
+ * 档位解析:**模型上限 > provider 上限 > 默认上限**;`0`/缺省 = 无限。
61
+ */
62
+ resolveLimit({ model, provider } = {}) {
63
+ if (model && this.#limits.models?.[model] != null) return normalizeLimit(this.#limits.models[model])
64
+ if (provider && this.#limits.providers?.[provider] != null) {
65
+ return normalizeLimit(this.#limits.providers[provider])
66
+ }
67
+ return normalizeLimit(this.#limits.default)
68
+ }
69
+
70
+ /**
71
+ * 取一个槽位。超限则排队等(不是失败)。
72
+ * @returns {Promise<() => void>} 释放函数
73
+ */
74
+ async acquire(key, { model, provider, nested = false, signal = null } = {}) {
75
+ // (a) 嵌套的受管任务豁免:它已持槽,排队会自死锁。
76
+ if (nested) return () => {}
77
+
78
+ const limit = this.resolveLimit({ model, provider })
79
+ if (limit === 0 || this.#active.size < limit) {
80
+ this.#active.add(key)
81
+ return () => this.release(key)
82
+ }
83
+
84
+ // 排队。被 abort 时撤票。
85
+ await new Promise((resolve, reject) => {
86
+ const ticket = { resolve, reject }
87
+ this.#waiting.set(key, ticket)
88
+ signal?.addEventListener?.(
89
+ 'abort',
90
+ () => {
91
+ if (this.#waiting.get(key) === ticket) {
92
+ this.#waiting.delete(key)
93
+ reject(new Error('reeve: 排队时被取消'))
94
+ }
95
+ },
96
+ { once: true },
97
+ )
98
+ })
99
+
100
+ this.#active.add(key)
101
+ return () => this.release(key)
102
+ }
103
+
104
+ release(key) {
105
+ this.#active.delete(key)
106
+ this.#drain()
107
+ }
108
+
109
+ /** 释放一个槽位后,把队首放行。 */
110
+ #drain() {
111
+ const limit = this.resolveLimit({})
112
+ if (limit !== 0 && this.#active.size >= limit) return
113
+ const next = this.#waiting.keys().next()
114
+ if (next.done) return
115
+ const [key, ticket] = [next.value, this.#waiting.get(next.value)]
116
+ this.#waiting.delete(key)
117
+ ticket.resolve()
118
+ }
119
+ }
120
+
121
+ function normalizeLimit(value) {
122
+ const n = Number(value)
123
+ if (!Number.isFinite(n) || n <= 0) return 0 // 0 / 缺省 / 非法 = 无限
124
+ return Math.floor(n)
125
+ }
@@ -0,0 +1,136 @@
1
+ // 首次启动时把 `providers.reeve` 写进引擎配置 —— **只加自己那一块**。
2
+ //
3
+ // 使用者裁决(2026-10-20):"要让插件首次启动时,自己把 providers.reeve 那两行写进 opencode.jsonc"。
4
+ // 于是这条从"插件绝不写引擎配置"改成"**只写自己那一块,且只在不写就没法工作时写**"。
5
+ //
6
+ // 四条纪律(都是为了"改别人的文件"这件事不出事):
7
+ // 1. **只加不改**:`providers.reeve` 已存在(哪怕值是错的)→ 一个字节都不动,只记日志;
8
+ // 2. **备份**:动之前写 `<文件>.reeve-bak`(只备份一次,别覆盖更早的备份);
9
+ // 3. **写完自校验**:剥离整行注释后必须能 JSON.parse,且 `providers.reeve.settings.baseURL` 在 ——
10
+ // 校验不过就把备份还原回去(绝不留一个坏掉的配置);
11
+ // 4. **保留注释与格式**:只做文本插入,不重新序列化(用户的 jsonc 里有注释)。
12
+
13
+ import { readFile, rename, writeFile } from 'node:fs/promises'
14
+ import { homedir } from 'node:os'
15
+ import { join } from 'node:path'
16
+
17
+ /** 引擎配置里我们那一块(三行端点)。 */
18
+ export function reeveProviderBlock({ port = 8080, host = '127.0.0.1', packageId = '@ai-sdk/openai-compatible' } = {}) {
19
+ return { package: packageId, settings: { baseURL: `http://${host}:${port}/v1` } }
20
+ }
21
+
22
+ /** 剥离整行注释后解析(与读端点、样板守门同一套规则)。 */
23
+ export function parseJsonc(text) {
24
+ const stripped = String(text)
25
+ .split('\n')
26
+ .filter((l) => !l.trim().startsWith('//'))
27
+ .join('\n')
28
+ return JSON.parse(stripped)
29
+ }
30
+
31
+ /**
32
+ * 计划插入:返回 { changed, text, reason }。纯函数,可测。
33
+ * 处理三种形状:没有 providers 键 / providers 是空对象 / providers 非空(要带逗号)。
34
+ */
35
+ export function planProviderInsertion(text, providerId = 'reeve', block = reeveProviderBlock()) {
36
+ const src = String(text)
37
+ let doc
38
+ try {
39
+ doc = parseJsonc(src)
40
+ } catch (err) {
41
+ return { changed: false, text: src, reason: `配置不是合法 JSON(不碰):${String(err).slice(0, 80)}` }
42
+ }
43
+ if (doc?.providers?.[providerId] !== undefined) {
44
+ return { changed: false, text: src, reason: 'providers.reeve 已存在(只加不改)' }
45
+ }
46
+
47
+ const entry = `"${providerId}": ${JSON.stringify(block, null, 2).replace(/\n/g, '\n ')}`
48
+ const anchor = /"providers"\s*:\s*\{/.exec(src)
49
+ let out
50
+ let where
51
+ if (anchor) {
52
+ const open = anchor.index + anchor[0].length
53
+ const after = src.slice(open)
54
+ const isEmpty = /^\s*\}/.test(after)
55
+ const indent = ' '
56
+ const body = isEmpty
57
+ ? `\n${indent}${entry.replace(/\n/g, `\n${indent}`)}\n `
58
+ : `\n${indent}${entry.replace(/\n/g, `\n${indent}`)},`
59
+ out = src.slice(0, open) + body + after
60
+ where = 'providers 对象里'
61
+ } else {
62
+ const open = src.indexOf('{')
63
+ if (open < 0) return { changed: false, text: src, reason: '找不到顶层对象(不碰)' }
64
+ const after = src.slice(open + 1)
65
+ const isEmpty = /^\s*\}/.test(after)
66
+ const body = isEmpty
67
+ ? `\n "providers": { ${entry} }\n`
68
+ : `\n "providers": { ${entry} },`
69
+ out = src.slice(0, open + 1) + body + after
70
+ where = '新建 providers 键'
71
+ }
72
+
73
+ // 自校验:插完之后必须是合法 JSON,且我们那块在
74
+ try {
75
+ const check = parseJsonc(out)
76
+ if (check?.providers?.[providerId]?.settings?.baseURL === undefined) {
77
+ return { changed: false, text: src, reason: '插入后校验不到 baseURL(不写)' }
78
+ }
79
+ } catch (err) {
80
+ return { changed: false, text: src, reason: `插入后不是合法 JSON(不写):${String(err).slice(0, 80)}` }
81
+ }
82
+ return { changed: true, text: out, reason: `在 ${where}新增 providers.${providerId}` }
83
+ }
84
+
85
+ /**
86
+ * 找到引擎配置文件并确保有我们那一块。返回 { status, path, detail };
87
+ * status: 'added' | 'present' | 'no-file' | 'skipped' | 'rolled-back'
88
+ */
89
+ export async function ensureProviderBlock({
90
+ configDir = null,
91
+ providerId = 'reeve',
92
+ block = reeveProviderBlock(),
93
+ readFileImpl = readFile,
94
+ writeFileImpl = writeFile,
95
+ renameImpl = rename,
96
+ logger = console,
97
+ createIfMissing = false,
98
+ } = {}) {
99
+ const dir = configDir ?? join(homedir(), '.config', 'opencode')
100
+ for (const name of ['opencode.jsonc', 'opencode.json']) {
101
+ const path = join(dir, name)
102
+ let text
103
+ try {
104
+ text = await readFileImpl(path, 'utf8')
105
+ } catch {
106
+ continue
107
+ }
108
+ const plan = planProviderInsertion(text, providerId, block)
109
+ if (!plan.changed) return { status: 'present', path, detail: plan.reason }
110
+ try {
111
+ await writeFileImpl(`${path}.reeve-bak`, text, 'utf8').catch(() => {})
112
+ await writeFileImpl(path, plan.text, 'utf8')
113
+ logger.debug(`[reeve] 已把 providers.${providerId} 写进 ${path}(原文件备份在 <同目录>.reeve-bak)`)
114
+ return { status: 'added', path, detail: plan.reason }
115
+ } catch (err) {
116
+ // 写失败/校验不过:还原
117
+ try {
118
+ await renameImpl(`${path}.reeve-bak`, path)
119
+ } catch {
120
+ /* 还原不了也没别的办法,日志里说清楚 */
121
+ }
122
+ logger.debug(`[reeve] 写引擎配置失败,已还原:${String(err).slice(0, 160)}`)
123
+ return { status: 'rolled-back', path, detail: String(err).slice(0, 160) }
124
+ }
125
+ }
126
+ if (!createIfMissing) return { status: 'no-file', path: join(dir, 'opencode.jsonc'), detail: '没有配置文件可改(由使用者/启动器创建)' }
127
+ const path = join(dir, 'opencode.jsonc')
128
+ const body = `{\n "providers": { "${providerId}": ${JSON.stringify(block, null, 2).replace(/\n/g, '\n ')} }\n}\n`
129
+ try {
130
+ await writeFileImpl(path, body, 'utf8')
131
+ logger.debug(`[reeve] 新建了 ${path} 并写入 providers.${providerId}`)
132
+ return { status: 'added', path, detail: '新建配置文件' }
133
+ } catch (err) {
134
+ return { status: 'skipped', path, detail: String(err).slice(0, 160) }
135
+ }
136
+ }