@fanchao8609/agent_brain_sync 1.5.4 → 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/README.md +2 -2
- package/bin/abs.js +64 -6
- package/bin/mcp.js +12 -12
- package/hooks/abs.opencode.ts +113 -0
- package/hooks/abs.pi.ts +130 -0
- package/package.json +1 -1
- package/skill/SKILL.md +10 -5
- package/src/index.js +15 -15
- package/src/install.js +317 -302
- package/src/store.js +27 -6
- package/src/todo.js +22 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**跨会话 AI 编码记忆。** 让 AI 会话不再「重开就失忆」:任务进度、经验、踩坑都落盘到项目里的 markdown 图谱,下一个会话开机能直接读回来。
|
|
4
4
|
|
|
5
5
|
- 图谱纯 markdown,放在项目根 `.brain/`,**可以进 git**,人和 AI 读同一份
|
|
6
|
-
-
|
|
6
|
+
- 多项目自动隔离:只在当前目录找 `.brain/`,不向上穿透
|
|
7
7
|
- 并发安全:CLI / MCP / hook 同时写不会互相覆盖
|
|
8
8
|
|
|
9
9
|
npm 包名 `@fanchao8609/agent_brain_sync`,全局命令 **`abs`**。
|
|
@@ -130,7 +130,7 @@ npm run pack:check # 预览 npm 发布产物
|
|
|
130
130
|
agent_brain_sync/
|
|
131
131
|
├── bin/abs.js CLI 入口
|
|
132
132
|
├── bin/mcp.js MCP server (stdio)
|
|
133
|
-
├── src/index.js
|
|
133
|
+
├── src/index.js 图谱定位(只认当前目录的 .brain/)
|
|
134
134
|
├── src/lock.js 并发写保护(原子锁 + 排队 + SKIP)
|
|
135
135
|
├── src/brainio.js 统一读写收口
|
|
136
136
|
├── src/todo.js todo.md 分区读写
|
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._ }));
|
package/bin/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// bin/mcp.js — abs MCP server (stdio)。
|
|
3
3
|
// 职责只有两个(PLAN.md v2 定稿):
|
|
4
|
-
// ① resolve_project:
|
|
4
|
+
// ① resolve_project: 用代码写死逻辑定位当前项目目录(只认 cwd 本身的 .brain/)
|
|
5
5
|
// ② 转接调用 CLI 读写(todo/board/query 的具体实现复用 src/)
|
|
6
6
|
// 无状态:每次调用从入参 cwd 重新解析,多项目各归各位。
|
|
7
7
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -70,12 +70,12 @@ function tool(name, description, schema, handler) {
|
|
|
70
70
|
// 工具面(narrow on purpose — 只暴露读/查/记状态,不做深提炼)
|
|
71
71
|
tool(
|
|
72
72
|
'abs_resolve_project',
|
|
73
|
-
'定位当前项目的 .brain
|
|
74
|
-
{ cwd: z.string().describe('
|
|
73
|
+
'定位当前项目的 .brain 图谱根(只认给定目录本身的 .brain/,不向上搜索)。多项目隔离的唯一入口。',
|
|
74
|
+
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
75
75
|
async ({ cwd }) => {
|
|
76
76
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
77
77
|
if (!root) {
|
|
78
|
-
return { content: [{ type: 'text', text: `未找到 .brain
|
|
78
|
+
return { content: [{ type: 'text', text: `未找到 .brain/(${cwd} 下没有,不向上搜索)。先在该目录运行: abs init` }] };
|
|
79
79
|
}
|
|
80
80
|
return { content: [{ type: 'text', text: root }] };
|
|
81
81
|
}
|
|
@@ -84,7 +84,7 @@ tool(
|
|
|
84
84
|
tool(
|
|
85
85
|
'abs_board',
|
|
86
86
|
'读取当前项目 todo 看板(含断点/Next-Step)。先 resolve 得到项目,再传其根目录。',
|
|
87
|
-
{ cwd: z.string().describe('
|
|
87
|
+
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
88
88
|
async ({ cwd }) => {
|
|
89
89
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
90
90
|
if (!root) return err('未找到 .brain/,先 abs init');
|
|
@@ -95,7 +95,7 @@ tool(
|
|
|
95
95
|
tool(
|
|
96
96
|
'abs_load',
|
|
97
97
|
'开机读状态:index 路线 + todo 看板 + 最近 log。跨会话续接的入口。',
|
|
98
|
-
{ cwd: z.string().describe('
|
|
98
|
+
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
99
99
|
async ({ cwd }) => {
|
|
100
100
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
101
101
|
if (!root) return err('未找到 .brain/,先 abs init');
|
|
@@ -109,7 +109,7 @@ tool(
|
|
|
109
109
|
{
|
|
110
110
|
action: z.enum(['add', 'start', 'done', 'note', 'blocked']),
|
|
111
111
|
id: z.string().describe('任务幂等键,如 TASK-xxx 或子任务名'),
|
|
112
|
-
cwd: z.string().describe('
|
|
112
|
+
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
113
113
|
note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步); blocked=卡点原因'),
|
|
114
114
|
},
|
|
115
115
|
async ({ action, id, cwd, note }) => {
|
|
@@ -124,7 +124,7 @@ tool(
|
|
|
124
124
|
tool(
|
|
125
125
|
'abs_status',
|
|
126
126
|
'当前项目 + 图谱概要(各类页数)。',
|
|
127
|
-
{ cwd: z.string().describe('
|
|
127
|
+
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
128
128
|
async ({ cwd }) => {
|
|
129
129
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
130
130
|
if (!root) return err('未找到 .brain/,先 abs init');
|
|
@@ -135,7 +135,7 @@ tool(
|
|
|
135
135
|
tool(
|
|
136
136
|
'abs_query',
|
|
137
137
|
'检索当前项目 .brain/ 知识页(多词 OR):以前踩过什么坑、哪页记了 X。',
|
|
138
|
-
{ cwd: z.string().describe('
|
|
138
|
+
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)'), terms: z.array(z.string()).min(1).describe('检索词') },
|
|
139
139
|
async ({ cwd, terms }) => {
|
|
140
140
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
141
141
|
if (!root) return err('未找到 .brain/,先 abs init');
|
|
@@ -146,7 +146,7 @@ tool(
|
|
|
146
146
|
tool(
|
|
147
147
|
'abs_lint',
|
|
148
148
|
'图谱体检:死链/孤岛/缺 frontmatter/超尺寸/sources 堆积/index 漏列。',
|
|
149
|
-
{ cwd: z.string().describe('
|
|
149
|
+
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
150
150
|
async ({ cwd }) => {
|
|
151
151
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
152
152
|
if (!root) return err('未找到 .brain/,先 abs init');
|
|
@@ -158,7 +158,7 @@ tool(
|
|
|
158
158
|
'abs_note',
|
|
159
159
|
'经验实时暂存:把刚踩的坑/技巧/结论一句话落进 sources/(防 context 断了流失)。Teardown 时再提炼进 concepts/。',
|
|
160
160
|
{
|
|
161
|
-
cwd: z.string().describe('
|
|
161
|
+
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
162
162
|
text: z.string().min(1).describe('经验/坑/技巧一句话'),
|
|
163
163
|
tags: z.string().optional().describe('逗号分隔标签,如 "docker,坑"'),
|
|
164
164
|
},
|
|
@@ -172,7 +172,7 @@ tool(
|
|
|
172
172
|
tool(
|
|
173
173
|
'abs_wrapup',
|
|
174
174
|
'收尾保险:把当前 todo 未完成任务快照到 ~/.abs/log/wrapup.log(下会话 load 时展示滞留对账)。',
|
|
175
|
-
{ cwd: z.string().describe('
|
|
175
|
+
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
176
176
|
async ({ cwd }) => {
|
|
177
177
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
178
178
|
if (!root) return err('未找到 .brain/,先 abs init');
|
|
@@ -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/skill/SKILL.md
CHANGED
|
@@ -16,7 +16,9 @@ Obsidian 可直接打开的 Markdown 图谱(`.brain/`)做统一落点。
|
|
|
16
16
|
技能被触发(用户问话、开新会话、或说 `abs ...`)时,**第一步永远是下面这条链**,
|
|
17
17
|
然后再看用户真正要什么:
|
|
18
18
|
|
|
19
|
-
1.
|
|
19
|
+
1. **找图谱**:只看**当前目录**是否含 `.brain/`(一个项目一个 `.brain/`)。
|
|
20
|
+
**不向上搜索**:向上爬会从子目录甚至 vault 一路命中家目录 `~/.brain` 的无关图谱,
|
|
21
|
+
静默把项目挂错地方。所以必须 cd 到项目根再跑,子目录不会自动归属。
|
|
20
22
|
2. **没有?按意图决定建档与否**:
|
|
21
23
|
- 用户意图是**沉淀**(收尾/「把这次记下来」)或**即将开工的长期任务**(会跨会话,
|
|
22
24
|
用词如「帮我做 X / 开工 / 继续开发 / 修 X」)→ **先建档**:`abs init`。别在没图谱时就开写。
|
|
@@ -59,11 +61,14 @@ Obsidian 可直接打开的 Markdown 图谱(`.brain/`)做统一落点。
|
|
|
59
61
|
## 图谱定位(一个项目一个 `.brain/`,abs 自动定位不用手工指定路径)
|
|
60
62
|
|
|
61
63
|
`.brain/` 放项目根,一个项目只建一份。所有 `abs` 命令(`abs load/todo/note/task/query/lint...`)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
**只认当前目录的 `.brain/`**——必须在项目根目录(即 `.brain/` 所在处)运行,
|
|
65
|
+
不用传路径。
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
**不在子目录自动归属,也不向上搜索**。原因是上爬会命中家目录 `~/.brain`
|
|
68
|
+
(vault / 临时目录 / 任意路径都可能爬到),把无关项目静默挂到别人图谱上。
|
|
69
|
+
宁可报错也不猜。多项目各自独立,各自 `cd` 到自己的根再跑。
|
|
70
|
+
|
|
71
|
+
monorepo 若多个子包各自独立交付,可各建一份 `.brain/`。`abs status` 显示当前定位到哪个项目。
|
|
67
72
|
|
|
68
73
|
## 图谱长什么样(都在 `.brain/` 下)
|
|
69
74
|
|
package/src/index.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
// src/index.js — 图谱定位:多项目隔离的核心。
|
|
2
|
-
//
|
|
2
|
+
// 只看给定目录本身有没有 `.brain/`,不向上搜索。
|
|
3
3
|
// 这是唯一"项目定位"逻辑,被 CLI / MCP / hook 共用,代码确定、不靠猜。
|
|
4
4
|
import { promises as fs } from 'node:fs';
|
|
5
|
-
import { join,
|
|
5
|
+
import { join, resolve, basename } from 'node:path';
|
|
6
6
|
|
|
7
7
|
export const BRAIN_DIR = '.brain';
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* 定位图谱根:仅当 `dir` 本身含 `.brain/` 才命中,否则返回 null。
|
|
11
|
+
* 故意不向上搜索:向上会从任意目录爬到 ~ 命中家目录图谱(如 ~/.brain),
|
|
12
|
+
* 把无关项目静默挂到别的图谱上。宁可报错,不猜。
|
|
13
|
+
*/
|
|
10
14
|
export async function findBrainRoot(startDir) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const parent = dirname(dir);
|
|
18
|
-
if (parent === dir) return null; // reached filesystem root
|
|
19
|
-
dir = parent;
|
|
20
|
-
}
|
|
15
|
+
const dir = resolve(startDir || process.cwd());
|
|
16
|
+
try {
|
|
17
|
+
const st = await fs.stat(join(dir, BRAIN_DIR));
|
|
18
|
+
if (st.isDirectory()) return dir;
|
|
19
|
+
} catch { /* 本目录没有图谱 */ }
|
|
20
|
+
return null;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** 解析图谱内文件的绝对路径。brainRoot 须已定位。 */
|
|
@@ -30,8 +30,8 @@ export async function requireBrain(startDir) {
|
|
|
30
30
|
const root = await findBrainRoot(startDir);
|
|
31
31
|
if (!root) {
|
|
32
32
|
throw new Error(
|
|
33
|
-
`abs:
|
|
34
|
-
`
|
|
33
|
+
`abs: 目录 ${resolve(startDir)} 下没有 .brain/ 图谱(不向上搜索)。\n` +
|
|
34
|
+
` 请在该目录运行: abs init`
|
|
35
35
|
);
|
|
36
36
|
}
|
|
37
37
|
return root;
|