@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/LICENSE +21 -0
- package/README.md +223 -2
- package/THIRD-PARTY.md +25 -0
- package/agents/advisor.md +45 -0
- package/agents/critic.md +66 -0
- package/agents/executor.md +49 -0
- package/agents/foreman.md +120 -0
- package/agents/librarian.md +43 -0
- package/agents/planner.md +51 -0
- package/agents/reviewer.md +60 -0
- package/agents/scout.md +42 -0
- package/default-chain.json +251 -0
- package/index.js +4 -0
- package/licenses/oh-my-opencode-slim.LICENSE +21 -0
- package/licenses/open-code-review.LICENSE +201 -0
- package/package.json +43 -3
- package/skills/review/SKILL.md +116 -0
- package/skills/review/default.md +22 -0
- package/skills/review/go.md +69 -0
- package/skills/review/json.md +1 -0
- package/skills/review/package_json.md +3 -0
- package/skills/review/python.md +77 -0
- package/skills/review/ts_js_tsx_jsx.md +40 -0
- package/skills/review/yaml.md +1 -0
- package/src/chain.js +358 -0
- package/src/concurrency.js +125 -0
- package/src/config-write.js +136 -0
- package/src/constants.js +107 -0
- package/src/index.js +613 -0
- package/src/ledger.js +253 -0
- package/src/models.js +114 -0
- package/src/proxy.js +587 -0
- package/src/reconcile.js +141 -0
- package/src/register.js +137 -0
- package/src/roster.js +44 -0
- package/src/snapshot.js +171 -0
- package/src/stats.js +384 -0
- package/src/supervisor.js +124 -0
- package/src/terminal-gate.js +98 -0
package/src/reconcile.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// 跨重启对账 —— **本仓库唯一没有参照实现可抄的一块**。
|
|
2
|
+
//
|
|
3
|
+
// 语义(使用者裁决 = (b) 档):**从父转录重建**。
|
|
4
|
+
// 宿主不给会话列表 → **父会话的持久化转录是唯一事实源**。
|
|
5
|
+
//
|
|
6
|
+
// **惰性触发**:父会话**下一次活动时**才扫(挂 ctx.session.hook("prompt")),不是启动时扫。
|
|
7
|
+
// 实测:`prompt` 钩子在回合一开始就触发,早于 `session.execution.started`(施工图 §14.7)。
|
|
8
|
+
//
|
|
9
|
+
// **明确不做**:不主动重新派发(会重复副作用)。
|
|
10
|
+
// **如实标注**:恢复是 best-effort,别名与终态结果**不恢复**。
|
|
11
|
+
|
|
12
|
+
import { REASON, STATE } from './constants.js'
|
|
13
|
+
import { VERDICT, classifyTranscript as classify } from './terminal-gate.js'
|
|
14
|
+
|
|
15
|
+
const RECONCILED_ONCE = new Set()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 对账一个父会话。同一次进程生命周期内,每个父会话只跑一次。
|
|
19
|
+
* @returns {Promise<{ scanned: number, reconciled: number, notes: string[] }>}
|
|
20
|
+
*/
|
|
21
|
+
export async function reconcileParent({ ctx, ledger, logger, parentSessionID }) {
|
|
22
|
+
if (!parentSessionID) return { scanned: 0, reconciled: 0, notes: [] }
|
|
23
|
+
if (RECONCILED_ONCE.has(parentSessionID)) return { scanned: 0, reconciled: 0, notes: [] }
|
|
24
|
+
RECONCILED_ONCE.add(parentSessionID)
|
|
25
|
+
|
|
26
|
+
const notes = []
|
|
27
|
+
let scanned = 0
|
|
28
|
+
let reconciled = 0
|
|
29
|
+
|
|
30
|
+
// 只处理"重启前在飞"的记录:进程内本次新建的不需要探活。
|
|
31
|
+
const candidates = ledger
|
|
32
|
+
.all()
|
|
33
|
+
.filter((r) => r.parentSessionID === parentSessionID && r.state === STATE.RUNNING && r.statusUncertain)
|
|
34
|
+
|
|
35
|
+
if (candidates.length === 0) return { scanned: 0, reconciled: 0, notes: [] }
|
|
36
|
+
|
|
37
|
+
for (const record of candidates) {
|
|
38
|
+
if (ledger.isTombstoned(record.taskID)) continue
|
|
39
|
+
scanned += 1
|
|
40
|
+
|
|
41
|
+
if (!record.childSessionID) {
|
|
42
|
+
// 连子会话 id 都没留下 —— 无法探活,如实标未知,保持登记(fail-open)。
|
|
43
|
+
notes.push(`${record.taskID}: 无子会话 id,无法探活`)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let probe
|
|
48
|
+
try {
|
|
49
|
+
probe = await ctx.session.get({ sessionID: record.childSessionID })
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const msg = String(err)
|
|
52
|
+
if (isNotFound(msg)) {
|
|
53
|
+
// 子会话不存在 → 墓碑 + 拆除。**不要复活成永远在跑的幽灵。**
|
|
54
|
+
ledger.tombstone(record.taskID, REASON.CHILD_SESSION_MISSING)
|
|
55
|
+
notes.push(`${record.taskID}: 子会话已不存在 → 墓碑 + 拆除`)
|
|
56
|
+
reconciled += 1
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
// 其他错误 → fail-open,保持登记。
|
|
60
|
+
notes.push(`${record.taskID}: 探活失败,保持登记(${msg.slice(0, 80)})`)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const outcome = extractOutcome(probe)
|
|
65
|
+
if (outcome) {
|
|
66
|
+
// 有终态 outcome → 落真实终态。
|
|
67
|
+
const mapped =
|
|
68
|
+
outcome === 'interrupted' ? STATE.STOPPED : outcome === 'failed' ? STATE.ERROR : STATE.COMPLETED
|
|
69
|
+
ledger.commitTerminal(record.taskID, mapped, {
|
|
70
|
+
source: 'reconcile',
|
|
71
|
+
reason: REASON.UNRECONCILED,
|
|
72
|
+
terminalUnreconciled: true,
|
|
73
|
+
})
|
|
74
|
+
notes.push(`${record.taskID}: 探到终态 ${outcome} → ${mapped}`)
|
|
75
|
+
reconciled += 1
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 存在但空闲/无输出 → 停止候选(这里不宽限等待:对账发生在重启之后,
|
|
80
|
+
// 已经过了远超宽限的时间)。
|
|
81
|
+
let verdict = VERDICT.UNKNOWN
|
|
82
|
+
try {
|
|
83
|
+
const transcript = await ctx.session.context({ sessionID: record.childSessionID })
|
|
84
|
+
verdict = classify(transcript)
|
|
85
|
+
} catch (err) {
|
|
86
|
+
notes.push(`${record.taskID}: 读转录失败(${String(err).slice(0, 80)})`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (verdict === VERDICT.TERMINAL) {
|
|
90
|
+
ledger.commitTerminal(record.taskID, STATE.COMPLETED, {
|
|
91
|
+
source: 'reconcile',
|
|
92
|
+
reason: REASON.UNRECONCILED,
|
|
93
|
+
terminalUnreconciled: true,
|
|
94
|
+
})
|
|
95
|
+
notes.push(`${record.taskID}: 转录显示有终态结果 → completed(unreconciled)`)
|
|
96
|
+
reconciled += 1
|
|
97
|
+
} else if (verdict === VERDICT.ABSENT) {
|
|
98
|
+
ledger.commitTerminal(record.taskID, STATE.STOPPED, {
|
|
99
|
+
source: 'reconcile',
|
|
100
|
+
reason: REASON.UNRECONCILED,
|
|
101
|
+
terminalUnreconciled: true,
|
|
102
|
+
statusUncertain: true,
|
|
103
|
+
})
|
|
104
|
+
notes.push(`${record.taskID}: 存在但无终态结果 → stopped(unreconciled)`)
|
|
105
|
+
reconciled += 1
|
|
106
|
+
} else {
|
|
107
|
+
notes.push(`${record.taskID}: 证据不足,保持登记(fail-open)`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
logger?.debug?.(`[reeve] 对账 ${parentSessionID}:扫 ${scanned},落定 ${reconciled}`)
|
|
112
|
+
return { scanned, reconciled, notes }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 仅供测试:重置"每个父会话只对账一次"的门。 */
|
|
116
|
+
export function _resetReconcileGate() {
|
|
117
|
+
RECONCILED_ONCE.clear()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isNotFound(message) {
|
|
121
|
+
return /not\s*found|NotFound|no such session|unknown session/i.test(message)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 从 `ctx.session.get()` 的返回里提取终态 outcome。
|
|
126
|
+
* ⚠️ 返回结构未完全实测,做形状容错;认不出就返回 null(fail-open)。
|
|
127
|
+
*/
|
|
128
|
+
function extractOutcome(probe) {
|
|
129
|
+
if (!probe || typeof probe !== 'object') return null
|
|
130
|
+
const candidates = [
|
|
131
|
+
probe.outcome,
|
|
132
|
+
probe.session?.outcome,
|
|
133
|
+
probe.info?.outcome,
|
|
134
|
+
probe.session?.info?.outcome,
|
|
135
|
+
probe.execution?.outcome,
|
|
136
|
+
]
|
|
137
|
+
for (const c of candidates) {
|
|
138
|
+
if (typeof c === 'string' && ['succeeded', 'failed', 'interrupted'].includes(c)) return c
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
package/src/register.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// 运行时注册:把 agent 与 skill **从插件自己的包里**注册进引擎。
|
|
2
|
+
//
|
|
3
|
+
// 为什么这么做(使用者:"像 omo 一样"):omo 的 agent 与 prompt 都在插件里,配置只有一行。
|
|
4
|
+
// 搬进来之后,**配置目录里不再需要 agent/ 与 skill/ 两个目录**,也不用往那儿拷文件。
|
|
5
|
+
//
|
|
6
|
+
// 提示词仍是 markdown(`plugin/agents/*.md`、`plugin/skills/<name>/*.md`)——
|
|
7
|
+
// 它们是插件包的一部分,改提示词就改这些文件;代价是**面板改不了**(那是刻意的,见 §6.1)。
|
|
8
|
+
//
|
|
9
|
+
// 三条纪律:
|
|
10
|
+
// 1. **配置优先**:引擎里已经有的(配置目录里的 md、用户的覆盖)一律不动,只补缺的;
|
|
11
|
+
// 2. 建出来的 agent 该 hidden 的就 hidden(前台只留编排者);
|
|
12
|
+
// 3. skill 的 content 里会把真实的绝对路径填进去 —— 因为配套的 rubric 文件也在插件包里,
|
|
13
|
+
// 模型要靠那个路径去读它们。
|
|
14
|
+
|
|
15
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
export const PLUGIN_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
20
|
+
export const AGENTS_DIR = join(PLUGIN_ROOT, 'agents')
|
|
21
|
+
export const SKILLS_DIR = join(PLUGIN_ROOT, 'skills')
|
|
22
|
+
|
|
23
|
+
/** 极小的 frontmatter 解析:只认我们的 md 用的那几个键(够用,不引依赖)。 */
|
|
24
|
+
/** 去掉一层引号(我们的 md 里 `resource: "*"` 是带引号的 YAML 写法)。 */
|
|
25
|
+
function unquote(v) {
|
|
26
|
+
const t = String(v).trim()
|
|
27
|
+
if (t.length >= 2 && ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))) {
|
|
28
|
+
return t.slice(1, -1)
|
|
29
|
+
}
|
|
30
|
+
return t
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseFrontmatter(text) {
|
|
34
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(String(text))
|
|
35
|
+
if (!m) return { data: {}, body: String(text).trim() }
|
|
36
|
+
const data = {}
|
|
37
|
+
const lines = m[1].split(/\r?\n/)
|
|
38
|
+
let current = null
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
if (/^\s*$/.test(line)) continue
|
|
41
|
+
const top = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
|
|
42
|
+
if (top) {
|
|
43
|
+
const [, key, raw] = top
|
|
44
|
+
if (raw === '') {
|
|
45
|
+
current = key
|
|
46
|
+
data[key] = key === 'permissions' ? [] : null
|
|
47
|
+
} else {
|
|
48
|
+
current = null
|
|
49
|
+
data[key] = unquote(raw)
|
|
50
|
+
}
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
const item = /^\s*-\s+([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
|
|
54
|
+
if (item && current === 'permissions' && Array.isArray(data[current])) {
|
|
55
|
+
data[current].push({ [item[1]]: unquote(item[2]) })
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
const cont = /^\s+([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
|
|
59
|
+
if (cont && current === 'permissions' && Array.isArray(data[current]) && data[current].length > 0) {
|
|
60
|
+
Object.assign(data[current][data[current].length - 1], { [cont[1]]: unquote(cont[2]) })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { data, body: m[2].trim() }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** md → 引擎要的 agent 形状(`ctx.agent.transform` 里 `editor.update(id, a => Object.assign(a, ...))`)。 */
|
|
67
|
+
export function toAgentInfo(id, text) {
|
|
68
|
+
const { data, body } = parseFrontmatter(text)
|
|
69
|
+
const info = {
|
|
70
|
+
id,
|
|
71
|
+
name: id,
|
|
72
|
+
description: typeof data.description === 'string' ? data.description : undefined,
|
|
73
|
+
system: body.length > 0 ? body : undefined,
|
|
74
|
+
mode: ['primary', 'subagent', 'all'].includes(data.mode) ? data.mode : 'subagent',
|
|
75
|
+
}
|
|
76
|
+
if (data.hidden === 'true') info.hidden = true
|
|
77
|
+
if (typeof data.model === 'string' && data.model.includes('/')) {
|
|
78
|
+
const [providerID, ...rest] = data.model.split('/')
|
|
79
|
+
info.model = { providerID, id: rest.join('/') }
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(data.permissions)) {
|
|
82
|
+
info.permissions = data.permissions
|
|
83
|
+
.filter((p) => p && p.action && p.resource && p.effect)
|
|
84
|
+
.map((p) => ({ action: p.action, resource: p.resource, effect: p.effect }))
|
|
85
|
+
}
|
|
86
|
+
return info
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 读插件包里的 agents/*.md。 */
|
|
90
|
+
export async function loadAgents({ dir = AGENTS_DIR, readdirImpl = readdir, readFileImpl = readFile } = {}) {
|
|
91
|
+
const out = []
|
|
92
|
+
let files = []
|
|
93
|
+
try {
|
|
94
|
+
files = (await readdirImpl(dir)).filter((f) => f.endsWith('.md')).sort()
|
|
95
|
+
} catch {
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
for (const f of files) {
|
|
99
|
+
try {
|
|
100
|
+
const text = await readFileImpl(join(dir, f), 'utf8')
|
|
101
|
+
out.push(toAgentInfo(f.replace(/\.md$/, ''), text))
|
|
102
|
+
} catch {
|
|
103
|
+
/* 读不到就跳过 —— 宁可少注册,也不要把插件加载搞崩 */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 读插件包里的 skills/<name>/SKILL.md;content 里把真实路径填进去(配套 rubric 靠它定位)。 */
|
|
110
|
+
export async function loadSkills({ dir = SKILLS_DIR, readdirImpl = readdir, readFileImpl = readFile } = {}) {
|
|
111
|
+
const out = []
|
|
112
|
+
let entries = []
|
|
113
|
+
try {
|
|
114
|
+
entries = await readdirImpl(dir, { withFileTypes: true })
|
|
115
|
+
} catch {
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
for (const e of entries) {
|
|
119
|
+
if (!e.isDirectory?.()) continue
|
|
120
|
+
const file = join(dir, e.name, 'SKILL.md')
|
|
121
|
+
try {
|
|
122
|
+
const text = await readFileImpl(file, 'utf8')
|
|
123
|
+
const { data, body } = parseFrontmatter(text)
|
|
124
|
+
out.push({
|
|
125
|
+
id: e.name,
|
|
126
|
+
name: typeof data.name === 'string' ? data.name : e.name,
|
|
127
|
+
description: typeof data.description === 'string' ? data.description.replace(/\s+/g, ' ').trim() : undefined,
|
|
128
|
+
path: file,
|
|
129
|
+
// 把 skill 自己的目录塞进正文 —— rubric 文件住在插件包里,模型得知道绝对路径
|
|
130
|
+
content: `(本 skill 的目录:${join(dir, e.name)})\n\n${body}`,
|
|
131
|
+
})
|
|
132
|
+
} catch {
|
|
133
|
+
/* 同一个 skill 有问题不影响其它 */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out
|
|
137
|
+
}
|
package/src/roster.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// subagent 名册 —— 注入到 `subagent` 工具的描述里。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:REEVE 的 7 个 subagent 都标了 `hidden: true`(前台不可见、
|
|
4
|
+
// openchamber 的 @ 提及列表也不列它们)。代价是**引擎自己的
|
|
5
|
+
// "Available subagents:" 宣传段也会把它们滤掉**(v2.0.32
|
|
6
|
+
// `packages/core/src/tool/plugin/subagent.ts` 的 context 钩子只列 `!agent.hidden`)。
|
|
7
|
+
// → 于是由本插件把名册**补回工具描述**:人看不见,模型看得见。
|
|
8
|
+
//
|
|
9
|
+
// 这份名册与 `agents/*.md` 一一对应(名字必须逐字一致,否则 subagent 工具会回
|
|
10
|
+
// "Unknown agent")。`plugin/test/unit.mjs` 里有一条测试盯着"名字不漂移"。
|
|
11
|
+
|
|
12
|
+
export const ROSTER_MARKER = 'REEVE 名册'
|
|
13
|
+
|
|
14
|
+
/** name = agents/<name>.md 的文件名;duty 与那些文件的 description 同义。 */
|
|
15
|
+
export const SUBAGENT_ROSTER = Object.freeze([
|
|
16
|
+
{ name: 'scout', duty: '代码库侦察:回答"X 在哪里、怎么工作的",带 路径:行号。只读' },
|
|
17
|
+
{ name: 'librarian', duty: '外部资料检索:外部库/规范/上游怎么规定的,每条带 URL。只读' },
|
|
18
|
+
{ name: 'planner', duty: '把需求写成"不携带上下文的执行者也能照着干完"的实施文档。不写代码' },
|
|
19
|
+
{ name: 'critic', duty: '计划评审:多轮 × 多角度,默认立场是不通过。只读' },
|
|
20
|
+
{ name: 'advisor', duty: '对抗性顾问:找出方案最可能失败的那个假设与代价。只读' },
|
|
21
|
+
{ name: 'executor', duty: '执行者:照实施文档干活,唯一的实现者。不重新设计、不扩大范围' },
|
|
22
|
+
{ name: 'reviewer', duty: '代码 diff 审查:五维 + 结构化输出(severity/category/path/行号/锚点)。只读' },
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 生成要追加到 `subagent` 工具描述后面的段落。
|
|
27
|
+
* @param {ReadonlyArray<{name:string,duty:string}>} roster
|
|
28
|
+
*/
|
|
29
|
+
export function buildSubagentRoster(roster = SUBAGENT_ROSTER) {
|
|
30
|
+
return [
|
|
31
|
+
`可用 subagent(${ROSTER_MARKER} —— 它们在前台不可见,只能由你委派;\`agent\` 参数逐字取下面的名字):`,
|
|
32
|
+
...roster.map((a) => `- ${a.name}: ${a.duty}`),
|
|
33
|
+
].join('\n')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 幂等地把名册接进一段工具描述:已经有标记就原样返回(钩子每回合都会触发)。
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function withRoster(description, roster = SUBAGENT_ROSTER) {
|
|
41
|
+
const base = typeof description === 'string' ? description : ''
|
|
42
|
+
if (base.includes(ROSTER_MARKER)) return base
|
|
43
|
+
return base.length > 0 ? `${base}\n\n${buildSubagentRoster(roster)}` : buildSubagentRoster(roster)
|
|
44
|
+
}
|
package/src/snapshot.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// 台账快照:把内存里的账**投影**成一份 JSON 文件,给只读面板(openchamber 扩展)读。
|
|
2
|
+
//
|
|
3
|
+
// 为什么是"投影"而不是"第二份真相":
|
|
4
|
+
// 权威仍然是 ctx.storage 里的记录;这个文件只是给人看的视图。
|
|
5
|
+
// 面板**只读**它,永远不会写回 —— 也就不会出现两处互相覆盖。
|
|
6
|
+
//
|
|
7
|
+
// 为什么用文件而不是让面板读 ctx.storage:
|
|
8
|
+
// 面板跑在 openchamber 的 iframe 里(浏览器环境、有 CSP、无网络),
|
|
9
|
+
// 拿不到引擎的 SQLite;而宿主给了它一个 `readFile` 能力。
|
|
10
|
+
// → 文件是两边唯一都够得着的通道。
|
|
11
|
+
//
|
|
12
|
+
// 原子性:先写 `<path>.tmp` 再 rename。面板永远看不到半截 JSON。
|
|
13
|
+
// 失败只记日志:**记账的正确性绝不依赖这个文件**。
|
|
14
|
+
|
|
15
|
+
import { mkdir, rename, writeFile, unlink } from 'node:fs/promises'
|
|
16
|
+
import { dirname } from 'node:path'
|
|
17
|
+
|
|
18
|
+
export class SnapshotWriter {
|
|
19
|
+
#path
|
|
20
|
+
#logger
|
|
21
|
+
#debounceMs
|
|
22
|
+
#timer = null
|
|
23
|
+
#building = null
|
|
24
|
+
#dirty = false
|
|
25
|
+
|
|
26
|
+
constructor({ path, logger = console, debounceMs = 250 } = {}) {
|
|
27
|
+
if (!path) throw new Error('SnapshotWriter 需要 path')
|
|
28
|
+
this.#path = path
|
|
29
|
+
this.#logger = logger
|
|
30
|
+
this.#debounceMs = debounceMs
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get path() {
|
|
34
|
+
return this.#path
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 记账变了:合并成一次写(250ms 内的连续变化只落一次盘)。 */
|
|
38
|
+
schedule(build) {
|
|
39
|
+
this.#building = build
|
|
40
|
+
this.#dirty = true
|
|
41
|
+
if (this.#timer) return
|
|
42
|
+
this.#timer = setTimeout(() => {
|
|
43
|
+
this.#timer = null
|
|
44
|
+
void this.flush()
|
|
45
|
+
}, this.#debounceMs)
|
|
46
|
+
// 定时器不该拖住进程退出(Node 独有;宿主里 also fine)
|
|
47
|
+
this.#timer?.unref?.()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cancel() {
|
|
51
|
+
if (this.#timer) clearTimeout(this.#timer)
|
|
52
|
+
this.#timer = null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 立刻写一次。返回是否真的写了(false = 没有变化)。 */
|
|
56
|
+
async flush() {
|
|
57
|
+
if (!this.#dirty || !this.#building) return false
|
|
58
|
+
this.#dirty = false
|
|
59
|
+
let doc
|
|
60
|
+
try {
|
|
61
|
+
doc = this.#building()
|
|
62
|
+
} catch (err) {
|
|
63
|
+
this.#logger?.debug?.(`[reeve] 快照构建失败(忽略):${String(err)}`)
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
const tmp = `${this.#path}.tmp`
|
|
67
|
+
try {
|
|
68
|
+
await mkdir(dirname(this.#path), { recursive: true })
|
|
69
|
+
await writeFile(tmp, JSON.stringify(doc, null, 2), 'utf8')
|
|
70
|
+
await rename(tmp, this.#path)
|
|
71
|
+
return true
|
|
72
|
+
} catch (err) {
|
|
73
|
+
this.#logger?.debug?.(`[reeve] 快照写入失败(忽略):${String(err)}`)
|
|
74
|
+
try {
|
|
75
|
+
await unlink(tmp)
|
|
76
|
+
} catch {
|
|
77
|
+
/* 清不掉就算了 */
|
|
78
|
+
}
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 把台账投影成面板要的形状。
|
|
86
|
+
* **注意**:这里刻意只放面板显示需要的字段 —— 不多不少,免得这份文件变成事实上的第二套接口。
|
|
87
|
+
*
|
|
88
|
+
* `agents`:启动时从引擎读到的 agent 表(谁在前台可见 / 谁被藏)。
|
|
89
|
+
* 为什么放进快照:`opencode debug agents` 走的是 CLI 自己连的那个服务,
|
|
90
|
+
* 未必是你以为的那个引擎(实测踩过 —— 它连到另一个 HOME 的服务,报出别人的表)。
|
|
91
|
+
* 这一份是**引擎进程内部**读的,落盘可核对,也是"前台只出现编排者"这条设计的证据。
|
|
92
|
+
*/
|
|
93
|
+
export function buildSnapshot(
|
|
94
|
+
records,
|
|
95
|
+
{ now = Date.now(), version = null, agents = null, roster = null, proxy = null, registered = null, endpoint = null, chain = null, models = null, stats = null } = {},
|
|
96
|
+
) {
|
|
97
|
+
const tasks = records
|
|
98
|
+
.map((r) => ({
|
|
99
|
+
taskID: r.taskID,
|
|
100
|
+
parentSessionID: r.parentSessionID ?? null,
|
|
101
|
+
childSessionID: r.childSessionID ?? null,
|
|
102
|
+
agent: r.agent ?? null,
|
|
103
|
+
description: r.description ?? null,
|
|
104
|
+
background: r.background === true,
|
|
105
|
+
state: r.state ?? null,
|
|
106
|
+
reason: r.reason ?? null,
|
|
107
|
+
terminalSource: r.terminalSource ?? null,
|
|
108
|
+
timedOut: r.timedOut === true,
|
|
109
|
+
statusUncertain: r.statusUncertain === true,
|
|
110
|
+
startedAt: r.startedAt ?? null,
|
|
111
|
+
endedAt: r.endedAt ?? null,
|
|
112
|
+
}))
|
|
113
|
+
.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0))
|
|
114
|
+
|
|
115
|
+
const counts = { total: tasks.length, running: 0, completed: 0, error: 0, stopped: 0, reconciled: 0 }
|
|
116
|
+
for (const t of tasks) {
|
|
117
|
+
if (Object.prototype.hasOwnProperty.call(counts, t.state)) counts[t.state] += 1
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
generatedAt: now,
|
|
122
|
+
plugin: 'reeve',
|
|
123
|
+
version,
|
|
124
|
+
agentTable: agents,
|
|
125
|
+
roster,
|
|
126
|
+
proxy,
|
|
127
|
+
chain: chain == null ? null : projectChain(chain),
|
|
128
|
+
models,
|
|
129
|
+
registered,
|
|
130
|
+
endpoint,
|
|
131
|
+
stats,
|
|
132
|
+
counts,
|
|
133
|
+
tasks,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 链的状态 → 面板要的那一小块。
|
|
139
|
+
* `tiers` 就是 `~/.reeve/chain.json` 里那份形状(面板编辑的就是它,写回也是它)。
|
|
140
|
+
*/
|
|
141
|
+
export function projectChain(chain) {
|
|
142
|
+
if (!chain) return null
|
|
143
|
+
return {
|
|
144
|
+
tiersPath: chain.tiersPath ?? null,
|
|
145
|
+
source: chain.source ?? null,
|
|
146
|
+
from: chain.from ?? null,
|
|
147
|
+
derived: chain.derived === true,
|
|
148
|
+
tiers: chain.tiers ?? null,
|
|
149
|
+
problems: chain.problems ?? [],
|
|
150
|
+
error: chain.error ?? null,
|
|
151
|
+
changedAt: chain.changedAt ?? null,
|
|
152
|
+
appliedAt: chain.appliedAt ?? null,
|
|
153
|
+
drift: chain.drift ?? null,
|
|
154
|
+
lastError: chain.lastError ?? null,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 从 `ctx.agent.list()` 的返回里抽出"谁可见"的最小形状。
|
|
160
|
+
* 读不到就返回 null —— 快照里没有这一节,不假装知道。
|
|
161
|
+
*/
|
|
162
|
+
export function projectAgentTable(list) {
|
|
163
|
+
if (!Array.isArray(list)) return null
|
|
164
|
+
const one = (a) => ({ id: a?.id ?? null, mode: a?.mode ?? null, hidden: a?.hidden === true })
|
|
165
|
+
return {
|
|
166
|
+
total: list.length,
|
|
167
|
+
visible: list.filter((a) => a?.mode !== 'subagent' && a?.hidden !== true).map(one),
|
|
168
|
+
hidden: list.filter((a) => a?.hidden === true).map(one),
|
|
169
|
+
subagent: list.filter((a) => a?.mode === 'subagent' && a?.hidden !== true).map(one),
|
|
170
|
+
}
|
|
171
|
+
}
|