@fanchao8609/agent_brain_sync 1.5.5 → 1.5.6
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/bin/abs.js +64 -6
- package/hooks/abs.opencode.ts +113 -0
- package/hooks/abs.pi.ts +130 -0
- package/package.json +1 -1
- package/src/install.js +316 -288
- package/src/store.js +27 -6
- package/src/todo.js +22 -0
package/bin/abs.js
CHANGED
|
@@ -69,8 +69,11 @@ function parseArgv(args) {
|
|
|
69
69
|
else if (a === '--id') { o.id = args[++i]; }
|
|
70
70
|
else if (a === '--section') { o.section = args[++i]; }
|
|
71
71
|
else if (a === '--note') { o.note = args[++i]; }
|
|
72
|
+
else if (a === '--as') { o.as = args[++i]; }
|
|
72
73
|
else if (a === '--payload') { o.payload = args[++i]; }
|
|
73
74
|
else if (a === '--keep-days') { o.keepDays = args[++i]; }
|
|
75
|
+
else if (a === '--tags') { o.tags = args[++i]; }
|
|
76
|
+
else if (a === '--help') { o.help = true; }
|
|
74
77
|
else if (a === '--dry-run') { o.dryRun = true; }
|
|
75
78
|
else if (a === '--yes') { o.yes = true; }
|
|
76
79
|
else if (a === '--repair') { o.repair = true; }
|
|
@@ -95,7 +98,9 @@ const usage = `abs — agent-brain-sync 记忆工具
|
|
|
95
98
|
abs todo add <id> [--note ..] [--section ..] 登记任务 (start 同义)
|
|
96
99
|
abs todo note <id> --note "断点/进度" 实时落 ↳ 断点 行
|
|
97
100
|
abs todo blocked <id> --note "卡点原因" 移入 Blocked 区
|
|
98
|
-
abs todo done <id>
|
|
101
|
+
abs todo done <id> [--as 落地|否决|仅方案] 完成;结语标明到底"做成了没有"
|
|
102
|
+
默认 落地。否决=评估后不做(含做了又撤);仅方案=只设计过
|
|
103
|
+
不加结语或结语失真会让下一个会话把"想过"当成"做完了"。
|
|
99
104
|
abs log "完成 X:…" 记一行工作成果 (无参=查看)
|
|
100
105
|
abs note "经验一句话" [--tags 坑,docker] 经验实时暂存 → sources/
|
|
101
106
|
|
|
@@ -115,6 +120,47 @@ const usage = `abs — agent-brain-sync 记忆工具
|
|
|
115
120
|
注: abs wrapup / abs teardown-check 是 hook 内部命令, 不需手动调用。
|
|
116
121
|
`;
|
|
117
122
|
|
|
123
|
+
/** 子命令级用法(abs <cmd> --help 时打印)。 */
|
|
124
|
+
const subUsage = {
|
|
125
|
+
install: [
|
|
126
|
+
'abs install — 把 MCP + hook + skill 安装到 AI 编码宿主',
|
|
127
|
+
'',
|
|
128
|
+
'用法:',
|
|
129
|
+
' abs install [--agent <宿主>] [--yes] [--no-mcp] [--no-skill]',
|
|
130
|
+
'',
|
|
131
|
+
'参数:',
|
|
132
|
+
' --agent <宿主> 只装一个宿主: claude-code | codex | opencode | pi',
|
|
133
|
+
' (不传则在 TTY 下交互多选; 非 TTY / --yes 时为全部)',
|
|
134
|
+
' --yes 不交互,直接装全部宿主(自动化用)',
|
|
135
|
+
' --no-mcp 不注册 MCP server',
|
|
136
|
+
' --no-skill 不装 skill',
|
|
137
|
+
'',
|
|
138
|
+
'说明:',
|
|
139
|
+
' • 幂等: 重复安装不会叠加,也不会覆盖宿主的其它配置',
|
|
140
|
+
' • 配置文件解析失败时会中止(不会清空你的配置)',
|
|
141
|
+
' • 安装前会备份原配置(同日多份保留最近 5 份)',
|
|
142
|
+
'',
|
|
143
|
+
'例:',
|
|
144
|
+
' abs install 交互选择',
|
|
145
|
+
' abs install --yes 全部宿主',
|
|
146
|
+
' abs install --agent pi --yes 只装 pi',
|
|
147
|
+
].join('\n'),
|
|
148
|
+
uninstall: [
|
|
149
|
+
'abs uninstall — 从 AI 编码宿主移除 abs 的 MCP / hook / skill',
|
|
150
|
+
'',
|
|
151
|
+
'用法:',
|
|
152
|
+
' abs uninstall [--agent <宿主>] [--yes]',
|
|
153
|
+
'',
|
|
154
|
+
'参数:',
|
|
155
|
+
' --agent <宿主> 只卸一个宿主: claude-code | codex | opencode | pi(不传=全部)',
|
|
156
|
+
' --yes 不交互',
|
|
157
|
+
'',
|
|
158
|
+
'说明:',
|
|
159
|
+
' • 只删 abs 自己装的东西,保留你其它的 hook / MCP / 配置字段',
|
|
160
|
+
' • 配置文件无法解析时会跳过该文件(不覆盖)但仍清理 abs 的脚本与 skill',
|
|
161
|
+
].join('\n'),
|
|
162
|
+
};
|
|
163
|
+
|
|
118
164
|
/**
|
|
119
165
|
* 拒绝多余的未知位置参数。
|
|
120
166
|
* 坑: 以前只读命令(如 abs todo)会静默忽略多余词 —— `abs todo add x` 照样打印看板、
|
|
@@ -201,17 +247,29 @@ async function main() {
|
|
|
201
247
|
` 登记任务: abs todo add <id> --note "做什么"`,
|
|
202
248
|
);
|
|
203
249
|
}
|
|
204
|
-
rejectExtra(rest2, `abs todo ${sub} <id>${action === 'done' ? '' : ' --note "…"'}`);
|
|
205
|
-
if (!id) throw new Error(`✗ 缺 <id>\n 用法: abs todo ${sub} <id>${action === 'done' ? '' : ' --note "…"'}`);
|
|
206
|
-
console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note }));
|
|
250
|
+
rejectExtra(rest2, `abs todo ${sub} <id>${action === 'done' ? ' [--as 落地|否决|仅方案]' : ' --note "…"'}`);
|
|
251
|
+
if (!id) throw new Error(`✗ 缺 <id>\n 用法: abs todo ${sub} <id>${action === 'done' ? ' [--as 落地|否决|仅方案]' : ' --note "…"'}`);
|
|
252
|
+
console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note, as: opts.as }));
|
|
207
253
|
break;
|
|
208
254
|
}
|
|
209
255
|
case 'note': {
|
|
210
256
|
console.log(await cmdNote({ dir: opts.dir, text: opts._.join(' '), tags: opts.tags }));
|
|
211
257
|
break;
|
|
212
258
|
}
|
|
213
|
-
case 'install':
|
|
214
|
-
case 'uninstall':
|
|
259
|
+
case 'install':
|
|
260
|
+
case 'uninstall': {
|
|
261
|
+
// 子命令级 --help: 打印用法后直接返回。
|
|
262
|
+
// 坑(INSTALL-HELP-FOOTGUN): 以前 --help 只在顶层命令被识别,跟在 install 后面时
|
|
263
|
+
// 落到 parseArgv 的通用分支,而 install 分支根本不读它 → 用户想看帮助,
|
|
264
|
+
// 实际执行了全量安装(幂等不炸,但确实改了四宿主配置,写了 15 个文件)。
|
|
265
|
+
if (opts.help) { console.log(subUsage[cmd]); break; }
|
|
266
|
+
if (cmd === 'install') {
|
|
267
|
+
await runInstall({ agent: opts.agent, mcp: opts.mcp !== false, skill: opts.skill !== false, yes: opts.yes });
|
|
268
|
+
} else {
|
|
269
|
+
await runUninstall({ agent: opts.agent, yes: opts.yes });
|
|
270
|
+
}
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
215
273
|
case 'agents': console.log(installSummary()); break;
|
|
216
274
|
case 'query': {
|
|
217
275
|
console.log(await cmdQuery({ dir: opts.dir, terms: opts._ }));
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* abs (agent-brain-sync) — Opencode plugin。
|
|
3
|
+
* 纯触发: 生命周期事件 → 技术日志一行 (~/.abs/log/hooks.log, ABS_LOG_DIR 可覆盖)。fire-and-forget。
|
|
4
|
+
* 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
|
|
5
|
+
* Opencode 事件: session.created / session.idle / session.deleted (idle = 每轮结束, 对应 pi 的 agent_end)。
|
|
6
|
+
* 收尾注入: session.idle 且本会话真改过文件 + .brain 今日无记录 → promptAsync 推 agent 走收尾循环。
|
|
7
|
+
*/
|
|
8
|
+
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
9
|
+
import { homedir } from "node:os"
|
|
10
|
+
import { join, dirname } from "node:path"
|
|
11
|
+
|
|
12
|
+
const server = async ({ client, directory }) => {
|
|
13
|
+
// 本会话是否真改过文件 (由 tool.execute.after 观测)
|
|
14
|
+
let wroteFiles = false
|
|
15
|
+
let nudged = false
|
|
16
|
+
let idleSeen = false
|
|
17
|
+
|
|
18
|
+
async function logHook(evt) {
|
|
19
|
+
try {
|
|
20
|
+
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
21
|
+
const d = new Date()
|
|
22
|
+
const pad = (n) => String(n).padStart(2, "0")
|
|
23
|
+
const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
|
|
24
|
+
await mkdir(dir, { recursive: true })
|
|
25
|
+
await appendFile(join(dir, "hooks.log"), "[" + stamp + "] opencode:" + evt + "\n")
|
|
26
|
+
} catch {} // fire-and-forget: 永不阻塞宿主
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 定位当前项目的 .brain/:只看 cwd 本身,不向上搜索(防爬到家目录图谱)
|
|
30
|
+
async function findBrain(cwd) {
|
|
31
|
+
const d = cwd || process.cwd()
|
|
32
|
+
try { const st = await stat(join(d, ".brain")); return st.isDirectory() ? join(d, ".brain") : null } catch { return null }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 判定"今天是否收尾过": 必须匹配 log.md 的条目头 '## [YYYY-MM-DD HH:MM]'。
|
|
36
|
+
// 坑: 曾用裸日期 substring(includes('2026-09-10')), 结果正文/任务行里任何一处
|
|
37
|
+
// 提到今天就能把 nudge 永久压掉(误判为已收尾), 守卫形同虚设。
|
|
38
|
+
async function loggedToday(brain) {
|
|
39
|
+
try {
|
|
40
|
+
const txt = await readFile(join(brain, "log.md"), "utf8")
|
|
41
|
+
const d = new Date()
|
|
42
|
+
const pad = (n) => String(n).padStart(2, "0")
|
|
43
|
+
const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
44
|
+
return new RegExp("^## \\[" + today + " \\d{2}:\\d{2}\\]", "m").test(txt)
|
|
45
|
+
} catch { return false }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const TEARDOWN_MSG =
|
|
49
|
+
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\n" +
|
|
50
|
+
"1) 跑 abs load 看 Today 还有哪些未完成;\n" +
|
|
51
|
+
"2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \"断点\";\n" +
|
|
52
|
+
"3) 值得留的经验 abs note \"...\"(宁少勿滥,能从代码 grep 到的不记);\n" +
|
|
53
|
+
"4) abs log \"完成 X:...\" 记一行工作成果,新页同步进 index。\n" +
|
|
54
|
+
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\"无可沉淀\"即可。"
|
|
55
|
+
|
|
56
|
+
// bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
|
|
57
|
+
const READONLY_CMD = /^\s*(ls|cat|grep|rg|find|head|tail|wc|git\s+(status|log|diff|show|branch)|pwd|which|echo|node\s+-v|npm\s+(ls|view)|curl)\b/
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
// 观测真实写操作: write/edit/patch 类工具成功即标记。
|
|
61
|
+
// 坑: 曾漏掉 bash —— opencode 里很多修改是经 bash(heredoc/sed) 完成的, 纯 bash 会话
|
|
62
|
+
// 永远不置位 wroteFiles, 于是收尾提醒静默不发。与 pi 侧 WRITE_TOOLS 保持一致。
|
|
63
|
+
"tool.execute.after": async ({ tool, args }) => {
|
|
64
|
+
const t = String(tool || "").toLowerCase()
|
|
65
|
+
if (["write", "edit", "patch", "multiedit", "apply_patch"].includes(t)) {
|
|
66
|
+
wroteFiles = true
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
// bash 里只有非只读命令算改文件(ls/cat/git status 之类不算)
|
|
70
|
+
if (t === "bash") {
|
|
71
|
+
const cmd = String((args && (args.command || args.cmd)) || "")
|
|
72
|
+
if (cmd && !READONLY_CMD.test(cmd)) wroteFiles = true
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
event: async ({ event }) => {
|
|
77
|
+
const type = event && event.type
|
|
78
|
+
if (!type) return
|
|
79
|
+
if (type === "session.created" || type === "session.deleted") {
|
|
80
|
+
await logHook(type)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (type !== "session.idle") return // idle = 每轮结束, 不记日志(太吵), 只做收尾判定
|
|
84
|
+
const cwd = directory || process.cwd()
|
|
85
|
+
const brain = await findBrain(cwd)
|
|
86
|
+
// 可观测性: 首次 idle 无条件留一行痕(否则无法区分“事件没触发”与“被守卫拦下”)
|
|
87
|
+
if (!idleSeen) {
|
|
88
|
+
idleSeen = true
|
|
89
|
+
await logHook(`session.idle:seen cwd=${cwd} brain=${brain || 'none'}`)
|
|
90
|
+
}
|
|
91
|
+
if (nudged || !wroteFiles) return
|
|
92
|
+
const sessionID = event.properties && event.properties.sessionID
|
|
93
|
+
if (!sessionID) return
|
|
94
|
+
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
95
|
+
if (await loggedToday(brain)) return // 今天已收尾过
|
|
96
|
+
nudged = true
|
|
97
|
+
await logHook("session.idle:teardown-nudge")
|
|
98
|
+
try {
|
|
99
|
+
await client.session.promptAsync({
|
|
100
|
+
path: { id: sessionID },
|
|
101
|
+
query: { directory: cwd },
|
|
102
|
+
body: { parts: [{ type: "text", text: TEARDOWN_MSG }] },
|
|
103
|
+
})
|
|
104
|
+
} catch {} // 注入失败不阻塞宿主
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default {
|
|
110
|
+
id: "abs",
|
|
111
|
+
server,
|
|
112
|
+
}
|
|
113
|
+
@@MARK@@
|
package/hooks/abs.pi.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* abs (agent-brain-sync) — Pi extension。
|
|
3
|
+
* 纯触发: 会话生命周期事件 → 技术日志一行 (~/.abs/log/hooks.log, ABS_LOG_DIR 可覆盖)。fire-and-forget。
|
|
4
|
+
* 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
|
|
5
|
+
* Pi 事件: session_start / session_shutdown (对应 host hook 的 SessionStart/SessionEnd)。
|
|
6
|
+
* session_shutdown 额外触发 abs wrapup: 把当前项目未完成任务快照到 wrapup.log (跨会话收尾保险)。
|
|
7
|
+
* agent_end 收尾注入: 本会话真改过文件 + .brain 今日无记录 → 注入一条收尾指令, 逼 agent 走收尾循环
|
|
8
|
+
* (被动记日志不够 —— 没人提醒就不会有人收尾)。每会话最多一次, 且已收尾后不再打扰。
|
|
9
|
+
*/
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
11
|
+
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
12
|
+
import { homedir } from "node:os"
|
|
13
|
+
import { join, dirname } from "node:path"
|
|
14
|
+
import { spawn } from "node:child_process"
|
|
15
|
+
|
|
16
|
+
const ABS_BIN = "@@ABS_BIN@@"
|
|
17
|
+
|
|
18
|
+
async function logHook(evt: string): Promise<void> {
|
|
19
|
+
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
20
|
+
const d = new Date()
|
|
21
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
22
|
+
const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
|
|
23
|
+
const line = "[" + stamp + "] pi:" + evt + "\n"
|
|
24
|
+
await mkdir(dir, { recursive: true })
|
|
25
|
+
await appendFile(join(dir, "hooks.log"), line)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 会话结束时快照当前项目滞留任务到 wrapup.log(detached fire-and-forget,wrapup 自身幂等去重不刷屏)。
|
|
29
|
+
// cwd 用事件 ctx.cwd(当前项目),让 abs 在该目录定位 .brain/。
|
|
30
|
+
function snapshotWrapup(cwd: string): void {
|
|
31
|
+
try {
|
|
32
|
+
const child = spawn(process.execPath, [ABS_BIN, "wrapup"], {
|
|
33
|
+
cwd: cwd || process.cwd(), stdio: "ignore", detached: true,
|
|
34
|
+
})
|
|
35
|
+
child.unref()
|
|
36
|
+
} catch {} // fire-and-forget
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---- 收尾注入判定 ----
|
|
40
|
+
// 真改过文件的工具(read/grep/ls 不算, 那些不产生可沉淀的产出)
|
|
41
|
+
const WRITE_TOOLS = new Set(["write", "edit", "multi_edit", "apply_patch", "bash"])
|
|
42
|
+
// bash 里只跑查询类命令不算改文件
|
|
43
|
+
const READONLY_CMD = /^\s*(ls|cat|grep|rg|find|head|tail|wc|git\s+(status|log|diff|show|branch)|pwd|which|echo|node\s+-v|npm\s+(ls|view)|curl)\b/
|
|
44
|
+
|
|
45
|
+
function hasWriteWork(toolResults: any[]): boolean {
|
|
46
|
+
for (const r of toolResults || []) {
|
|
47
|
+
const name = String(r?.toolName || "")
|
|
48
|
+
if (!WRITE_TOOLS.has(name)) continue
|
|
49
|
+
if (r?.isError) continue
|
|
50
|
+
if (name === "bash") {
|
|
51
|
+
const cmd = String(r?.input?.command ?? r?.args?.command ?? "")
|
|
52
|
+
if (READONLY_CMD.test(cmd)) continue
|
|
53
|
+
}
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 定位当前项目的 .brain/:只看 cwd 本身,不向上搜索(防爬到家目录图谱)。 */
|
|
60
|
+
async function findBrain(cwd: string): Promise<string | null> {
|
|
61
|
+
const d = cwd || process.cwd()
|
|
62
|
+
try { const st = await stat(join(d, ".brain")); return st.isDirectory() ? join(d, ".brain") : null } catch { return null }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* .brain/log.md 今天有记录吗? 有=已收尾, 不再打扰。
|
|
67
|
+
* 必须匹配条目头 '## [YYYY-MM-DD HH:MM]' —— 裸日期 substring 会被正文里任意一处
|
|
68
|
+
* 今天的日期误命中, 导致"已收尾"误判、nudge 永不触发。
|
|
69
|
+
*/
|
|
70
|
+
async function loggedToday(brain: string): Promise<boolean> {
|
|
71
|
+
try {
|
|
72
|
+
const txt = await readFile(join(brain, "log.md"), "utf8")
|
|
73
|
+
const d = new Date()
|
|
74
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
75
|
+
const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
76
|
+
// 注意: 这里是真实 TS, 不是生成代码的模板字符串。用拼接形式避免反斜杠逃逸歧义。
|
|
77
|
+
return new RegExp("^## \\[" + today + " \\d{2}:\\d{2}\\]", "m").test(txt)
|
|
78
|
+
} catch { return false }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default function absPiHook(pi: ExtensionAPI): void {
|
|
82
|
+
pi.on("session_start", () => logHook("session_start").catch(() => {}))
|
|
83
|
+
|
|
84
|
+
// 收尾注入: 每个会话最多一次, 避免反复打扰。
|
|
85
|
+
let teardownNudged = false
|
|
86
|
+
let agentEndSeen = false
|
|
87
|
+
pi.on("agent_end", async (event: any, ctx: any) => {
|
|
88
|
+
if (teardownNudged) return
|
|
89
|
+
const cwd = (ctx && ctx.cwd) || process.cwd()
|
|
90
|
+
const brain = await findBrain(cwd)
|
|
91
|
+
// 可观测性: 本会话首次 agent_end 无条件留一行痕。
|
|
92
|
+
// 没有它就无法区分三种状态: ①事件没触发 ②触发了但被守卫拦下 ③真注入了。
|
|
93
|
+
if (!agentEndSeen) {
|
|
94
|
+
agentEndSeen = true
|
|
95
|
+
// 带上 cwd 与命中的 brain —— 否则事后无法解释“为何这条 nudge 会出现”
|
|
96
|
+
await logHook(`agent_end:seen cwd=${cwd} brain=${brain || 'none'}`).catch(() => {})
|
|
97
|
+
}
|
|
98
|
+
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
99
|
+
if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
|
|
100
|
+
if (await loggedToday(brain)) return // 今天已收尾过
|
|
101
|
+
teardownNudged = true
|
|
102
|
+
await logHook("agent_end:teardown-nudge").catch(() => {})
|
|
103
|
+
try {
|
|
104
|
+
pi.sendUserMessage(
|
|
105
|
+
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\n" +
|
|
106
|
+
"1) 跑 abs load 看 Today 还有哪些未完成;\n" +
|
|
107
|
+
"2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \"断点\";\n" +
|
|
108
|
+
"3) 值得留的经验 abs note \"...\"(宁少勿滥,能从代码 grep 到的不记);\n" +
|
|
109
|
+
"4) abs log \"完成 X:...\" 记一行工作成果,新页同步进 index。\n" +
|
|
110
|
+
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\"无可沉淀\"即可。",
|
|
111
|
+
{ deliverAs: "followUp" },
|
|
112
|
+
)
|
|
113
|
+
} catch {}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
pi.on("session_shutdown", (_e: any, ctx: any) => {
|
|
117
|
+
snapshotWrapup((ctx && ctx.cwd) || process.cwd())
|
|
118
|
+
logHook("session_shutdown").catch(() => {})
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** agent_end 的 event.messages 里翻出 toolResult 消息。 */
|
|
123
|
+
function collectToolResults(messages: any[]): any[] {
|
|
124
|
+
const out: any[] = []
|
|
125
|
+
for (const m of messages || []) {
|
|
126
|
+
if (m && m.role === "toolResult") out.push(m)
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
@@MARK@@
|
package/package.json
CHANGED
package/src/install.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { promises as fs } from 'node:fs';
|
|
9
9
|
import * as fsSync from 'node:fs';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
|
-
import { join, dirname, resolve } from 'node:path';
|
|
11
|
+
import { join, dirname, resolve, basename } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { HOSTS, hostByKey } from './hosts.js';
|
|
14
14
|
|
|
@@ -17,28 +17,57 @@ const HOOK_TEMPLATE = join(ABS_DIR, 'hooks', 'event.sh');
|
|
|
17
17
|
const SKILL_SOURCE = join(ABS_DIR, 'skill', 'SKILL.md');
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* 本包的稳定入口路径解析(mcp.js / abs.js 通用)。
|
|
21
|
+
*
|
|
21
22
|
* 坑: 直接用 ABS_DIR 是不稳定的 —— ABS_DIR = "install.js 自己住哪", 从仓库跑
|
|
22
|
-
* `abs install`
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* `abs install` 就把仓库路径烧进宿主配置/hook 脚本。之后若卸载/移动仓库或全局包,
|
|
24
|
+
* 该路径直接失效; 而某些分支"已存在即跳过", 一旦写错永不修正。
|
|
25
|
+
*
|
|
26
|
+
* 故统一优先解析本包的稳定安装位置(全局 node_modules),解析不到才回退 ABS_DIR。
|
|
27
|
+
* 单一收口: MCP 注册与 hook 脚本都走这里 —— 曾经 MCP 修了、hook 没修,
|
|
28
|
+
* 同一份安装里 hook 指向仓库而 MCP 指向全局,是两个不同的包。
|
|
29
|
+
*
|
|
30
|
+
* @param entryFile 包内相对 bin/ 的文件名,如 'mcp.js' / 'abs.js'
|
|
25
31
|
*/
|
|
26
|
-
function
|
|
32
|
+
function stableBinPath(entryFile) {
|
|
27
33
|
// 本包名(package.json),用于反查全局安装位置
|
|
28
34
|
let pkgName = '@fanchao8609/agent_brain_sync';
|
|
29
35
|
try {
|
|
30
36
|
pkgName = JSON.parse(fsSync.readFileSync(join(ABS_DIR, 'package.json'), 'utf8')).name || pkgName;
|
|
31
37
|
} catch { /* 保持默认 */ }
|
|
38
|
+
|
|
39
|
+
// 各平台 npm 全局根下的本包位置。
|
|
40
|
+
// Unix (nvm/homebrew/apt): <node根>/lib/node_modules/<pkg>
|
|
41
|
+
// Windows: %APPDATA%\npm\node_modules\<pkg>
|
|
42
|
+
// 注: 曾经只有 Unix 候选 —— Windows 上两个都不中,会回退到 ABS_DIR(仓库路径),
|
|
43
|
+
// 即 H3 在 Windows 上仍会复现。此条为代码层推断,未在 Windows 实测。
|
|
44
|
+
const nodeRoot = dirname(dirname(process.execPath)); // <node>/bin/node → <node>
|
|
32
45
|
const candidates = [
|
|
33
|
-
//
|
|
34
|
-
join(dirname(process.execPath), '..', 'lib', 'node_modules', pkgName, 'bin', 'mcp.js'),
|
|
35
|
-
join(process.env.npm_config_prefix || '', 'lib', 'node_modules', pkgName, 'bin', 'mcp.js'),
|
|
46
|
+
join(nodeRoot, 'lib', 'node_modules', pkgName, 'bin', entryFile), // Unix
|
|
36
47
|
];
|
|
48
|
+
// npm 自己报告的全局根(npm_config_prefix 由 npm 子进程设置;交互 shell 里通常为空)
|
|
49
|
+
if (process.env.npm_config_prefix) {
|
|
50
|
+
candidates.push(join(process.env.npm_config_prefix, 'lib', 'node_modules', pkgName, 'bin', entryFile));
|
|
51
|
+
candidates.push(join(process.env.npm_config_prefix, 'node_modules', pkgName, 'bin', entryFile));
|
|
52
|
+
}
|
|
53
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
54
|
+
candidates.push(join(process.env.APPDATA, 'npm', 'node_modules', pkgName, 'bin', entryFile));
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
for (const c of candidates) {
|
|
38
|
-
try {
|
|
58
|
+
try {
|
|
59
|
+
// 归一化为绝对路径,避免任何依赖 cwd 的相对候选被误用
|
|
60
|
+
const abs = resolve(c);
|
|
61
|
+
if (fsSync.existsSync(abs)) return abs;
|
|
62
|
+
} catch { /* 试下一个 */ }
|
|
39
63
|
}
|
|
40
64
|
// 回退:仓库/本地安装形态
|
|
41
|
-
return join(ABS_DIR, 'bin',
|
|
65
|
+
return join(ABS_DIR, 'bin', entryFile);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 写进宿主 MCP 配置的 mcp.js 路径。 */
|
|
69
|
+
function mcpEntryPath() {
|
|
70
|
+
return stableBinPath('mcp.js');
|
|
42
71
|
}
|
|
43
72
|
|
|
44
73
|
const MARK = '// abs-managed (agent-brain-sync)'; // TS plugin 标记
|
|
@@ -60,27 +89,159 @@ export function hostSkillDir(key) {
|
|
|
60
89
|
async function atomicWrite(p, text) {
|
|
61
90
|
await fs.mkdir(dirname(p), { recursive: true });
|
|
62
91
|
const tmp = `${p}.abs-tmp-${Date.now()}`;
|
|
63
|
-
|
|
64
|
-
|
|
92
|
+
try {
|
|
93
|
+
await fs.writeFile(tmp, text, 'utf8');
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// 写 tmp 就失败(如目标是目录): 清掉半成品再抛,否则沙盒里留 .abs-tmp-* 残骸
|
|
96
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
await fs.rename(tmp, p);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// rename 失败(如目标路径被目录占位): 同样清理,不留 tmp
|
|
103
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
65
106
|
}
|
|
66
107
|
|
|
67
108
|
async function backup(p) {
|
|
68
109
|
try {
|
|
69
|
-
|
|
110
|
+
// 文件名含日期+时分秒: 曾经只有日期,同日多次安装/卸载会互相覆盖,
|
|
111
|
+
// 只剩最后一份 —— 用户装坏了想回滚时,最早的可用副本已经不在了。
|
|
112
|
+
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
113
|
+
const bak = `${p}.abs-bak-${ts}`;
|
|
70
114
|
await fs.copyFile(p, bak);
|
|
115
|
+
await pruneBackups(p);
|
|
71
116
|
return bak;
|
|
72
117
|
} catch { return null; } // 文件不存在则无备份
|
|
73
118
|
}
|
|
74
119
|
|
|
75
|
-
|
|
76
|
-
|
|
120
|
+
/** 只保留最近 KEEP 份备份(按文件名时间戳倒序),避免备份目录无限增长。 */
|
|
121
|
+
const BACKUP_KEEP = 5;
|
|
122
|
+
async function pruneBackups(p) {
|
|
123
|
+
try {
|
|
124
|
+
const dir = dirname(p);
|
|
125
|
+
const base = `${basename(p)}.abs-bak-`;
|
|
126
|
+
const olds = (await fs.readdir(dir))
|
|
127
|
+
.filter((f) => f.startsWith(base))
|
|
128
|
+
.sort() // 时间戳格式 YYYY-MM-DD-HH-mm-ss 字典序 == 时间序
|
|
129
|
+
.reverse();
|
|
130
|
+
for (const f of olds.slice(BACKUP_KEEP)) {
|
|
131
|
+
await fs.rm(join(dir, f), { force: true });
|
|
132
|
+
}
|
|
133
|
+
} catch { /* 清理失败不影响主流程 */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function readJson(p, { strict = true } = {}) {
|
|
137
|
+
let text;
|
|
138
|
+
try {
|
|
139
|
+
text = await fs.readFile(p, 'utf8');
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e.code === 'ENOENT') return {}; // 文件不存在 = 空配置(正常首装)
|
|
142
|
+
throw e; // 权限/IO 错不能静默吞
|
|
143
|
+
}
|
|
144
|
+
if (!text.trim()) return {};
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(text);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
if (!strict) return null; // 宽松模式: 调用方据此跳过写文件,但仍继续其它清理
|
|
149
|
+
// 关键: 「文件不存在」与「存在但解析失败」必须分开处理。
|
|
150
|
+
// 曾经两者都返回 {},于是只要用户的配置里有 JSONC 注释/尾逗号/多一个字符,
|
|
151
|
+
// 就会被当作空对象重建 → 用户的 hooks/permissions/model/mcpServers 静默全消失。
|
|
152
|
+
// 而 JSONC(带 // 注释)正是 CLaude Code 官方文档鼓励的写法,命中率不低。
|
|
153
|
+
// 测例: 带注释的 settings.json 安装后 myKey/permissions 全丢,且无任何报错。
|
|
154
|
+
// 处置: 宁可整个安装失败,也不写坏用户文件(与 requireBrain 同原则)。
|
|
155
|
+
// 宽松模式(strict:false)供"卸载"使用: 跳过配置文件、但仍清理 abs 自己的脚本/skill,
|
|
156
|
+
// 避免因一个坏配置就留下残余。
|
|
157
|
+
throw new Error(
|
|
158
|
+
`配置文件无法解析为 JSON,已中止以免覆盖你的配置:\n` +
|
|
159
|
+
` ${p}\n` +
|
|
160
|
+
` ${e.message}\n` +
|
|
161
|
+
` 若文件含 // 注释或尾逗号(JSONC),请先转成标准 JSON;` +
|
|
162
|
+
`或备份后移走该文件重跑。`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
77
165
|
}
|
|
78
166
|
|
|
79
|
-
|
|
80
|
-
|
|
167
|
+
/**
|
|
168
|
+
* 判断一个 hook 数组元素是不是 abs 装的(用于卸载只删自己、安装去重)。
|
|
169
|
+
*
|
|
170
|
+
* 修复: 曾经用 command.includes('/.abs/hooks/') —— 子串匹配。任何「命令文本里恰好
|
|
171
|
+
* 出现该片段」的用户 hook 都会被误判为 abs 装的,于是卸载时删掉、安装时静默丢弃。
|
|
172
|
+
* 实测真例(都被误删):
|
|
173
|
+
* grep -r try /home/u/.abs/hooks/ > /tmp/report
|
|
174
|
+
* tar czf /tmp/b.tgz /home/u/.abs/hooks/ && echo done
|
|
175
|
+
* 现在改为精确匹配本工具实际写入的路径前缀: <homedir>/.abs/hooks/<agentKey>/abs-
|
|
176
|
+
* (stageHookScripts 生成的脚本名统一是 abs-<Event>.sh)。
|
|
177
|
+
*/
|
|
178
|
+
function absHookDir(agentKey) {
|
|
179
|
+
return join(homedir(), '.abs', 'hooks', agentKey);
|
|
180
|
+
}
|
|
181
|
+
/** 本工具会生成的 hook 脚本名(与 stageHookScripts 的 abs-<Event>.sh 一致)。 */
|
|
182
|
+
const ABS_SCRIPT_NAMES = new Set(
|
|
183
|
+
[...new Set(HOSTS.flatMap((h) => h.events))].map((ev) => `abs-${ev}.sh`),
|
|
184
|
+
);
|
|
185
|
+
function isAbsStagedCommand(cmd) {
|
|
186
|
+
if (typeof cmd !== 'string') return false;
|
|
187
|
+
// 逐个路径 token 比对: 必须是 <…>/.abs/hooks/<宿名>/<本工具生成的脚本名>。
|
|
188
|
+
// 不用"任意 abs-*"——那会把用户自己的 abs-mine.sh 也判成我们的(实测边界)。
|
|
189
|
+
const tokens = cmd.match(/\S+/g) || [];
|
|
190
|
+
return tokens.some((raw) => {
|
|
191
|
+
const t = raw.replace(/^['"]+|['"]+$/g, '');
|
|
192
|
+
const m = t.match(/(?:^|\/)\.abs\/hooks\/([^/]+)\/([^/]+)$/);
|
|
193
|
+
return !!m && ABS_SCRIPT_NAMES.has(m[2]);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
81
196
|
function entryHasAbs(entry) {
|
|
82
197
|
const hs = entry && entry.hooks ? (Array.isArray(entry.hooks) ? entry.hooks : [entry.hooks]) : [];
|
|
83
|
-
|
|
198
|
+
const cmds = [];
|
|
199
|
+
for (const h of hs) if (h && typeof h.command === 'string') cmds.push(h.command);
|
|
200
|
+
// 兼容扁平形态 { command } (无 hooks 字段)
|
|
201
|
+
if (entry && typeof entry.command === 'string') cmds.push(entry.command);
|
|
202
|
+
return cmds.some(isAbsStagedCommand);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ============================ TOML section 行级工具 ============================
|
|
206
|
+
// 为什么不用正则: 曾用 /\n?\[mcp_servers\.abs\][^\[]*/s 删 section,
|
|
207
|
+
// 而 `[^\[]*` 会在下一个 `[` 处停下 —— `args = ["/x/mcp.js"]` 的数组左括号就是 `[`。
|
|
208
|
+
// 结果卸载后把数组值原地截成活一个假 section 头,留下非法行 `["/…/mcp.js"]`,
|
|
209
|
+
// 用户 codex 启动时 TOML 解析直接失败(卸载却给用户留个坏配置)。
|
|
210
|
+
// 另一坑: 用 includes('[mcp_servers.abs]') 判"已存在"不区分注释 ——
|
|
211
|
+
// 用户配置里一句 `# 例: [mcp_servers.abs]` 就让安装器报"已存在且路径正确, 跳过",
|
|
212
|
+
// 实际从未注册(静默失效,且重装永不修复)。
|
|
213
|
+
// 故统一用行级扫描: section 头必须锚定行首、非注释;section 体到下一个 section 头为止。
|
|
214
|
+
const TOML_ABS_SECTION = '[mcp_servers.abs]';
|
|
215
|
+
|
|
216
|
+
/** 某行是否为 section 头(含数组表格 [[x]])。 */
|
|
217
|
+
function isTomlSectionHeader(line) {
|
|
218
|
+
return line.trimStart().startsWith('[');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 找出 [mcp_servers.abs] 真实 section 的 [start, end) 行下标;无则 null。
|
|
222
|
+
* 注释行(# 开头)不算 —— 这是修复 H1(注释导致假装成功)的关键。 */
|
|
223
|
+
function findTomlAbsSection(lines) {
|
|
224
|
+
const start = lines.findIndex((l) => {
|
|
225
|
+
const t = l.trimStart();
|
|
226
|
+
return !t.startsWith('#') && t.trim() === TOML_ABS_SECTION;
|
|
227
|
+
});
|
|
228
|
+
if (start === -1) return null;
|
|
229
|
+
let end = start + 1;
|
|
230
|
+
while (end < lines.length && !isTomlSectionHeader(lines[end])) end++;
|
|
231
|
+
return { start, end };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 删除 [mcp_servers.abs] section(含其 body);无则原样返回。
|
|
235
|
+
* @returns {{ text: string, removed: boolean }} */
|
|
236
|
+
function removeTomlAbsSection(text) {
|
|
237
|
+
const lines = String(text || '').split('\n');
|
|
238
|
+
const sec = findTomlAbsSection(lines);
|
|
239
|
+
if (!sec) return { text, removed: false };
|
|
240
|
+
// 一并去掉 section 前的多余空行,避免留下连续空行
|
|
241
|
+
let from = sec.start;
|
|
242
|
+
while (from > 0 && lines[from - 1].trim() === '') from--;
|
|
243
|
+
const out = [...lines.slice(0, from), ...lines.slice(sec.end)];
|
|
244
|
+
return { text: out.join('\n').replace(/\n{3,}/g, '\n\n'), removed: true };
|
|
84
245
|
}
|
|
85
246
|
|
|
86
247
|
// ============================ Hook 脚本落盘 ============================
|
|
@@ -93,7 +254,7 @@ async function stageHookScripts(agentKey, events) {
|
|
|
93
254
|
const name = `abs-${ev}.sh`;
|
|
94
255
|
const p = join(dir, name);
|
|
95
256
|
const script = tpl
|
|
96
|
-
.replaceAll('__ABS_BIN__',
|
|
257
|
+
.replaceAll('__ABS_BIN__', stableBinPath('abs.js'))
|
|
97
258
|
.replaceAll('__NODE_BIN__', process.execPath)
|
|
98
259
|
.replaceAll('__EVENT__', ev);
|
|
99
260
|
await atomicWrite(p, script);
|
|
@@ -147,9 +308,13 @@ async function installClaudeCode({ withMcp, withSkill, log }) {
|
|
|
147
308
|
async function uninstallClaudeCode() {
|
|
148
309
|
const steps = [];
|
|
149
310
|
const settingsP = claudeSettingsPath();
|
|
150
|
-
const settings = await readJson(settingsP);
|
|
311
|
+
const settings = await readJson(settingsP, { strict: false });
|
|
312
|
+
if (settings === null) {
|
|
313
|
+
// 配置解析失败: 跳过该文件(绝不覆盖),但仍继续清理 abs 自己的脚本/skill。
|
|
314
|
+
steps.push(`⚠ ${settingsP} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
315
|
+
}
|
|
151
316
|
let touched = false;
|
|
152
|
-
if (settings.hooks) {
|
|
317
|
+
if (settings && settings.hooks) {
|
|
153
318
|
const events = HOSTS[0].events;
|
|
154
319
|
for (const ev of events) {
|
|
155
320
|
if (!Array.isArray(settings.hooks[ev])) continue;
|
|
@@ -161,11 +326,11 @@ async function uninstallClaudeCode() {
|
|
|
161
326
|
}
|
|
162
327
|
}
|
|
163
328
|
}
|
|
164
|
-
if (settings.mcpServers && settings.mcpServers.abs) {
|
|
329
|
+
if (settings && settings.mcpServers && settings.mcpServers.abs) {
|
|
165
330
|
delete settings.mcpServers.abs; touched = true;
|
|
166
331
|
}
|
|
167
332
|
if (touched) await atomicWrite(settingsP, JSON.stringify(settings, null, 2));
|
|
168
|
-
steps.push(`✓ hooks/MCP 已从 ${settingsP} 移除`);
|
|
333
|
+
if (settings) steps.push(`✓ hooks/MCP 已从 ${settingsP} 移除`);
|
|
169
334
|
// staged hook 脚本目录 —— 只删本 agent 的,绝不整删 ~/.abs/(其它 agent 的 hook / mcp.log 共存)
|
|
170
335
|
await fs.rm(join(homedir(), '.abs', 'hooks', 'claude-code'), { recursive: true, force: true });
|
|
171
336
|
steps.push(`✓ ~/.abs/hooks/claude-code/ (本 agent hook 脚本) 已删除`);
|
|
@@ -208,7 +373,9 @@ async function installCodex({ withMcp, withSkill, log }) {
|
|
|
208
373
|
const mcpP = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'config.toml');
|
|
209
374
|
let text = '';
|
|
210
375
|
try { text = await fs.readFile(mcpP, 'utf8'); } catch {}
|
|
211
|
-
|
|
376
|
+
const found = findTomlAbsSection(text.split('\n'));
|
|
377
|
+
if (!found) {
|
|
378
|
+
// 必须用 findTomlAbsSection 而非 includes —— 注释里的同名文本不算已注册(H1)。
|
|
212
379
|
const block = `\n[mcp_servers.abs]\ncommand = "${process.execPath}"\nargs = ["${mcpEntryPath()}"]\n`;
|
|
213
380
|
await backup(mcpP);
|
|
214
381
|
await atomicWrite(mcpP, text.replace(/\s*$/, '') + '\n' + block);
|
|
@@ -216,13 +383,23 @@ async function installCodex({ withMcp, withSkill, log }) {
|
|
|
216
383
|
} else {
|
|
217
384
|
// 已存在也要校对路径: 旧版可能写入了仓库路径(不稳定) 或全局包已迁移。
|
|
218
385
|
const want = mcpEntryPath();
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
await atomicWrite(mcpP, text.replace(re, `$1"${want}"$2`));
|
|
223
|
-
steps.push(`✓ MCP → ${mcpP} 路径已校正 → ${want}`);
|
|
224
|
-
} else {
|
|
386
|
+
const lines = text.split('\n');
|
|
387
|
+
const body = lines.slice(found.start, found.end).join('\n');
|
|
388
|
+
if (body.includes(`"${want}"`)) {
|
|
225
389
|
steps.push(`• MCP → ${mcpP} 已存在且路径正确, 跳过`);
|
|
390
|
+
} else {
|
|
391
|
+
// 只改 args 行的字面量值,不碰其它行(正则跨行会误伤数组内容)
|
|
392
|
+
let touched = false;
|
|
393
|
+
for (let k = found.start; k < found.end; k++) {
|
|
394
|
+
if (/^\s*args\s*=/.test(lines[k])) { lines[k] = `args = ["${want}"]`; touched = true; break; }
|
|
395
|
+
}
|
|
396
|
+
if (touched) {
|
|
397
|
+
await backup(mcpP);
|
|
398
|
+
await atomicWrite(mcpP, lines.join('\n'));
|
|
399
|
+
steps.push(`✓ MCP → ${mcpP} 路径已校正 → ${want}`);
|
|
400
|
+
} else {
|
|
401
|
+
steps.push(`• MCP → ${mcpP} 已注册但无 args 行, 未动(请手动确认)`);
|
|
402
|
+
}
|
|
226
403
|
}
|
|
227
404
|
}
|
|
228
405
|
}
|
|
@@ -238,13 +415,16 @@ async function uninstallCodex() {
|
|
|
238
415
|
const steps = [];
|
|
239
416
|
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
240
417
|
const p = join(home, 'hooks.json');
|
|
241
|
-
const cfg = await readJson(p);
|
|
418
|
+
const cfg = await readJson(p, { strict: false });
|
|
419
|
+
if (cfg === null) {
|
|
420
|
+
steps.push(`⚠ ${p} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
421
|
+
}
|
|
242
422
|
let changed = false;
|
|
243
|
-
if (Array.isArray(cfg.hooks)) {
|
|
423
|
+
if (cfg && Array.isArray(cfg.hooks)) {
|
|
244
424
|
const before = cfg.hooks.length;
|
|
245
|
-
cfg.hooks = cfg.hooks.filter((h) => !String(h.command || '')
|
|
425
|
+
cfg.hooks = cfg.hooks.filter((h) => !isAbsStagedCommand(String(h.command || '')));
|
|
246
426
|
changed = cfg.hooks.length !== before;
|
|
247
|
-
} else if (cfg.hooks && typeof cfg.hooks === 'object') {
|
|
427
|
+
} else if (cfg && cfg.hooks && typeof cfg.hooks === 'object') {
|
|
248
428
|
for (const ev of Object.keys(cfg.hooks)) {
|
|
249
429
|
const arr = cfg.hooks[ev];
|
|
250
430
|
if (!Array.isArray(arr)) continue;
|
|
@@ -263,9 +443,11 @@ async function uninstallCodex() {
|
|
|
263
443
|
const mcpP = join(home, 'config.toml');
|
|
264
444
|
try {
|
|
265
445
|
const text = await fs.readFile(mcpP, 'utf8');
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
446
|
+
// 用行级扫描而非正则: 正则会在 args = [...] 的 [ 处截断,
|
|
447
|
+
// 留下非法行 ["/…/mcp.js"] 让用户的 TOML 解析失败。
|
|
448
|
+
const { text: after, removed } = removeTomlAbsSection(text);
|
|
449
|
+
if (removed) {
|
|
450
|
+
await atomicWrite(mcpP, after);
|
|
269
451
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
270
452
|
}
|
|
271
453
|
} catch {}
|
|
@@ -274,128 +456,53 @@ async function uninstallCodex() {
|
|
|
274
456
|
return steps;
|
|
275
457
|
}
|
|
276
458
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
|
|
289
|
-
* Opencode 事件: session.created / session.idle / session.deleted (idle = 每轮结束, 对应 pi 的 agent_end)。
|
|
290
|
-
* 收尾注入: session.idle 且本会话真改过文件 + .brain 今日无记录 → promptAsync 推 agent 走收尾循环。
|
|
459
|
+
/**
|
|
460
|
+
* opencode 路径歧义提示(返回一行警告文本,无歧义时返回 null)。
|
|
461
|
+
*
|
|
462
|
+
* 背景: opencode 的全局配置默认在 ~/.config/opencode/,但它同样尊重
|
|
463
|
+
* XDG_CONFIG_HOME(官方支持 XDG 规范)。若用户设了该变量,opencode 会去
|
|
464
|
+
* $XDG_CONFIG_HOME/opencode/ 读配置,而本工具默认仍写 ~/.config/opencode/ ——
|
|
465
|
+
* 于是插件装了却不生效(静默失效,最难查)。同理 OPENCODE_CONFIG 可覆盖配置文件路径。
|
|
466
|
+
*
|
|
467
|
+
* 为什么只警告不自动跟随: ①跟随会让"装到哪"依赖环境变量,变得不可预测;
|
|
468
|
+
* ②若 opencode 实际没读该变量,跟着写反而错。故只提示,把决定权留给用户 ——
|
|
469
|
+
* 用户可用 ABS_OPENCODE_HOME 显式指定目标。
|
|
291
470
|
*/
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
async function logHook(evt) {
|
|
303
|
-
try {
|
|
304
|
-
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
305
|
-
const d = new Date()
|
|
306
|
-
const pad = (n) => String(n).padStart(2, "0")
|
|
307
|
-
const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
|
|
308
|
-
await mkdir(dir, { recursive: true })
|
|
309
|
-
await appendFile(join(dir, "hooks.log"), "[" + stamp + "] opencode:" + evt + "\\n")
|
|
310
|
-
} catch {} // fire-and-forget: 永不阻塞宿主
|
|
471
|
+
function opencodePathWarning() {
|
|
472
|
+
// 本工具实际会写入的位置(ABS_OPENCODE_HOME 覆盖时即该值)
|
|
473
|
+
const ours = hostConfigRoot("opencode");
|
|
474
|
+
const hints = [];
|
|
475
|
+
|
|
476
|
+
// opencode 自身可能读取的位置。它支持 XDG 规范,故 XDG_CONFIG_HOME 优先于 ~/.config。
|
|
477
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
478
|
+
const opencodeWillRead = xdg ? join(xdg, "opencode") : null;
|
|
479
|
+
if (opencodeWillRead && resolve(opencodeWillRead) !== resolve(ours)) {
|
|
480
|
+
hints.push(`XDG_CONFIG_HOME=${xdg} → opencode 会读 ${opencodeWillRead},但本工具写的是 ${ours}`);
|
|
311
481
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
async function findBrain(cwd) {
|
|
315
|
-
const d = cwd || process.cwd()
|
|
316
|
-
try { const st = await stat(join(d, ".brain")); return st.isDirectory() ? join(d, ".brain") : null } catch { return null }
|
|
482
|
+
if (process.env.OPENCODE_CONFIG) {
|
|
483
|
+
hints.push(`OPENCODE_CONFIG=${process.env.OPENCODE_CONFIG} 会覆盖全局配置路径(优先级高于上面两者)`);
|
|
317
484
|
}
|
|
318
485
|
|
|
319
|
-
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const TEARDOWN_MSG =
|
|
333
|
-
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\\n" +
|
|
334
|
-
"1) 跑 abs load 看 Today 还有哪些未完成;\\n" +
|
|
335
|
-
"2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \\"断点\\";\\n" +
|
|
336
|
-
"3) 值得留的经验 abs note \\"...\\"(宁少勿滥,能从代码 grep 到的不记);\\n" +
|
|
337
|
-
"4) abs log \\"完成 X:...\\" 记一行工作成果,新页同步进 index。\\n" +
|
|
338
|
-
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\\"无可沉淀\\"即可。"
|
|
339
|
-
|
|
340
|
-
// bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
|
|
341
|
-
const READONLY_CMD = /^\\s*(ls|cat|grep|rg|find|head|tail|wc|git\\s+(status|log|diff|show|branch)|pwd|which|echo|node\\s+-v|npm\\s+(ls|view)|curl)\\b/
|
|
342
|
-
|
|
343
|
-
return {
|
|
344
|
-
// 观测真实写操作: write/edit/patch 类工具成功即标记。
|
|
345
|
-
// 坑: 曾漏掉 bash —— opencode 里很多修改是经 bash(heredoc/sed) 完成的, 纯 bash 会话
|
|
346
|
-
// 永远不置位 wroteFiles, 于是收尾提醒静默不发。与 pi 侧 WRITE_TOOLS 保持一致。
|
|
347
|
-
"tool.execute.after": async ({ tool, args }) => {
|
|
348
|
-
const t = String(tool || "").toLowerCase()
|
|
349
|
-
if (["write", "edit", "patch", "multiedit", "apply_patch"].includes(t)) {
|
|
350
|
-
wroteFiles = true
|
|
351
|
-
return
|
|
352
|
-
}
|
|
353
|
-
// bash 里只有非只读命令算改文件(ls/cat/git status 之类不算)
|
|
354
|
-
if (t === "bash") {
|
|
355
|
-
const cmd = String((args && (args.command || args.cmd)) || "")
|
|
356
|
-
if (cmd && !READONLY_CMD.test(cmd)) wroteFiles = true
|
|
357
|
-
}
|
|
358
|
-
},
|
|
359
|
-
|
|
360
|
-
event: async ({ event }) => {
|
|
361
|
-
const type = event && event.type
|
|
362
|
-
if (!type) return
|
|
363
|
-
if (type === "session.created" || type === "session.deleted") {
|
|
364
|
-
await logHook(type)
|
|
365
|
-
return
|
|
366
|
-
}
|
|
367
|
-
if (type !== "session.idle") return // idle = 每轮结束, 不记日志(太吵), 只做收尾判定
|
|
368
|
-
const cwd = directory || process.cwd()
|
|
369
|
-
const brain = await findBrain(cwd)
|
|
370
|
-
// 可观测性: 首次 idle 无条件留一行痕(否则无法区分“事件没触发”与“被守卫拦下”)
|
|
371
|
-
if (!idleSeen) {
|
|
372
|
-
idleSeen = true
|
|
373
|
-
await logHook(\`session.idle:seen cwd=\${cwd} brain=\${brain || 'none'}\`)
|
|
374
|
-
}
|
|
375
|
-
if (nudged || !wroteFiles) return
|
|
376
|
-
const sessionID = event.properties && event.properties.sessionID
|
|
377
|
-
if (!sessionID) return
|
|
378
|
-
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
379
|
-
if (await loggedToday(brain)) return // 今天已收尾过
|
|
380
|
-
nudged = true
|
|
381
|
-
await logHook("session.idle:teardown-nudge")
|
|
382
|
-
try {
|
|
383
|
-
await client.session.promptAsync({
|
|
384
|
-
path: { id: sessionID },
|
|
385
|
-
query: { directory: cwd },
|
|
386
|
-
body: { parts: [{ type: "text", text: TEARDOWN_MSG }] },
|
|
387
|
-
})
|
|
388
|
-
} catch {} // 注入失败不阻塞宿主
|
|
389
|
-
},
|
|
390
|
-
}
|
|
486
|
+
if (!hints.length) return null;
|
|
487
|
+
// 修复: 曾经 ABS_OPENCODE_HOME 一被设置就无条件 return null —— 于是即使用户
|
|
488
|
+
// 显式指定的目标与 opencode 实际会读的位置也不一致,警告仍完全静默。
|
|
489
|
+
// 现在改为「只比较路径是否一致」: 一致就静默(无歧义),不一致就提示,
|
|
490
|
+
// 无论目标来自默认值还是显式 env。
|
|
491
|
+
const via = process.env.ABS_OPENCODE_HOME ? "(目标由 ABS_OPENCODE_HOME 显式指定)" : "";
|
|
492
|
+
return [
|
|
493
|
+
"⚠ opencode 配置路径可能不一致(已装的插件可能不生效):" + via,
|
|
494
|
+
...hints.map((h) => ` - ${h}`),
|
|
495
|
+
" 如需指定目标: ABS_OPENCODE_HOME=<配置根> abs install --agent opencode",
|
|
496
|
+
].join("\n");
|
|
391
497
|
}
|
|
392
498
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
499
|
+
// ============================ Opencode / Pi (TS 插件) ============================
|
|
500
|
+
function opencodePluginSource() {
|
|
501
|
+
// 模板已外置到 hooks/abs.opencode.ts(真文件,非字符串)。
|
|
502
|
+
// 为什么外置: ①改一处逻辑不用在模板里再改一遍(曾漏改导致行为不一致)
|
|
503
|
+
// ②生成产物能被测试直接 import 断言, 不再解析字符串产物(间接测试的税)
|
|
504
|
+
// 转义坑已消除: 以前模板嵌在模板字符串里,\n / \" / \` 双层转义极易写错(代码注释亦踩过)。
|
|
505
|
+
return renderPluginTemplate('abs.opencode.ts', { '@@MARK@@': MARK });
|
|
399
506
|
}
|
|
400
507
|
|
|
401
508
|
// ============================ Pi (TS extension) ============================
|
|
@@ -403,137 +510,29 @@ ${MARK}
|
|
|
403
510
|
// 不能复用 opencodePluginSource 的写法(那是 export const ... = ({ project }) => ...)。
|
|
404
511
|
// 两者同纪律: 生命周期事件只进技术日志 hooks.log, 不进图谱 log.md。
|
|
405
512
|
function piPluginSource() {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
* session_shutdown 额外触发 abs wrapup: 把当前项目未完成任务快照到 wrapup.log (跨会话收尾保险)。
|
|
412
|
-
* agent_end 收尾注入: 本会话真改过文件 + .brain 今日无记录 → 注入一条收尾指令, 逼 agent 走收尾循环
|
|
413
|
-
* (被动记日志不够 —— 没人提醒就不会有人收尾)。每会话最多一次, 且已收尾后不再打扰。
|
|
414
|
-
*/
|
|
415
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
416
|
-
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
417
|
-
import { homedir } from "node:os"
|
|
418
|
-
import { join, dirname } from "node:path"
|
|
419
|
-
import { spawn } from "node:child_process"
|
|
420
|
-
|
|
421
|
-
const ABS_BIN = "${join(ABS_DIR, 'bin', 'abs.js')}"
|
|
422
|
-
|
|
423
|
-
async function logHook(evt: string): Promise<void> {
|
|
424
|
-
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
425
|
-
const d = new Date()
|
|
426
|
-
const pad = (n: number) => String(n).padStart(2, "0")
|
|
427
|
-
const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
|
|
428
|
-
const line = "[" + stamp + "] pi:" + evt + "\\n"
|
|
429
|
-
await mkdir(dir, { recursive: true })
|
|
430
|
-
await appendFile(join(dir, "hooks.log"), line)
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// 会话结束时快照当前项目滞留任务到 wrapup.log(detached fire-and-forget,wrapup 自身幂等去重不刷屏)。
|
|
434
|
-
// cwd 用事件 ctx.cwd(当前项目),让 abs 在该目录定位 .brain/。
|
|
435
|
-
function snapshotWrapup(cwd: string): void {
|
|
436
|
-
try {
|
|
437
|
-
const child = spawn(process.execPath, [ABS_BIN, "wrapup"], {
|
|
438
|
-
cwd: cwd || process.cwd(), stdio: "ignore", detached: true,
|
|
439
|
-
})
|
|
440
|
-
child.unref()
|
|
441
|
-
} catch {} // fire-and-forget
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// ---- 收尾注入判定 ----
|
|
445
|
-
// 真改过文件的工具(read/grep/ls 不算, 那些不产生可沉淀的产出)
|
|
446
|
-
const WRITE_TOOLS = new Set(["write", "edit", "multi_edit", "apply_patch", "bash"])
|
|
447
|
-
// bash 里只跑查询类命令不算改文件
|
|
448
|
-
const READONLY_CMD = /^\\s*(ls|cat|grep|rg|find|head|tail|wc|git\\s+(status|log|diff|show|branch)|pwd|which|echo|node\\s+-v|npm\\s+(ls|view)|curl)\\b/
|
|
449
|
-
|
|
450
|
-
function hasWriteWork(toolResults: any[]): boolean {
|
|
451
|
-
for (const r of toolResults || []) {
|
|
452
|
-
const name = String(r?.toolName || "")
|
|
453
|
-
if (!WRITE_TOOLS.has(name)) continue
|
|
454
|
-
if (r?.isError) continue
|
|
455
|
-
if (name === "bash") {
|
|
456
|
-
const cmd = String(r?.input?.command ?? r?.args?.command ?? "")
|
|
457
|
-
if (READONLY_CMD.test(cmd)) continue
|
|
458
|
-
}
|
|
459
|
-
return true
|
|
460
|
-
}
|
|
461
|
-
return false
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/** 定位当前项目的 .brain/:只看 cwd 本身,不向上搜索(防爬到家目录图谱)。 */
|
|
465
|
-
async function findBrain(cwd: string): Promise<string | null> {
|
|
466
|
-
const d = cwd || process.cwd()
|
|
467
|
-
try { const st = await stat(join(d, ".brain")); return st.isDirectory() ? join(d, ".brain") : null } catch { return null }
|
|
513
|
+
// 模板已外置到 hooks/abs.pi.ts。理由同 opencodePluginSource。
|
|
514
|
+
return renderPluginTemplate('abs.pi.ts', {
|
|
515
|
+
'@@ABS_BIN@@': stableBinPath('abs.js'),
|
|
516
|
+
'@@MARK@@': MARK,
|
|
517
|
+
});
|
|
468
518
|
}
|
|
469
519
|
|
|
470
520
|
/**
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
521
|
+
* 读 hooks/ 下的插件模板(真文件,非字符串)并替换 @@占位符@@。
|
|
522
|
+
* 为什么用占位符而非模板字符串: 模板本身是 TS 代码,含 \n / " / \` 等;
|
|
523
|
+
* 以前嵌在模板字符串里需双层转义,极易写错且只能靠“生成的字符串”间接测。
|
|
524
|
+
* 现在模板是真文件 → 可用真 TS 编辑器/测试直接 import 断言。
|
|
525
|
+
* 找不到模板文件时抛错(宁可失败也不静默生成空插件)。
|
|
474
526
|
*/
|
|
475
|
-
|
|
527
|
+
function renderPluginTemplate(name, vars) {
|
|
528
|
+
let t;
|
|
476
529
|
try {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
481
|
-
// 注意: 这里是真实 TS, 不是生成代码的模板字符串。用拼接形式避免反斜杠逃逸歧义。
|
|
482
|
-
return new RegExp("^## \\\\[" + today + " \\\\d{2}:\\\\d{2}\\\\]", "m").test(txt)
|
|
483
|
-
} catch { return false }
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
export default function absPiHook(pi: ExtensionAPI): void {
|
|
487
|
-
pi.on("session_start", () => logHook("session_start").catch(() => {}))
|
|
488
|
-
|
|
489
|
-
// 收尾注入: 每个会话最多一次, 避免反复打扰。
|
|
490
|
-
let teardownNudged = false
|
|
491
|
-
let agentEndSeen = false
|
|
492
|
-
pi.on("agent_end", async (event: any, ctx: any) => {
|
|
493
|
-
if (teardownNudged) return
|
|
494
|
-
const cwd = (ctx && ctx.cwd) || process.cwd()
|
|
495
|
-
const brain = await findBrain(cwd)
|
|
496
|
-
// 可观测性: 本会话首次 agent_end 无条件留一行痕。
|
|
497
|
-
// 没有它就无法区分三种状态: ①事件没触发 ②触发了但被守卫拦下 ③真注入了。
|
|
498
|
-
if (!agentEndSeen) {
|
|
499
|
-
agentEndSeen = true
|
|
500
|
-
// 带上 cwd 与命中的 brain —— 否则事后无法解释“为何这条 nudge 会出现”
|
|
501
|
-
await logHook(\`agent_end:seen cwd=\${cwd} brain=\${brain || 'none'}\`).catch(() => {})
|
|
502
|
-
}
|
|
503
|
-
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
504
|
-
if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
|
|
505
|
-
if (await loggedToday(brain)) return // 今天已收尾过
|
|
506
|
-
teardownNudged = true
|
|
507
|
-
await logHook("agent_end:teardown-nudge").catch(() => {})
|
|
508
|
-
try {
|
|
509
|
-
pi.sendUserMessage(
|
|
510
|
-
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\\n" +
|
|
511
|
-
"1) 跑 abs load 看 Today 还有哪些未完成;\\n" +
|
|
512
|
-
"2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \\"断点\\";\\n" +
|
|
513
|
-
"3) 值得留的经验 abs note \\"...\\"(宁少勿滥,能从代码 grep 到的不记);\\n" +
|
|
514
|
-
"4) abs log \\"完成 X:...\\" 记一行工作成果,新页同步进 index。\\n" +
|
|
515
|
-
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\\"无可沉淀\\"即可。",
|
|
516
|
-
{ deliverAs: "followUp" },
|
|
517
|
-
)
|
|
518
|
-
} catch {}
|
|
519
|
-
})
|
|
520
|
-
|
|
521
|
-
pi.on("session_shutdown", (_e: any, ctx: any) => {
|
|
522
|
-
snapshotWrapup((ctx && ctx.cwd) || process.cwd())
|
|
523
|
-
logHook("session_shutdown").catch(() => {})
|
|
524
|
-
})
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/** agent_end 的 event.messages 里翻出 toolResult 消息。 */
|
|
528
|
-
function collectToolResults(messages: any[]): any[] {
|
|
529
|
-
const out: any[] = []
|
|
530
|
-
for (const m of messages || []) {
|
|
531
|
-
if (m && m.role === "toolResult") out.push(m)
|
|
530
|
+
t = fsSync.readFileSync(join(ABS_DIR, 'hooks', name), 'utf8');
|
|
531
|
+
} catch (e) {
|
|
532
|
+
throw new Error(`插件模板缺失: hooks/${name}(安装包不完整?): ${e.message}`);
|
|
532
533
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
${MARK}
|
|
536
|
-
`;
|
|
534
|
+
for (const [k, v] of Object.entries(vars || {})) t = t.split(k).join(v);
|
|
535
|
+
return t;
|
|
537
536
|
}
|
|
538
537
|
|
|
539
538
|
async function installOpenCode({ withMcp, withSkill, log }) {
|
|
@@ -542,6 +541,8 @@ async function installOpenCode({ withMcp, withSkill, log }) {
|
|
|
542
541
|
const p = join(dir, 'abs.ts');
|
|
543
542
|
await atomicWrite(p, opencodePluginSource());
|
|
544
543
|
steps.push(`✓ hook(ts plugin) → ${p}`);
|
|
544
|
+
const warn = opencodePathWarning();
|
|
545
|
+
if (warn) steps.push(warn);
|
|
545
546
|
if (withMcp) {
|
|
546
547
|
const mcpP = join(hostConfigRoot('opencode'), 'opencode.json');
|
|
547
548
|
const cfg = await readJson(mcpP);
|
|
@@ -564,12 +565,18 @@ async function installOpenCode({ withMcp, withSkill, log }) {
|
|
|
564
565
|
|
|
565
566
|
async function uninstallOpenCode() {
|
|
566
567
|
const steps = [];
|
|
568
|
+
// 路径歧义提示(卸载侧同样有价值:可能提示用户去清另一个位置的旧副本)
|
|
569
|
+
const warn = opencodePathWarning();
|
|
570
|
+
if (warn) steps.push(warn);
|
|
567
571
|
const plugin = join(hostConfigRoot('opencode'), 'plugins', 'abs.ts');
|
|
568
572
|
await fs.rm(plugin, { force: true });
|
|
569
573
|
steps.push(`✓ plugin 已删除`);
|
|
570
574
|
const mcpP = join(hostConfigRoot('opencode'), 'opencode.json');
|
|
571
|
-
const cfg = await readJson(mcpP);
|
|
572
|
-
if (cfg
|
|
575
|
+
const cfg = await readJson(mcpP, { strict: false });
|
|
576
|
+
if (cfg === null) {
|
|
577
|
+
steps.push(`⚠ ${mcpP} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
578
|
+
}
|
|
579
|
+
if (cfg && cfg.mcp && cfg.mcp.abs) {
|
|
573
580
|
delete cfg.mcp.abs;
|
|
574
581
|
await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
|
|
575
582
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
@@ -612,8 +619,11 @@ async function uninstallPi() {
|
|
|
612
619
|
await fs.rm(hostSkillDir('pi'), { recursive: true, force: true });
|
|
613
620
|
steps.push(`✓ skill 已删除`);
|
|
614
621
|
const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp.json');
|
|
615
|
-
const cfg = await readJson(mcpP);
|
|
616
|
-
if (cfg
|
|
622
|
+
const cfg = await readJson(mcpP, { strict: false });
|
|
623
|
+
if (cfg === null) {
|
|
624
|
+
steps.push(`⚠ ${mcpP} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
625
|
+
}
|
|
626
|
+
if (cfg && cfg.mcpServers && cfg.mcpServers.abs) {
|
|
617
627
|
delete cfg.mcpServers.abs;
|
|
618
628
|
await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
|
|
619
629
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
@@ -634,15 +644,30 @@ export function installSummary() {
|
|
|
634
644
|
}
|
|
635
645
|
|
|
636
646
|
export async function runInstall({ agent, mcp = true, skill = true, yes = false } = {}) {
|
|
637
|
-
const targets = agent ? [agent] : await pickAgents();
|
|
647
|
+
const targets = agent ? [agent] : await pickAgents(yes);
|
|
648
|
+
const failed = [];
|
|
638
649
|
for (const key of targets) {
|
|
639
650
|
const inst = INSTALLERS[key];
|
|
640
651
|
if (!inst) throw new Error(`未知 agent: ${key} (可用: ${Object.keys(INSTALLERS).join(', ')})`);
|
|
641
652
|
console.log(`\n▸ 安装到 ${key} …`);
|
|
642
|
-
|
|
643
|
-
|
|
653
|
+
try {
|
|
654
|
+
for (const line of await inst.on({ withMcp: mcp, withSkill: skill })) {
|
|
655
|
+
console.log(' ' + line);
|
|
656
|
+
}
|
|
657
|
+
} catch (e) {
|
|
658
|
+
// 逐宿主容错: 一个宿主失败不应让其余宿主整体被跳过。
|
|
659
|
+
// 坑: 曾经任一处报错就直接上抛,于是前序宿主已装、后续宿主全未装 —— 半成品状态。
|
|
660
|
+
// 注意: 单宿主安装(agent 指定)时仍上抛,保持 CLI 非零退出语义。
|
|
661
|
+
failed.push({ key, msg: String(e && e.message ? e.message : e) });
|
|
662
|
+
console.error(` ✗ ${key} 安装失败: ${failed[failed.length - 1].msg}`);
|
|
663
|
+
if (agent) throw e;
|
|
644
664
|
}
|
|
645
665
|
}
|
|
666
|
+
if (failed.length) {
|
|
667
|
+
console.log(`\n⚠ ${failed.length} 个宿主安装失败: ${failed.map((f) => f.key).join(', ')}`);
|
|
668
|
+
console.log(' 其余宿主已完成。修复上述问题后重跑 `abs install`(幂等,不会重复写入)。');
|
|
669
|
+
throw new Error(`${failed.length} 个宿主安装失败(见上)`);
|
|
670
|
+
}
|
|
646
671
|
console.log('\n完成。项目内运行 abs init 建图谱; 会话里说 "abs load" 续接。');
|
|
647
672
|
}
|
|
648
673
|
|
|
@@ -658,8 +683,11 @@ export async function runUninstall({ agent, yes = false } = {}) {
|
|
|
658
683
|
}
|
|
659
684
|
|
|
660
685
|
// 交互式多选(无 TTY 时回退为全部)
|
|
661
|
-
async function pickAgents() {
|
|
662
|
-
|
|
686
|
+
async function pickAgents(yes = false) {
|
|
687
|
+
// --yes / 非 TTY: 直接全选,绝不弹交互。
|
|
688
|
+
// 坑: 曾经只看 isTTY,--yes 被收下却从不使用 —— 在 TTY 里跑
|
|
689
|
+
// abs install --yes(自动化/脚本)仍会弹提示并挂起等输入。
|
|
690
|
+
if (yes || !process.stdin.isTTY) return Object.keys(INSTALLERS);
|
|
663
691
|
const readline = await import('node:readline/promises');
|
|
664
692
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
665
693
|
console.log('选择要安装的智能体 (逗号分隔, 回车=全部):');
|
package/src/store.js
CHANGED
|
@@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs';
|
|
|
4
4
|
import { join, resolve, dirname } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { requireBrain, findBrainRoot, brainPath, BRAIN_DIR } from './index.js';
|
|
7
|
-
import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine, archiveDoneInText, renderArchivePage, renderArchiveBody } from './todo.js';
|
|
7
|
+
import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine, archiveDoneInText, renderArchivePage, renderArchiveBody, DONE_KINDS, withDoneKind, doneKindOf } from './todo.js';
|
|
8
8
|
import { editFile, SKIP } from './lock.js';
|
|
9
9
|
import { appendWrapup, strandedFor, wrapupLogPath } from './wrapup.js';
|
|
10
10
|
|
|
@@ -402,7 +402,7 @@ export async function cmdLog({ dir, title, kind = 'dev' }) {
|
|
|
402
402
|
// ---------- task: 登记/推进(幂等键 = 行首 id;hook 也调这个) ----------
|
|
403
403
|
// 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
|
|
404
404
|
// log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
|
|
405
|
-
export async function cmdTask({ dir, action, id, section, note }) {
|
|
405
|
+
export async function cmdTask({ dir, action, id, section, note, as }) {
|
|
406
406
|
const root = await requireBrain(dir || process.cwd());
|
|
407
407
|
if (action === 'start') {
|
|
408
408
|
const text = `${id}${note ? ' — ' + note : ''}`;
|
|
@@ -423,13 +423,17 @@ export async function cmdTask({ dir, action, id, section, note }) {
|
|
|
423
423
|
}
|
|
424
424
|
if (action === 'done') {
|
|
425
425
|
// 找到匹配 id 的行,勾选并归位 Done(简化:若行在某 section 则标记完成)
|
|
426
|
-
|
|
426
|
+
// --as 结语:落地(默认) / 否决 / 仅方案 —— 让 [x] 可被信任(见 todo.js DONE_KINDS)
|
|
427
|
+
if (as && !DONE_KINDS.includes(as)) {
|
|
428
|
+
throw new Error(`✗ --as 只接受: ${DONE_KINDS.join(' | ')}(收到 "${as}")`);
|
|
429
|
+
}
|
|
430
|
+
const res = await markDone(brainPath(root, 'todo.md'), id, as || '落地');
|
|
427
431
|
return res;
|
|
428
432
|
}
|
|
429
433
|
throw new Error(`unknown task action: ${action}`);
|
|
430
434
|
}
|
|
431
435
|
|
|
432
|
-
async function markDone(file, id) {
|
|
436
|
+
async function markDone(file, id, kind = '落地') {
|
|
433
437
|
const res = await editFile(file, (text) => {
|
|
434
438
|
const lines = text.split('\n');
|
|
435
439
|
let changed = false;
|
|
@@ -440,7 +444,10 @@ async function markDone(file, id) {
|
|
|
440
444
|
const l = lines[i];
|
|
441
445
|
if (!moved && idOfTaskLine(l) === wantId) {
|
|
442
446
|
changed = true;
|
|
443
|
-
const head =
|
|
447
|
+
const head = withDoneKind(
|
|
448
|
+
l.replace('- [ ]', '- [x]').replace(/\(认领[^)]*\)/, '') + ` (完成 ${today()})`,
|
|
449
|
+
kind,
|
|
450
|
+
);
|
|
444
451
|
const bp = [];
|
|
445
452
|
while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('↳')) bp.push(lines[++i]);
|
|
446
453
|
moved = [head, ...bp];
|
|
@@ -454,7 +461,7 @@ async function markDone(file, id) {
|
|
|
454
461
|
});
|
|
455
462
|
return res === SKIP
|
|
456
463
|
? `(未找到含 "${id}" 的未完成任务行)`
|
|
457
|
-
: `✓ 已完成并归位 Done: ${id}
|
|
464
|
+
: `✓ 已完成并归位 Done: ${id} 【${kind}】`;
|
|
458
465
|
}
|
|
459
466
|
|
|
460
467
|
// ---------- show: 查看 index/todo/log(只读面) ----------
|
|
@@ -669,6 +676,20 @@ export async function cmdLint({ dir }) {
|
|
|
669
676
|
if (doneLines > DONE_MAX) {
|
|
670
677
|
issues.push(`DONE-PILED-UP: Done 区 ${doneLines} 行 > ${DONE_MAX}; 跑 \`abs todo archive\` 迁出旧日期组`);
|
|
671
678
|
}
|
|
679
|
+
|
|
680
|
+
// 结语契约:Done 的 [x] 必须带【落地/否决/仅方案】。
|
|
681
|
+
// 为什么钉死: 无结语的 [x] 同时意味着"真做完了"和"只想过",读的人无法区分。
|
|
682
|
+
// 实测翻车: 把"跑通后又被撤销"的 daemon 条当成已落地 → 得出错误结论。
|
|
683
|
+
// 只查 Done 区(含历史归档前的旧条目也算),不做自动改写(改记录属内容决策,不该由 lint 代劳)。
|
|
684
|
+
const doneBody = todoTxt.split('\n').slice(di + 1);
|
|
685
|
+
const noKind = doneBody.filter((l) => /^\s*- \[x\]/.test(l) && !doneKindOf(l));
|
|
686
|
+
if (noKind.length) {
|
|
687
|
+
const sample = (noKind[0].match(/- \[x\] (\S+)/) || [, '?'])[1];
|
|
688
|
+
issues.push(
|
|
689
|
+
`DONE-NO-KIND: Done 区 ${noKind.length} 条缺结语(如 ${sample})。` +
|
|
690
|
+
`逐条补 \`--as 落地|否决|仅方案\`(新条目:abs todo done <id> --as …)`,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
672
693
|
}
|
|
673
694
|
|
|
674
695
|
const n = issues.length;
|
package/src/todo.js
CHANGED
|
@@ -93,6 +93,28 @@ export function doneDateOf(line) {
|
|
|
93
93
|
return m ? m[1] : '';
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ---------- Done 结语契约 ----------
|
|
97
|
+
// 为什么需要: `[x]` 原本同时表示「真落地」「评估后不做」「仅设计过」三种完全不同的状态,
|
|
98
|
+
// 读的人(下一个会话/未来的自己)无法区分。实测翻车: 读 daemon 条的 [x] 当成已落地,
|
|
99
|
+
// 实际它跑通后被撤销、代码全删 —— 基于假记录得出「daemon 是过配项」的错误结论。
|
|
100
|
+
// 结论: 图谱的价值取决于「可被信任」,一条状态失真的记录比一百条冗长记录的危害大一个量级。
|
|
101
|
+
export const DONE_KINDS = ['落地', '否决', '仅方案'];
|
|
102
|
+
|
|
103
|
+
/** 从 Done 任务行提取结语标记;无标记返回 ''。 */
|
|
104
|
+
export function doneKindOf(line) {
|
|
105
|
+
const m = String(line).match(/【(落地|否决|仅方案)】/);
|
|
106
|
+
return m ? m[1] : '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 追写结语到任务行:插在 `(完成 …)` 之前,幂等(已有则原位替换)。 */
|
|
110
|
+
export function withDoneKind(line, kind) {
|
|
111
|
+
if (!kind) return line;
|
|
112
|
+
const base = String(line).replace(/【(落地|否决|仅方案)】/g, '').replace(/\s{2,}/g, ' ').trimEnd();
|
|
113
|
+
const i = base.lastIndexOf(' (完成 ');
|
|
114
|
+
if (i === -1) return `${base} 【${kind}】`;
|
|
115
|
+
return `${base.slice(0, i)} 【${kind}】${base.slice(i)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
96
118
|
/** 判断一行是否为任务行(- [x] / - [ ],允许缩进)。 */
|
|
97
119
|
function isTaskLine(l) {
|
|
98
120
|
return /^\s*- \[[ x]\]/.test(l);
|