@bd7pil/reeve 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ledger.js ADDED
@@ -0,0 +1,253 @@
1
+ // REEVE 台账:子任务登记表。
2
+ //
3
+ // 为什么必须自持:opencode v2 **不把 session.list / session.children / session.status 交给插件**
4
+ // (施工图 §6)。所以"谁在跑、谁死了"这份账只能插件自己记。
5
+ //
6
+ // 两条正确性性质(本项目最贵的 bug 是"静默不一致",不是崩溃):
7
+ // 1. 终态一旦发布就**不可被迟到的完成/忙碌覆盖**(commitTerminal 的单调性)
8
+ // 2. 墓碑一旦立下就**不可复活**(跨重启对账不要造出永远在跑的幽灵)
9
+
10
+ import { STORAGE_PREFIX, STATE, TERMINAL_STATES, newRecord } from './constants.js'
11
+
12
+ export class Ledger {
13
+ /** @type {Map<string, object>} taskID -> record */
14
+ #records = new Map()
15
+ /** @type {Map<string, string>} childSessionID -> taskID */
16
+ #byChild = new Map()
17
+ /** @type {Set<string>} 已墓碑的 taskID —— 永不复活 */
18
+ #tombstones = new Set()
19
+
20
+ constructor({ storage = null, logger = console } = {}) {
21
+ this.storage = storage
22
+ this.logger = logger
23
+ // 变更通知:给只读快照用。**同步、绝不 await** —— 记账不能被视图拖慢。
24
+ this.onChange = null
25
+ }
26
+
27
+ #changed() {
28
+ try {
29
+ this.onChange?.()
30
+ } catch {
31
+ /* 视图的问题不是记账的问题 */
32
+ }
33
+ }
34
+
35
+ static storageKey(taskID) {
36
+ return STORAGE_PREFIX + taskID
37
+ }
38
+
39
+ create(rec) {
40
+ const record = newRecord(rec)
41
+ this.#records.set(record.taskID, record)
42
+ if (record.childSessionID) this.#byChild.set(record.childSessionID, record.taskID)
43
+ void this.persist(record.taskID)
44
+ this.#changed()
45
+ return record
46
+ }
47
+
48
+ get(taskID) {
49
+ return this.#records.get(taskID) ?? null
50
+ }
51
+
52
+ /** 子会话 id 是 `subagent` 工具的输出解析出来的,可能晚于创建。 */
53
+ attachChild(taskID, childSessionID) {
54
+ const record = this.#records.get(taskID)
55
+ if (!record || !childSessionID) return record ?? null
56
+ record.childSessionID = childSessionID
57
+ this.#byChild.set(childSessionID, taskID)
58
+ void this.persist(taskID)
59
+ this.#changed()
60
+ return record
61
+ }
62
+
63
+ forChildSession(childSessionID) {
64
+ const taskID = this.#byChild.get(childSessionID)
65
+ return taskID ? (this.#records.get(taskID) ?? null) : null
66
+ }
67
+
68
+ update(taskID, patch) {
69
+ const record = this.#records.get(taskID)
70
+ if (!record) return null
71
+ Object.assign(record, patch)
72
+ record.lastActivityAt = Date.now()
73
+ void this.persist(taskID)
74
+ this.#changed()
75
+ return record
76
+ }
77
+
78
+ /**
79
+ * 落终态。**单调**:已发布的终态不会被再次写入覆盖。
80
+ * @returns {boolean} 是否真的写入了(false = 已有终态,被拒绝)
81
+ */
82
+ commitTerminal(taskID, state, { source = null, reason = null, ...extra } = {}) {
83
+ const record = this.#records.get(taskID)
84
+ if (!record) return false
85
+ if (record.terminalPublished) {
86
+ this.logger?.debug?.(
87
+ `[reeve] 拒绝覆盖已发布的终态:${taskID} 已是 ${record.state},忽略迟到的 ${state}`,
88
+ )
89
+ return false
90
+ }
91
+ if (!TERMINAL_STATES.has(state)) return false
92
+ Object.assign(record, extra, {
93
+ state,
94
+ reason,
95
+ terminalSource: source,
96
+ terminalPublished: true,
97
+ endedAt: Date.now(),
98
+ statusUncertain: extra.statusUncertain ?? false,
99
+ })
100
+ void this.persist(taskID)
101
+ this.#changed()
102
+ return true
103
+ }
104
+
105
+ /** 在飞的(父会话维度)。parentSessionID 省略则返回全部在飞的。 */
106
+ running(parentSessionID = null) {
107
+ const out = []
108
+ for (const r of this.#records.values()) {
109
+ if (r.state !== STATE.RUNNING) continue
110
+ if (parentSessionID && r.parentSessionID !== parentSessionID) continue
111
+ out.push(r)
112
+ }
113
+ return out
114
+ }
115
+
116
+ all() {
117
+ return [...this.#records.values()]
118
+ }
119
+
120
+ tombstone(taskID, reason) {
121
+ this.#tombstones.add(taskID)
122
+ const record = this.#records.get(taskID)
123
+ if (record) {
124
+ // 拆除:不留幽灵。终态为 reconciled,明确标"不是正常结束"。
125
+ this.commitTerminal(taskID, STATE.RECONCILED, {
126
+ source: 'reconcile',
127
+ reason,
128
+ terminalUnreconciled: true,
129
+ })
130
+ if (record.childSessionID) this.#byChild.delete(record.childSessionID)
131
+ }
132
+ void this.persist(taskID)
133
+ this.#changed()
134
+ }
135
+
136
+ isTombstoned(taskID) {
137
+ return this.#tombstones.has(taskID)
138
+ }
139
+
140
+ // ---- 持久化(best-effort,绝不因为落盘失败而打断记账)----
141
+
142
+ async persist(taskID) {
143
+ if (!this.storage) return
144
+ const record = this.#records.get(taskID)
145
+ if (!record) return
146
+ try {
147
+ await this.storage.set(Ledger.storageKey(taskID), toJson(record))
148
+ if (this.#tombstones.has(taskID)) {
149
+ await this.storage.set(Ledger.storageKey(taskID) + '.tombstone', true)
150
+ }
151
+ } catch (err) {
152
+ this.logger?.debug?.(`[reeve] 台账落盘失败(忽略):${String(err)}`)
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 启动时把盘上的账读回来。
158
+ * **注意**:恢复是 best-effort,别名与终态结果**不恢复**;
159
+ * 重启时仍在 running 的记录一律标 statusUncertain —— **如实标未知,不谎报**。
160
+ *
161
+ * @returns {Promise<{loaded:number, truncated:boolean, pages:number}>}
162
+ * `truncated: true` = 存储没有返回游标但取满了一页,**可能还有没读到的记录**。
163
+ * 这是"静默不一致"的一个入口,所以**不吞掉**,交给调用方决定怎么报。
164
+ */
165
+ async hydrate() {
166
+ if (!this.storage?.scan) return { loaded: 0, truncated: false, pages: 0 }
167
+ let loaded = 0
168
+ let pages = 0
169
+ let truncated = false
170
+ const PAGE = 500
171
+ try {
172
+ let after
173
+ for (;;) {
174
+ const page = await this.storage.scan({ prefix: STORAGE_PREFIX, after, limit: PAGE })
175
+ const entries = page?.entries ?? []
176
+ pages += 1
177
+ for (const entry of entries) {
178
+ if (entry.key.endsWith('.tombstone')) {
179
+ this.#tombstones.add(entry.key.slice(0, -'.tombstone'.length).replace(STORAGE_PREFIX, ''))
180
+ continue
181
+ }
182
+ const record = fromJson(entry.value)
183
+ if (!record?.taskID) continue
184
+ this.#records.set(record.taskID, record)
185
+ if (record.childSessionID) this.#byChild.set(record.childSessionID, record.taskID)
186
+ loaded += 1
187
+ }
188
+ if (!page?.next) {
189
+ // 取满一页却没有游标 —— 存储可能不支持分页。如实标记,别假装读全了。
190
+ if (entries.length >= PAGE) truncated = true
191
+ break
192
+ }
193
+ after = page.next
194
+ }
195
+ } catch (err) {
196
+ this.logger?.debug?.(`[reeve] 台账恢复失败(忽略):${String(err)}`)
197
+ return { loaded, truncated, pages }
198
+ }
199
+
200
+ if (truncated) {
201
+ this.logger?.debug?.(
202
+ `[reeve] ⚠️ 台账恢复可能不完整:scan 取满 ${PAGE} 条但没有返回游标(已恢复 ${loaded} 条)。` +
203
+ `未读到的记录不会出现在台账里 —— 这是静默不一致,请检查 ctx.storage.scan 的分页。`,
204
+ )
205
+ }
206
+
207
+ // 重启后仍在 running 的一律标未知 —— 不要谎报 running,也不要谎报 completed。
208
+ for (const record of this.#records.values()) {
209
+ if (record.state === STATE.RUNNING) {
210
+ record.statusUncertain = true
211
+ record.reason = 'host_restarted'
212
+ }
213
+ }
214
+ if (loaded > 0) this.#changed()
215
+ return { loaded, truncated, pages }
216
+ }
217
+
218
+ /**
219
+ * 保留期修剪:删掉**已落终态**且超过保留期的记录,以及过期的墓碑。
220
+ * **只动终态**——在飞记录永不修剪(哪怕很老,那也是"账还没结")。
221
+ * 为什么需要:台账只增不减,跑一年会攒到几万条 → 启动扫描变慢、内存与盘都涨。
222
+ * @returns {Promise<number>} 删掉的条数
223
+ */
224
+ async prune({ olderThanMs = 30 * 24 * 60 * 60 * 1000, now = Date.now() } = {}) {
225
+ const cutoff = now - olderThanMs
226
+ let removed = 0
227
+ for (const [taskID, record] of [...this.#records.entries()]) {
228
+ if (record.state === STATE.RUNNING) continue // 在飞的一律不动
229
+ if (!record.endedAt || record.endedAt > cutoff) continue
230
+ this.#records.delete(taskID)
231
+ if (record.childSessionID) this.#byChild.delete(record.childSessionID)
232
+ this.#tombstones.delete(taskID)
233
+ try {
234
+ await this.storage?.remove?.(Ledger.storageKey(taskID))
235
+ await this.storage?.remove?.(Ledger.storageKey(taskID) + '.tombstone')
236
+ } catch (err) {
237
+ this.logger?.debug?.(`[reeve] 修剪落盘失败(忽略):${String(err)}`)
238
+ }
239
+ removed += 1
240
+ }
241
+ if (removed > 0) this.#changed()
242
+ return removed
243
+ }
244
+ }
245
+
246
+ function toJson(record) {
247
+ return JSON.parse(JSON.stringify(record))
248
+ }
249
+
250
+ function fromJson(value) {
251
+ if (!value || typeof value !== 'object') return null
252
+ return { ...value }
253
+ }
package/src/models.js ADDED
@@ -0,0 +1,114 @@
1
+ // 三个档位的**模型注册** —— 让 opencode.jsonc 里不再需要一份 models 映射。
2
+ //
3
+ // 为什么能做:v2 的插件 API 有 `ctx.provider.transform(editor => editor.models.set(id, models))`
4
+ //(`packages/plugin/src/promise/provider.ts` @v2.0.27),`Model.Info` 也能从最小形状造出来。
5
+ //
6
+ // 规则(顺序即优先级):
7
+ // - 配置里已经写了的模型条目**不动**(配置说话时以配置为准);
8
+ // - 只补上缺的那几个档位;
9
+ // - 端点(baseURL / key)**插件注册不了** —— `Provider.Info` 只有 {id,name,activation,package,integrationID},
10
+ // `Connection.Info` 只是凭据引用,`Integration.Method` 只是"怎么拿凭据",都没有 URL。
11
+ // 所以配置里那两行是**irreducible** 的(见 config/opencode.sample.jsonc)。
12
+ //
13
+ // 另一处简化:代理端口不再让用户重复写 —— 从 `providers.reeve.settings.baseURL` 里推。
14
+
15
+ import { readFile } from 'node:fs/promises'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
18
+
19
+ /** 三个档位。`images` 决定这一档声明不声明"能吃图"(只有视觉档声明)。 */
20
+ export const REEVE_TIERS = Object.freeze([
21
+ { id: 'flash', modelID: 'reeve/flash', name: 'REEVE flash(廉价链)', images: false },
22
+ { id: 'pro', modelID: 'reeve/pro', name: 'REEVE pro(强推理链)', images: false },
23
+ { id: 'multimodal', modelID: 'reeve/multimodal', name: 'REEVE multimodal(视觉链)', images: true },
24
+ ])
25
+
26
+ /** 造一个最小的 `Model.Info`(照 `Model.Info.default()` 的形状,能力按档位给)。 */
27
+ export function buildTierModel(tier, providerID = 'reeve') {
28
+ return {
29
+ id: tier.id,
30
+ modelID: tier.modelID,
31
+ providerID,
32
+ name: tier.name,
33
+ capabilities: {
34
+ tools: true,
35
+ input: tier.images ? ['text', 'image'] : ['text'],
36
+ output: ['text'],
37
+ },
38
+ variants: [],
39
+ time: { released: 0 },
40
+ cost: [],
41
+ status: 'active',
42
+ enabled: true,
43
+ limit: { context: 200_000, output: 32_000 },
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 合并:保留已有的,补上缺的档位。
49
+ * @param {Array<{id: string}>} existing 运行时已有的模型(可能为空)
50
+ * @param {ReadonlyArray<typeof REEVE_TIERS[number]>} tiers
51
+ * @param {string} providerID
52
+ * @returns {Array<object>} 要 set 回去的完整数组
53
+ */
54
+ export function mergeTierModels(existing, tiers = REEVE_TIERS, providerID = 'reeve') {
55
+ const have = new Set((existing ?? []).map((m) => m?.id).filter(Boolean))
56
+ const added = tiers.filter((t) => !have.has(t.id)).map((t) => buildTierModel(t, providerID))
57
+ return [...(existing ?? []), ...added]
58
+ }
59
+
60
+ /**
61
+ * 从引擎配置里读 `providers.reeve.settings.baseURL` → 代理该在哪个 host:port 上服务。
62
+ * 读不到就返回 null(调用方退回 options.proxy.port / 默认端口)。
63
+ * **只读**:插件永不写引擎配置。
64
+ */
65
+ export function parseEndpointFromConfig(text, providerID = 'reeve') {
66
+ try {
67
+ const stripped = String(text)
68
+ .split('\n')
69
+ .filter((l) => !l.trim().startsWith('//'))
70
+ .join('\n')
71
+ const json = JSON.parse(stripped)
72
+ const base = json?.providers?.[providerID]?.settings?.baseURL
73
+ if (typeof base !== 'string' || base.length === 0) return null
74
+ const url = new URL(base)
75
+ return { baseURL: base, host: url.hostname, port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)) }
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** 读配置目录里的 opencode.json / opencode.jsonc(哪个先读到用哪个)。 */
82
+ export async function readEndpoint({ configDir = null, readFileImpl = readFile, providerID = 'reeve' } = {}) {
83
+ const dir = configDir ?? join(homedir(), '.config', 'opencode')
84
+ for (const name of ['opencode.json', 'opencode.jsonc']) {
85
+ try {
86
+ const text = await readFileImpl(join(dir, name), 'utf8')
87
+ const found = parseEndpointFromConfig(text, providerID)
88
+ if (found) return { ...found, from: join(dir, name) }
89
+ } catch {
90
+ /* 换下一个 */
91
+ }
92
+ }
93
+ return null
94
+ }
95
+ /**
96
+ * 要藏起来的内置 agent —— 前台只留编排者(见 PROGRESS §6.1)。
97
+ * 由插件在启动时设置 `hidden: true`,所以配置里不再需要 `agents` 块。
98
+ * 希望某个内置 agent 重新可见:删掉这里的 id,或者在配置里显式写 `agents.<id>.hidden = false`
99
+ * (**配置后应用**,会覆盖这里的设置)。
100
+ */
101
+ export const BUILTINS_TO_HIDE = Object.freeze(['build', 'plan', 'general', 'explore'])
102
+
103
+ /** 把默认 agent 设为编排者(存在才设;配置里的 `default_agent` 优先级更高)。 */
104
+ export function pickDefaultAgent(editor, id = 'foreman') {
105
+ try {
106
+ if (editor.get(id)) {
107
+ editor.default(id)
108
+ return true
109
+ }
110
+ } catch {
111
+ /* 读不到就当不存在 */
112
+ }
113
+ return false
114
+ }