@fanchao8609/agent_brain_sync 1.7.8 → 1.8.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/README.md +5 -3
- package/bin/abs.js +24 -34
- package/bin/mcp.js +102 -22
- package/hooks/abs.opencode.ts +60 -8
- package/hooks/abs.pi.ts +74 -13
- package/package.json +1 -1
- package/skill/SKILL.md +87 -20
- package/src/store.js +290 -129
- package/src/todo.js +81 -308
- package/src/wrapup.js +4 -0
package/README.md
CHANGED
|
@@ -60,16 +60,18 @@ abs init # 建 .brain/ 图谱,只需一次
|
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
62
|
abs load # 开机读状态(Rules + 图谱计数 + 看板 + 最近流水)
|
|
63
|
-
abs todo # 看板
|
|
63
|
+
abs todo # 看板 Todo / Done(行首带状态标记)
|
|
64
64
|
|
|
65
65
|
abs todo add TASK-1 --note "要做什么"
|
|
66
66
|
abs todo note TASK-1 --note "改到 X 文件 L40" # 实时断点
|
|
67
|
-
abs todo
|
|
67
|
+
abs todo state TASK-1 --note "进行中|讨论中|滞留中" # 改行首状态标记
|
|
68
68
|
abs todo done TASK-1
|
|
69
69
|
|
|
70
70
|
abs note "一句话经验" --tags 坑,docker # 经验暂存 → sources/
|
|
71
71
|
abs log "完成 X" # 记一行流水;abs log 无参 = 查看
|
|
72
|
-
abs query <词> # 检索图谱(多词 OR
|
|
72
|
+
abs query <词> # 检索图谱(多词 OR);已被推翻的经验默认隐藏(--all 可看)
|
|
73
|
+
abs resolve <页名或id> # 反查页面路径(页改名后 id 不变)
|
|
74
|
+
abs supersede <页名> [--by <新页>] # 标记经验已失效(不删文件,保留历史;query 默认不再返回)
|
|
73
75
|
abs status # 当前项目 + 图谱概要
|
|
74
76
|
abs lint # 体检:死链/孤岛/超尺寸/堆积
|
|
75
77
|
abs rule # 列出 index.md 的 ## Rules 硬规则
|
package/bin/abs.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// bin/abs.js — abs CLI 入口。
|
|
3
3
|
// abs <cmd> [args]
|
|
4
4
|
// 命令: init / board / status / load / task / install / uninstall / help
|
|
5
|
-
import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive,
|
|
5
|
+
import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede } from '../src/store.js';
|
|
6
6
|
import { setUser, getUser, userConfigPath } from '../src/userconfig.js';
|
|
7
7
|
import { runInstall, runUninstall } from '../src/install.js';
|
|
8
8
|
import { readFileSync } from 'node:fs';
|
|
@@ -74,6 +74,8 @@ const FLAG_SPEC = {
|
|
|
74
74
|
'payload': { type: 'string' },
|
|
75
75
|
'tags': { type: 'string' },
|
|
76
76
|
'state': { type: 'string' },
|
|
77
|
+
'by': { type: 'string' },
|
|
78
|
+
'all': { type: 'boolean' },
|
|
77
79
|
'keep-days': { type: 'string' },
|
|
78
80
|
'help': { type: 'boolean' },
|
|
79
81
|
'dry-run': { type: 'boolean' },
|
|
@@ -144,6 +146,8 @@ function parseArgv(args) {
|
|
|
144
146
|
note: values.note,
|
|
145
147
|
as: values.as,
|
|
146
148
|
state: values.state,
|
|
149
|
+
by: values.by,
|
|
150
|
+
all: !!values.all,
|
|
147
151
|
payload: values.payload,
|
|
148
152
|
tags: values.tags,
|
|
149
153
|
help: !!values.help,
|
|
@@ -167,8 +171,7 @@ const usage = `abs — agent-brain-sync 记忆工具
|
|
|
167
171
|
|
|
168
172
|
读:
|
|
169
173
|
abs load 开机读状态 (index/todo/log)
|
|
170
|
-
abs todo 任务看板
|
|
171
|
-
abs topic 话题树(讨论轨道,含已证伪);无参=查看
|
|
174
|
+
abs todo 任务看板 Todo / Done(未完成的都在这,行首带状态)
|
|
172
175
|
abs index 图谱索引 index.md
|
|
173
176
|
abs log 流水 log.md
|
|
174
177
|
abs status 当前项目 + 图谱概要
|
|
@@ -176,11 +179,7 @@ const usage = `abs — agent-brain-sync 记忆工具
|
|
|
176
179
|
写:
|
|
177
180
|
abs todo add <id> [--note ..] [--section ..] 登记任务 (start 同义)
|
|
178
181
|
abs todo note <id> --note "断点/进度" 实时落 ↳ 断点 行
|
|
179
|
-
abs todo
|
|
180
|
-
abs topic new "#1 标题" [--state 状态] [--note 结论] 登记/更新话题
|
|
181
|
-
状态: 进行中|已结论|已否决|待验证|已落地|未落地
|
|
182
|
-
子话题: abs topic new "#1.1 标题"(父子由 id 点分层级自动推)
|
|
183
|
-
abs topic promote <id> 讨论成熟、要动手 → 移进 Today(同 id 追踪,不复制)
|
|
182
|
+
abs todo state <id> --note "进行中|讨论中|滞留中" 改状态标记(原地,不搬区)
|
|
184
183
|
abs todo done <id> [--as 落地|否决|仅方案] 完成;结语标明到底"做成了没有"
|
|
185
184
|
默认 落地。否决=评估后不做(含做了又撤);仅方案=只设计过
|
|
186
185
|
不加结语或结语失真会让下一个会话把"想过"当成"做完了"。
|
|
@@ -188,7 +187,12 @@ const usage = `abs — agent-brain-sync 记忆工具
|
|
|
188
187
|
abs note "经验一句话" [--tags 坑,docker] 经验实时暂存 → sources/
|
|
189
188
|
|
|
190
189
|
维护:
|
|
191
|
-
abs query <词1> [词2 …]
|
|
190
|
+
abs query <词1> [词2 …] [--all]
|
|
191
|
+
检索 .brain/ 知识页 (多词 OR);superseded 默认隐藏
|
|
192
|
+
abs resolve <id-or-slug> [更多…]
|
|
193
|
+
按 id/页面名反查路径 (页改名后 id 不变,仍能找回)
|
|
194
|
+
abs supersede <页名> [--by <取代它的页>]
|
|
195
|
+
标记一条经验已失效 (不删文件;query/load 默认不再展示)
|
|
192
196
|
abs todo archive [--keep-days N] [--dry-run]
|
|
193
197
|
归档 Done 区旧日期组 → sessions/<日期>-todo归档.md
|
|
194
198
|
(默认保留近 3 天; 任一天有未完成则整天不归档)
|
|
@@ -274,7 +278,7 @@ const TODO_ACTIONS = {
|
|
|
274
278
|
add: 'start',
|
|
275
279
|
start: 'start', // add 的别名(老习惯保留)
|
|
276
280
|
note: 'note',
|
|
277
|
-
|
|
281
|
+
state: 'state', // 改行首状态标记:进行中|讨论中|滞留中(原地,不搬区)
|
|
278
282
|
done: 'done',
|
|
279
283
|
};
|
|
280
284
|
|
|
@@ -344,28 +348,6 @@ async function main() {
|
|
|
344
348
|
rejectExtra(opts._, 'abs status');
|
|
345
349
|
console.log(await cmdStatus({ dir: opts.dir }));
|
|
346
350
|
break;
|
|
347
|
-
// abs topic —— 分区(话题树):无子命令=树视图;带子命令=话题写操作
|
|
348
|
-
case 'topic': {
|
|
349
|
-
const [sub, id, ...rest2] = opts._;
|
|
350
|
-
if (!sub) {
|
|
351
|
-
rejectExtra([id, ...rest2].filter(Boolean), 'abs topic');
|
|
352
|
-
console.log(await cmdTopic({ dir: opts.dir }));
|
|
353
|
-
break;
|
|
354
|
-
}
|
|
355
|
-
const r = await cmdTopic({
|
|
356
|
-
dir: opts.dir,
|
|
357
|
-
action: sub,
|
|
358
|
-
id,
|
|
359
|
-
// 标题 = 第 3 个位置参数起的全部(含空格),未给则用 id 本身
|
|
360
|
-
title: rest2.join(' ') || undefined,
|
|
361
|
-
state: opts.state,
|
|
362
|
-
conclusion: opts.note,
|
|
363
|
-
full: opts.full,
|
|
364
|
-
clear: opts.clear,
|
|
365
|
-
});
|
|
366
|
-
console.log(r);
|
|
367
|
-
break;
|
|
368
|
-
}
|
|
369
351
|
// abs todo —— 无子命令=看板;带子命令=任务写操作
|
|
370
352
|
case 'todo': {
|
|
371
353
|
const [sub, id, ...rest2] = opts._;
|
|
@@ -385,7 +367,7 @@ async function main() {
|
|
|
385
367
|
if (!action) {
|
|
386
368
|
throw new Error(
|
|
387
369
|
`✗ 未知子命令 "${sub}"\n` +
|
|
388
|
-
` 可用: add / start / note /
|
|
370
|
+
` 可用: add / start / note / state / done / archive\n` +
|
|
389
371
|
` 看板: abs todo(不带参数)\n` +
|
|
390
372
|
` 登记任务: abs todo add <id> --note "做什么"`,
|
|
391
373
|
);
|
|
@@ -427,7 +409,15 @@ async function main() {
|
|
|
427
409
|
break;
|
|
428
410
|
}
|
|
429
411
|
case 'query': {
|
|
430
|
-
console.log(await cmdQuery({ dir: opts.dir, terms: opts._ }));
|
|
412
|
+
console.log(await cmdQuery({ dir: opts.dir, terms: opts._, includeSuperseded: opts.all }));
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case 'supersede': {
|
|
416
|
+
console.log(await cmdSupersede({ dir: opts.dir, refs: opts._, by: opts.by }));
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case 'resolve': {
|
|
420
|
+
console.log(await cmdResolve({ dir: opts.dir, refs: opts._ }));
|
|
431
421
|
break;
|
|
432
422
|
}
|
|
433
423
|
case 'lint': {
|
package/bin/mcp.js
CHANGED
|
@@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs';
|
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { findBrainRoot, absLogDir } from '../src/index.js';
|
|
15
|
-
import { cmdBoard, cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdWrapup, cmdRule } from '../src/store.js';
|
|
15
|
+
import { cmdBoard, cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdWrapup, cmdRule, cmdResolve, cmdSupersede } from '../src/store.js';
|
|
16
16
|
|
|
17
17
|
// ---------- 技术日志: MCP 请求跟踪(调试用, 与图谱 log.md 完全分开) ----------
|
|
18
18
|
// 落 ~/.abs/log/mcp.log: 每次工具调用一行 [时间] tool cwd 参数摘要 → 耗时/结果摘要。
|
|
@@ -66,7 +66,12 @@ function tool(name, description, schema, handler) {
|
|
|
66
66
|
server.tool(name, description, schema, withTrace(name, handler));
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// 工具面(narrow on purpose —
|
|
69
|
+
// 工具面(narrow on purpose — 只暴露读/查/记状态,不做深提炼)。
|
|
70
|
+
// 分级标注(借 OpenContext 的 P0/P1/P2 惯例):P0=每次开机必调;P1=干活中按需;P2=诊断/收尾。
|
|
71
|
+
// 后来人(和 AI)照这个顺序调,别一上来就 lint。
|
|
72
|
+
// P0 abs_resolve_project / abs_load
|
|
73
|
+
// P1 abs_board / abs_task / abs_query / abs_note / abs_resolve
|
|
74
|
+
// P2 abs_status / abs_rule / abs_lint / abs_wrapup
|
|
70
75
|
tool(
|
|
71
76
|
'abs_resolve_project',
|
|
72
77
|
'定位当前项目的 .brain 图谱根(只认给定目录本身的 .brain/,不向上搜索)。多项目隔离的唯一入口。',
|
|
@@ -74,7 +79,10 @@ tool(
|
|
|
74
79
|
async ({ cwd }) => {
|
|
75
80
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
76
81
|
if (!root) {
|
|
77
|
-
return
|
|
82
|
+
return err(`目录 ${cwd} 下没有 .brain/ 图谱(不向上搜索)。`, {
|
|
83
|
+
code: 'NO_BRAIN',
|
|
84
|
+
fallback: '先在该目录运行: abs init',
|
|
85
|
+
});
|
|
78
86
|
}
|
|
79
87
|
return { content: [{ type: 'text', text: root }] };
|
|
80
88
|
}
|
|
@@ -86,7 +94,7 @@ tool(
|
|
|
86
94
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
87
95
|
async ({ cwd }) => {
|
|
88
96
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
89
|
-
if (!root) return
|
|
97
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
90
98
|
return { content: [{ type: 'text', text: await cmdBoard({ dir: root }) }] };
|
|
91
99
|
}
|
|
92
100
|
);
|
|
@@ -97,36 +105,75 @@ tool(
|
|
|
97
105
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
98
106
|
async ({ cwd }) => {
|
|
99
107
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
100
|
-
if (!root) return
|
|
108
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
101
109
|
return { content: [{ type: 'text', text: await cmdLoad({ dir: root }) }] };
|
|
102
110
|
}
|
|
103
111
|
);
|
|
104
112
|
|
|
105
113
|
tool(
|
|
106
114
|
'abs_task',
|
|
107
|
-
'任务实时落盘(幂等键 = id)。
|
|
115
|
+
'任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补断点(改到哪文件哪行) / state 改状态 / done 完成归位 Done。',
|
|
108
116
|
{
|
|
109
|
-
action: z.enum(['add', 'start', 'done', 'note', '
|
|
117
|
+
action: z.enum(['add', 'start', 'done', 'note', 'state']),
|
|
110
118
|
id: z.string().describe('任务幂等键,如 TASK-xxx 或子任务名'),
|
|
111
119
|
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
112
|
-
note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步);
|
|
120
|
+
note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步); state=进行中|讨论中|滞留中'),
|
|
113
121
|
},
|
|
114
122
|
async ({ action, id, cwd, note }) => {
|
|
115
123
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
116
|
-
if (!root) return
|
|
124
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
117
125
|
// add 是 start 的别名(与 CLI `abs todo add` 对齐)
|
|
118
126
|
const act = action === 'add' ? 'start' : action;
|
|
119
127
|
return { content: [{ type: 'text', text: await cmdTask({ dir: root, action: act, id, note }) }] };
|
|
120
128
|
}
|
|
121
129
|
);
|
|
122
130
|
|
|
131
|
+
tool(
|
|
132
|
+
'abs_resolve',
|
|
133
|
+
'按页面 id(或页面名 slug)反查文件路径。页改名后 id 不变,引用请用返回的路径/id。',
|
|
134
|
+
{
|
|
135
|
+
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
136
|
+
refs: z.array(z.string()).min(1).describe('页面 id 或页面名,如 "file-write-locking"'),
|
|
137
|
+
},
|
|
138
|
+
async ({ cwd, refs }) => {
|
|
139
|
+
const root = await findBrainRoot(cwd || process.cwd());
|
|
140
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
141
|
+
const text = await cmdResolve({ dir: root, refs });
|
|
142
|
+
// 全部未命中才算错 —— 部分命中时整体标 isError 会让 Agent 以为调用失败,
|
|
143
|
+
// 把已经拿到的正确路径丢掉(实测:1 中 1 不中 → 整个结果被当错)。
|
|
144
|
+
if (/✗/.test(text) && !/✓/.test(text)) {
|
|
145
|
+
return err(text, { code: 'NOT_FOUND', fallback: 'abs query <词> 全文搜,或直接读 index.md' });
|
|
146
|
+
}
|
|
147
|
+
return { content: [{ type: 'text', text }] };
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
tool(
|
|
152
|
+
'abs_supersede',
|
|
153
|
+
'标记一条经验/知识页已失效(被推翻),不删文件、保留历史。之后 abs_query 默认不再返回它。核实后发现仍有效可手动把 status 改回 active。',
|
|
154
|
+
{
|
|
155
|
+
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
156
|
+
refs: z.array(z.string()).min(1).describe('页名或 id,如 "old-approach"'),
|
|
157
|
+
by: z.string().optional().describe('取代它的页名(可选;给了则必须已存在)'),
|
|
158
|
+
},
|
|
159
|
+
async ({ cwd, refs, by }) => {
|
|
160
|
+
const root = await findBrainRoot(cwd || process.cwd());
|
|
161
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
162
|
+
const text = await cmdSupersede({ dir: root, refs, by });
|
|
163
|
+
if (/✗/.test(text) && !/✓/.test(text)) {
|
|
164
|
+
return err(text, { code: 'NOT_FOUND', fallback: '用 abs_query <词> 找到正确的页名后重试' });
|
|
165
|
+
}
|
|
166
|
+
return { content: [{ type: 'text', text }] };
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
|
|
123
170
|
tool(
|
|
124
171
|
'abs_status',
|
|
125
172
|
'当前项目 + 图谱概要(各类页数)。',
|
|
126
173
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
127
174
|
async ({ cwd }) => {
|
|
128
175
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
129
|
-
if (!root) return
|
|
176
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
130
177
|
return { content: [{ type: 'text', text: await cmdStatus({ dir: root }) }] };
|
|
131
178
|
}
|
|
132
179
|
);
|
|
@@ -134,17 +181,26 @@ tool(
|
|
|
134
181
|
tool(
|
|
135
182
|
'abs_query',
|
|
136
183
|
'检索当前项目 .brain/ 知识页(多词 OR):以前踩过什么坑、哪页记了 X。',
|
|
137
|
-
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)'), terms: z.array(z.string()).min(1).describe('检索词') },
|
|
138
|
-
async ({ cwd, terms }) => {
|
|
184
|
+
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)'), terms: z.array(z.string()).min(1).describe('检索词'), include_superseded: z.boolean().optional().describe('true=连已标记失效的经验一起返回(默认隐藏)') },
|
|
185
|
+
async ({ cwd, terms, include_superseded }) => {
|
|
139
186
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
140
|
-
if (!root) return
|
|
141
|
-
|
|
187
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
188
|
+
const text = await cmdQuery({ dir: root, terms, includeSuperseded: include_superseded });
|
|
189
|
+
// 无命中时给逃生口:query 是全文匹配,猜的词不对就换词/看全貌,别以为图谱是空的。
|
|
190
|
+
// 收口在这里(不散在 store.js)—— CLI 侧是一行提示,MCP 侧需要可自救的结构。
|
|
191
|
+
if (/无命中/.test(text)) {
|
|
192
|
+
return err(text, {
|
|
193
|
+
code: 'NO_MATCH',
|
|
194
|
+
fallback: '换更具体的词重试(支持多个词 OR);或读 .brain/index.md 看完整清单',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return { content: [{ type: 'text', text }] };
|
|
142
198
|
}
|
|
143
199
|
);
|
|
144
200
|
|
|
145
201
|
tool(
|
|
146
202
|
'abs_rule',
|
|
147
|
-
'读/写 index.md 的 ## Rules 硬规则区。action=list 列出;action=add
|
|
203
|
+
'读/写 index.md 的 ## Rules 硬规则区。action=list 列出;action=add 追加一条(只放一句话,≤42 字符、不带链接;展开写概念页)。',
|
|
148
204
|
{
|
|
149
205
|
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
150
206
|
action: z.enum(['list', 'add']).optional().describe('list=列出(默认);add=追加一条'),
|
|
@@ -152,7 +208,7 @@ tool(
|
|
|
152
208
|
},
|
|
153
209
|
async ({ cwd, action, text }) => {
|
|
154
210
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
155
|
-
if (!root) return
|
|
211
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
156
212
|
return { content: [{ type: 'text', text: await cmdRule({ dir: root, action: action || 'list', text }) }] };
|
|
157
213
|
}
|
|
158
214
|
);
|
|
@@ -163,8 +219,14 @@ tool(
|
|
|
163
219
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
164
220
|
async ({ cwd }) => {
|
|
165
221
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
166
|
-
if (!root) return
|
|
167
|
-
|
|
222
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
223
|
+
const text = await cmdLint({ dir: root });
|
|
224
|
+
// lint 报的是「哪不对」,得补一句「下一步怎么办」,否则 Agent 只能把问题复述给用户。
|
|
225
|
+
const fallback = /无问题|0 个问题|健康/.test(text)
|
|
226
|
+
? null
|
|
227
|
+
: '按条目里的文件名/H1 直接改;死链可删或改指向;index 漏列用 abs_rule 同区手工补一行';
|
|
228
|
+
if (!fallback) return { content: [{ type: 'text', text }] };
|
|
229
|
+
return err(text, { code: 'LINT_ISSUES', fallback });
|
|
168
230
|
}
|
|
169
231
|
);
|
|
170
232
|
|
|
@@ -178,7 +240,7 @@ tool(
|
|
|
178
240
|
},
|
|
179
241
|
async ({ cwd, text, tags }) => {
|
|
180
242
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
181
|
-
if (!root) return
|
|
243
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
182
244
|
return { content: [{ type: 'text', text: await cmdNote({ dir: root, text, tags }) }] };
|
|
183
245
|
}
|
|
184
246
|
);
|
|
@@ -189,13 +251,31 @@ tool(
|
|
|
189
251
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
190
252
|
async ({ cwd }) => {
|
|
191
253
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
192
|
-
if (!root) return
|
|
254
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
193
255
|
return { content: [{ type: 'text', text: await cmdWrapup({ dir: root }) }] };
|
|
194
256
|
}
|
|
195
257
|
);
|
|
196
258
|
|
|
197
|
-
|
|
198
|
-
|
|
259
|
+
// 错误带逃生口:Agent 收到错要能自救,而不是回来问人。
|
|
260
|
+
// 借 OpenContext 的做法——错误里直接给「退一步怎么做」,且带结构化 code 供程序判分支。
|
|
261
|
+
// 判据:凡 isError 的返回,调用方读完应知道下一步跑什么命令。
|
|
262
|
+
function err(msg, { code, fallback } = {}) {
|
|
263
|
+
const body = [
|
|
264
|
+
code ? `[${code}] ${msg}` : msg,
|
|
265
|
+
fallback ? `→ ${fallback}` : null,
|
|
266
|
+
].filter(Boolean).join('\n');
|
|
267
|
+
const res = { content: [{ type: 'text', text: body }], isError: true };
|
|
268
|
+
// 结构化字段:MCP 客户端可读 structuredContent 判分支(code=NO_BRAIN 等)
|
|
269
|
+
if (code) res.structuredContent = { error: code, message: msg, fallback: fallback || null };
|
|
270
|
+
return res;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 无图谱专用:所有工具共用同一条自救路径,收口在这里(改一处即全改)。 */
|
|
274
|
+
function errNoBrain(cwd) {
|
|
275
|
+
return err(`目录 ${cwd} 下没有 .brain/ 图谱(不向上搜索)。`, {
|
|
276
|
+
code: 'NO_BRAIN',
|
|
277
|
+
fallback: `先在该目录运行: abs init (或在含图谱的目录重调本工具)`,
|
|
278
|
+
});
|
|
199
279
|
}
|
|
200
280
|
|
|
201
281
|
await server.connect(new StdioServerTransport());
|
package/hooks/abs.opencode.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
* 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
|
|
5
5
|
* Opencode 事件: session.created / session.idle / session.deleted (idle = 每轮结束, 对应 pi 的 agent_end)。
|
|
6
6
|
* 收尾注入: session.idle 且本会话真改过文件 + .brain 今日无记录 → promptAsync 推 agent 走收尾循环。
|
|
7
|
+
*
|
|
8
|
+
* 登记注入 (2026-09-15 用户定): 写文件 = 任务开始的机械信号。
|
|
9
|
+
* 旧设计只在 idle(轮末) 提醒收尾 —— 那时用户已想结束, AI 只想收尾不想登记。
|
|
10
|
+
* 故 tool.execute.after 检测到**项目文件**写入即注入登记提示(不等 idle):
|
|
11
|
+
* · 频繁没关系 —— 每次写都提醒(用户明确要求)
|
|
12
|
+
* · 判据 = 整个项目, 但**排除 .brain/ 自身**(写图谱是记录行为, 不是任务)
|
|
13
|
+
* · 文案明说「纯讨论可跳过」—— 不逼 AI 造任务
|
|
7
14
|
*/
|
|
8
15
|
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
9
16
|
import { homedir } from "node:os"
|
|
@@ -14,6 +21,10 @@ const server = async ({ client, directory }) => {
|
|
|
14
21
|
let wroteFiles = false
|
|
15
22
|
let nudged = false
|
|
16
23
|
let idleSeen = false
|
|
24
|
+
// 登记提醒计数:只为日志/诊断,不做去重 —— 用户要「频繁一点」,每次写文件都提醒。
|
|
25
|
+
let registerNudged = 0
|
|
26
|
+
// 当前 session id(由事件里拿),tool.execute.after 没带 sessionID 时靠它补。
|
|
27
|
+
let currentSessionID = ""
|
|
17
28
|
|
|
18
29
|
async function logHook(evt) {
|
|
19
30
|
try {
|
|
@@ -45,36 +56,72 @@ const server = async ({ client, directory }) => {
|
|
|
45
56
|
} catch { return false }
|
|
46
57
|
}
|
|
47
58
|
|
|
59
|
+
// 登记提醒:注入失败不阻塞宿主。每次写文件都提醒(用户明确要「频繁一点」)。
|
|
60
|
+
async function registerNudge(files) {
|
|
61
|
+
registerNudged++
|
|
62
|
+
await logHook(`tool.execute.after:register-nudge #${registerNudged} files=${files.length}`)
|
|
63
|
+
if (!currentSessionID) return // 拿不到 session 就无法注入,静默跳过(不抛)
|
|
64
|
+
const cwd = directory || process.cwd()
|
|
65
|
+
if (!(await findBrain(cwd))) return // 无图谱 = 不在这项目沉淀,不打扰
|
|
66
|
+
try {
|
|
67
|
+
await client.session.promptAsync({
|
|
68
|
+
path: { id: currentSessionID },
|
|
69
|
+
query: { directory: cwd },
|
|
70
|
+
body: { parts: [{ type: "text", text: REGISTER_MSG(files) }] },
|
|
71
|
+
})
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
|
|
48
75
|
const TEARDOWN_MSG =
|
|
49
76
|
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\n" +
|
|
50
|
-
"1) 跑 abs load 看
|
|
77
|
+
"1) 跑 abs load 看 Todo 还有哪些未完成;\n" +
|
|
51
78
|
"2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \"断点\";\n" +
|
|
52
79
|
"3) 值得留的经验 abs note \"...\"(宁少勿滥,能从代码 grep 到的不记);\n" +
|
|
53
80
|
"4) abs log \"完成 X:...\" 记一行工作成果,新页同步进 index。\n" +
|
|
54
|
-
// 话题对账:Topics 区只放「正在讨论」的话题。
|
|
55
|
-
"5) 话题对账 abs topic:本会话在讨论什么→abs topic new \"#N 标题\" --state 进行中;" +
|
|
56
|
-
"已收尾的话题用 --state 已结论/已否决(会自动移出树),停下来的事落 Blocked。\n" +
|
|
57
|
-
" ⚠ Topics 区只放正在讨论的话题;五态(已结论/已否决/已落地/未落地/待验证)都会离开树," +
|
|
58
|
-
"离开前先把结论写进 sources/(abs note),否则就丢了。\n" +
|
|
59
81
|
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\"无可沉淀\"即可。"
|
|
60
82
|
|
|
61
83
|
// bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
|
|
62
84
|
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/
|
|
63
85
|
|
|
86
|
+
// 写文件 = 任务开始的机械信号。排除 .brain/ 自身(`abs note`/`abs log` 也写文件,
|
|
87
|
+
// 但那是记录行为不是任务 —— 不排除则每落一条经验都弹登记提醒,纯噪音)。
|
|
88
|
+
const BRAIN_PATH = /\.brain[\\/]/
|
|
89
|
+
function isProjectWrite(p, cmd) {
|
|
90
|
+
if (cmd !== undefined) return !!cmd && !READONLY_CMD.test(cmd)
|
|
91
|
+
if (!p) return false
|
|
92
|
+
return !BRAIN_PATH.test(String(p))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const REGISTER_MSG = (files) =>
|
|
96
|
+
"[abs 登记提醒] 本轮改了项目文件,说明有任务在进行。\n" +
|
|
97
|
+
"改了:" + files.slice(0, 6).join(", ") + (files.length > 6 ? ` 等 ${files.length} 个` : "") + "\n" +
|
|
98
|
+
"若这属于某个任务,立刻登记(别等到会话结束才回忆——那时必漏):\n" +
|
|
99
|
+
" · 新任务: abs todo add <id> --note \"做什么\"\n" +
|
|
100
|
+
" · 已有任务: abs todo note <id> --note \"改到哪/下一步\"\n" +
|
|
101
|
+
" · 纯讨论/调研/改图谱自身 → 无需登记,回「跳过」即可。\n" +
|
|
102
|
+
"简洁执行,不要复述本条提醒。"
|
|
103
|
+
|
|
64
104
|
return {
|
|
65
105
|
// 观测真实写操作: write/edit/patch 类工具成功即标记。
|
|
66
106
|
// 坑: 曾漏掉 bash —— opencode 里很多修改是经 bash(heredoc/sed) 完成的, 纯 bash 会话
|
|
67
107
|
// 永远不置位 wroteFiles, 于是收尾提醒静默不发。与 pi 侧 WRITE_TOOLS 保持一致。
|
|
68
108
|
"tool.execute.after": async ({ tool, args }) => {
|
|
69
109
|
const t = String(tool || "").toLowerCase()
|
|
110
|
+
const a = args || {}
|
|
111
|
+
const fp = a.file_path || a.filePath || a.path || a.filename
|
|
70
112
|
if (["write", "edit", "patch", "multiedit", "apply_patch"].includes(t)) {
|
|
71
113
|
wroteFiles = true
|
|
114
|
+
// 登记提醒:项目文件被写 = 任务已开始,立刻推(不等 idle)
|
|
115
|
+
if (isProjectWrite(fp)) await registerNudge([String(fp)])
|
|
72
116
|
return
|
|
73
117
|
}
|
|
74
118
|
// bash 里只有非只读命令算改文件(ls/cat/git status 之类不算)
|
|
75
119
|
if (t === "bash") {
|
|
76
|
-
const cmd = String(
|
|
77
|
-
if (
|
|
120
|
+
const cmd = String(a.command || a.cmd || "")
|
|
121
|
+
if (isProjectWrite(undefined, cmd)) {
|
|
122
|
+
wroteFiles = true
|
|
123
|
+
await registerNudge(["bash: " + cmd.slice(0, 60)])
|
|
124
|
+
}
|
|
78
125
|
}
|
|
79
126
|
},
|
|
80
127
|
|
|
@@ -83,9 +130,14 @@ const server = async ({ client, directory }) => {
|
|
|
83
130
|
if (!type) return
|
|
84
131
|
if (type === "session.created" || type === "session.deleted") {
|
|
85
132
|
await logHook(type)
|
|
133
|
+
// session.created / idle 等事件带 sessionID —— 存下来供 tool.execute.after 用
|
|
134
|
+
const sid = event.properties && event.properties.sessionID
|
|
135
|
+
if (sid) currentSessionID = String(sid)
|
|
86
136
|
return
|
|
87
137
|
}
|
|
88
138
|
if (type !== "session.idle") return // idle = 每轮结束, 不记日志(太吵), 只做收尾判定
|
|
139
|
+
const sid0 = event.properties && event.properties.sessionID
|
|
140
|
+
if (sid0) currentSessionID = String(sid0)
|
|
89
141
|
const cwd = directory || process.cwd()
|
|
90
142
|
const brain = await findBrain(cwd)
|
|
91
143
|
// 可观测性: 首次 idle 无条件留一行痕(否则无法区分“事件没触发”与“被守卫拦下”)
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -12,7 +12,14 @@
|
|
|
12
12
|
* - before_agent_start (用户发话 = 天然话题边界) → 记用户原话
|
|
13
13
|
* - turn_end (每轮结束) → 记改过哪些文件
|
|
14
14
|
* 累积在**内存**(下方 sessionNotes), 收尾时拼进注入提示 —— **不落盘**。
|
|
15
|
-
*
|
|
15
|
+
*
|
|
16
|
+
* 登记提醒 (2026-09-15 用户定): 写文件 = 任务开始的机械信号。
|
|
17
|
+
* 旧设计只在 agent_end 提醒收尾 —— 但那时用户已想结束, AI 只想收尾不想登记;
|
|
18
|
+
* 且「任务何时开始」只有写文件那一刻清楚, 事后回忆必漏。
|
|
19
|
+
* 故 turn_end 检测到**项目文件**写入即注入登记提示(不等 agent_end):
|
|
20
|
+
* · 频繁没关系 —— 每轮改文件都提醒(用户明确要求), 用 nudgeCount 防同一轮重复
|
|
21
|
+
* · 判据 = 整个项目, 但**排除 .brain/ 自身**(写图谱不是任务, 是记录行为)
|
|
22
|
+
* · 文案明说「纯讨论可跳过」—— 不逼 AI 造任务(否则会长出假条目污染看板)
|
|
16
23
|
*/
|
|
17
24
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
18
25
|
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
@@ -104,22 +111,61 @@ async function loggedToday(brain: string): Promise<boolean> {
|
|
|
104
111
|
// 只认**工作成果**条目(kind=dev),不能只看“今天有没有行”。
|
|
105
112
|
// 坑(2026-09-13 实测):`abs note` 也写 log.md(kind=note),
|
|
106
113
|
// 旧判据 (^## [今天 HH:MM]) 把“沉淀了一条经验”当成“今天已收尾”→
|
|
107
|
-
// 整天不再提醒 →
|
|
114
|
+
// 整天不再提醒 → 会话后半场全部漏登。
|
|
108
115
|
// 行格式: `## [YYYY-MM-DD HH:MM] [[name]] dev | 内容`。
|
|
109
116
|
return new RegExp("^## \\[" + today + " \\d{2}:\\d{2}\\] (?:\\[\\[[^\\]]+\\]\\] )?dev \\|", "m").test(txt)
|
|
110
117
|
} catch { return false }
|
|
111
118
|
}
|
|
112
119
|
|
|
120
|
+
// 写文件 = 任务开始的机械信号。
|
|
121
|
+
// 注: `abs note` / `abs log` 也会写文件 —— 但那是**记录行为**不是任务,
|
|
122
|
+
// 所以要排除 .brain/ 自身(否则每落一条经验都弹一次登记提醒,纯噪音)。
|
|
123
|
+
// ponytail: 用路径前缀判,不解析项目根(子目录/软链场景可能漏判;
|
|
124
|
+
// 漏判的代价只是少提醒一次,不丢数据)。
|
|
125
|
+
const BRAIN_PATH = /\.brain[\/]/;
|
|
126
|
+
|
|
127
|
+
function isProjectWrite(p: string | undefined, cmd?: string): boolean {
|
|
128
|
+
if (cmd !== undefined) return !READONLY_CMD.test(cmd); // bash: 非只读就算
|
|
129
|
+
if (!p) return false;
|
|
130
|
+
return !BRAIN_PATH.test(String(p));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 从一轮的 toolResults 里抽出「改过的项目文件」。排除 .brain/ 自身。 */
|
|
134
|
+
function projectWrites(toolResults: any[]): string[] {
|
|
135
|
+
const out = new Set<string>();
|
|
136
|
+
for (const r of toolResults || []) {
|
|
137
|
+
const name = String(r?.toolName || "");
|
|
138
|
+
if (!WRITE_TOOLS.has(name)) continue;
|
|
139
|
+
if (r?.isError) continue;
|
|
140
|
+
if (name === "bash") {
|
|
141
|
+
const cmd = String(r?.input?.command ?? r?.args?.command ?? "");
|
|
142
|
+
if (!isProjectWrite(undefined, cmd)) continue;
|
|
143
|
+
out.add("bash: " + cmd.slice(0, 60));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const f = r?.input?.file_path ?? r?.args?.file_path ?? r?.input?.path ?? r?.args?.path;
|
|
147
|
+
if (isProjectWrite(f ? String(f) : undefined)) out.add(String(f));
|
|
148
|
+
}
|
|
149
|
+
return [...out];
|
|
150
|
+
}
|
|
151
|
+
|
|
113
152
|
export default function absPiHook(pi: ExtensionAPI): void {
|
|
114
153
|
// 收尾注入的节流状态。声明在**外层** + 在 session_start 里重置:
|
|
115
154
|
// 否则同一进程的第二个会话会继承上一个会话的 true,永久不再提醒
|
|
116
155
|
// (2026-09-13 实测:同一天多个会话时后半场全部静默)。
|
|
117
156
|
let teardownNudged = false
|
|
118
157
|
let agentEndSeen = false
|
|
158
|
+
// 登记提醒节流:只需防**同一轮内**重复注入(一个 turn_end 只提醒一次)。
|
|
159
|
+
// 跨轮不去重 —— 用户明确要「频繁一点」:同一文件改第二回仍是新任务进展,
|
|
160
|
+
// 该提醒。曾经用「文件列表」当去重键 → 同一文件再改就永不提醒(实测 step②失败)。
|
|
161
|
+
let lastNudgedTurn = -1
|
|
162
|
+
let turnSeq = 0
|
|
119
163
|
|
|
120
164
|
pi.on("session_start", () => {
|
|
121
165
|
teardownNudged = false
|
|
122
166
|
agentEndSeen = false
|
|
167
|
+
lastNudgedTurn = -1
|
|
168
|
+
turnSeq = 0
|
|
123
169
|
return logHook("session_start").catch(() => {})
|
|
124
170
|
})
|
|
125
171
|
|
|
@@ -130,7 +176,8 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
130
176
|
})
|
|
131
177
|
|
|
132
178
|
// 每轮结束 = 这轮干了什么(改了哪些文件)。机械事实,供收尾时回忆。
|
|
133
|
-
|
|
179
|
+
// 同时:写文件 = 任务开始 → **立刻**提醒登记(不等 agent_end)。
|
|
180
|
+
pi.on("turn_end", async (event: any, ctx: any) => {
|
|
134
181
|
const files = new Set<string>()
|
|
135
182
|
for (const r of event?.toolResults || []) {
|
|
136
183
|
const name = String(r?.toolName || "")
|
|
@@ -144,6 +191,28 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
144
191
|
}
|
|
145
192
|
}
|
|
146
193
|
if (files.size) addNote("tool", [...files].join(", "))
|
|
194
|
+
|
|
195
|
+
// ---- 登记提醒:项目文件被写 = 任务已经开始 ----
|
|
196
|
+
const touched = projectWrites(event?.toolResults || [])
|
|
197
|
+
if (!touched.length) return
|
|
198
|
+
const me = ++turnSeq
|
|
199
|
+
if (me === lastNudgedTurn) return // 同一轮不重复(turn_end 理论上只来一次,防御)
|
|
200
|
+
lastNudgedTurn = me
|
|
201
|
+
const cwd = (ctx && ctx.cwd) || process.cwd()
|
|
202
|
+
if (!(await findBrain(cwd))) return // 无图谱 = 不在这项目沉淀,不打扰
|
|
203
|
+
await logHook(`turn_end:register-nudge files=${touched.length}`).catch(() => {})
|
|
204
|
+
try {
|
|
205
|
+
pi.sendUserMessage(
|
|
206
|
+
"[abs 登记提醒] 本轮改了项目文件,说明有任务在进行。\n" +
|
|
207
|
+
"改了:" + touched.slice(0, 6).join(", ") + (touched.length > 6 ? ` 等 ${touched.length} 个` : "") + "\n" +
|
|
208
|
+
"若这属于某个任务,立刻登记(别等到会话结束才回忆——那时必漏):\n" +
|
|
209
|
+
" · 新任务: abs todo add <id> --note \"做什么\"\n" +
|
|
210
|
+
" · 已有任务: abs todo note <id> --note \"改到哪/下一步\"\n" +
|
|
211
|
+
" · 纯讨论/调研/改图谱自身 → 无需登记,回「跳过」即可。\n" +
|
|
212
|
+
"简洁执行,不要复述本条提醒。",
|
|
213
|
+
{ deliverAs: "followUp" },
|
|
214
|
+
)
|
|
215
|
+
} catch {}
|
|
147
216
|
})
|
|
148
217
|
// 收尾注入: 每个会话最多一次, 避免反复打扰。
|
|
149
218
|
pi.on("agent_end", async (event: any, ctx: any) => {
|
|
@@ -160,8 +229,7 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
160
229
|
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
161
230
|
if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
|
|
162
231
|
// 今日已收尾 → 只在**本会话还没真正干事**时才静默。
|
|
163
|
-
// 旧行为:今天 log 有一行就整天闭口 ——
|
|
164
|
-
// (2026-09-13 实报:web 版聊了一整轮,Topics 里一条没有)。
|
|
232
|
+
// 旧行为:今天 log 有一行就整天闭口 —— 于是收尾之后的产出全部没人提醒。
|
|
165
233
|
// 注意不能用 sessionNotes.length 当判据:用户每说一句话就会 push 一条,
|
|
166
234
|
// 那会让 nudge 每轮都触发(噪音)。判据保持「今天已收尾」但配合下面的笔记消费。
|
|
167
235
|
if (await loggedToday(brain)) return
|
|
@@ -172,17 +240,10 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
172
240
|
try {
|
|
173
241
|
pi.sendUserMessage(
|
|
174
242
|
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\n" +
|
|
175
|
-
"1) 跑 abs load 看
|
|
243
|
+
"1) 跑 abs load 看 Todo 还有哪些未完成;\n" +
|
|
176
244
|
"2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \"断点\";\n" +
|
|
177
245
|
"3) 值得留的经验 abs note \"...\"(宁少勿滥,能从代码 grep 到的不记);\n" +
|
|
178
246
|
"4) abs log \"完成 X:...\" 记一行工作成果,新页同步进 index。\n" +
|
|
179
|
-
// 话题对账:只放「正在讨论」的话题。终止态(含已否决/已结论/未落地/待验证)
|
|
180
|
-
// 一律不入树 —— 结论落 abs note,暂停的事落 Blocked 当任务追踪。
|
|
181
|
-
"5) 话题对账 abs topic:据下方素材归纳本会话讨论过什么→" +
|
|
182
|
-
"abs topic new \"#N 标题\" --state 进行中;" +
|
|
183
|
-
"已收尾的用 --state 已结论/已否决(会自动移出树),停下来的落 Blocked。\n" +
|
|
184
|
-
" 正在动手的话题: `abs topic promote #N` 移进 Today(同 id 连续追踪)。\n" +
|
|
185
|
-
" ⚠ Topics 区只放正在讨论的话题;五态都会离开树,离开前先把结论写进 sources/(abs note)。\n" +
|
|
186
247
|
notesBlock(notes) +
|
|
187
248
|
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\"无可沉淀\"即可。",
|
|
188
249
|
{ deliverAs: "followUp" },
|