@fanchao8609/agent_brain_sync 1.31.1 → 1.31.3
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 +1 -1
- package/bin/abs.js +4 -3
- package/hooks/abs.pi.ts +11 -4
- package/hooks/event.sh +5 -1
- package/package.json +1 -1
- package/src/brain/gate.js +4 -1
- package/src/brain/ingest.js +3 -2
- package/src/brain/load.js +1 -1
- package/src/install.js +9 -1
- package/src/lock.js +46 -8
- package/src/todo/task-cmd.js +14 -7
package/README.md
CHANGED
|
@@ -172,7 +172,7 @@ npm i -g @fanchao8609/agent_brain_sync@latest
|
|
|
172
172
|
abs install # 重新刷 hook/skill
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
-
> ⚠️ 改完源码(尤其 `skill/*/SKILL.md`、`src
|
|
175
|
+
> ⚠️ 改完源码(尤其 `skill/*/SKILL.md`、`src/`)后**必须重扇出**,否则宿主还在跑旧副本 —— 版本号与代码会脱节。TTY 下跑 `abs install` 选宿主;脚本/CI 里 `abs install --agent pi --yes`(`--yes` 只装默认的 pi,不是全部)。发布用 `npm version patch`(自动打 tag),别手改 `package.json` 的 version;发布后 `npm view` 有缓存延迟,必要时 `npm cache clean --force` 再验。
|
|
176
176
|
|
|
177
177
|
---
|
|
178
178
|
|
package/bin/abs.js
CHANGED
|
@@ -410,8 +410,8 @@ const subUsage = {
|
|
|
410
410
|
'',
|
|
411
411
|
'参数:',
|
|
412
412
|
' --agent <宿主> 只装一个宿主: claude-code | codex | opencode | pi | omp',
|
|
413
|
-
' (不传则在 TTY 下交互多选; 非 TTY / --yes
|
|
414
|
-
' --yes
|
|
413
|
+
' (不传则在 TTY 下交互多选; 非 TTY / --yes 时只装默认的 pi)',
|
|
414
|
+
' --yes 不交互(只装默认的 pi;要装全部用 --all)',
|
|
415
415
|
' --no-mcp 不注册 MCP server',
|
|
416
416
|
' --no-skill 不装 skill',
|
|
417
417
|
'',
|
|
@@ -422,7 +422,8 @@ const subUsage = {
|
|
|
422
422
|
'',
|
|
423
423
|
'例:',
|
|
424
424
|
' abs install 交互选择',
|
|
425
|
-
' abs install --
|
|
425
|
+
' abs install --all 全部宿主',
|
|
426
|
+
' abs install --yes 不问,只装 pi',
|
|
426
427
|
' abs install --agent pi --yes 只装 pi',
|
|
427
428
|
].join('\n'),
|
|
428
429
|
uninstall: [
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -265,7 +265,14 @@ const TODO_GUIDELINES = [
|
|
|
265
265
|
// ⑧ 有疑惑先查记忆(用户 2026-11-03 定):主动搜索不是等报错才应激,
|
|
266
266
|
// 而是「心里形成关键词/有疑问的那一刻」自己查。上面七条只讲了登记,
|
|
267
267
|
// 没讲「记下来的东西怎么取」——实测一场会话从头到尾没查过一次记忆。
|
|
268
|
-
|
|
268
|
+
// ★ 2026-11-09 修:原来写的是 `abs recall`,那个命令 11-08 就删了 ——
|
|
269
|
+
// 规矩给成不存在的命令,模型只能白撞一次。
|
|
270
|
+
'When a doubt forms about something the project has likely dealt with before (a design choice, a past bug, a convention), run `abs query <关键词>` BEFORE acting on the assumption. Do not wait for an error or for the user to remind you — asking the memory is your own decision, made the moment the question arises.',
|
|
271
|
+
// ⑨ 参数规矩要说在**前面**(用户 2026-11-09 提:闸门拦得住,但得先告诉模型规矩)。
|
|
272
|
+
// 起因:另一个 pi 会话(pi-capsule-tools)连撞两次 —— ① 不传 cwd → 被 schema 拒;
|
|
273
|
+
// ② 把整句话当 id → 被「id 不能含空格」拒。两次都是「没提前说」而不是「不会做」。
|
|
274
|
+
// 放在常驻指引里而不是只靠工具 description:description 模型常不读。
|
|
275
|
+
'abs task tool parameters — wrong ones are rejected before anything is written, so get them right the first time: `cwd` = the project root that contains `.brain/` and is ALWAYS required; `id` = ONE word, no spaces / tabs / brackets (put the readable name in `note`); `start`/`add` require `note` (what to do, one sentence); `child_done` / `child_undone` / `state` take `n` = which child item (1-based, default = last); `done` takes the conclusion in `note` plus `as` (落地 / 否决 / 仅方案). A child row must keep `note` to ONE short sentence (≤200 字) — longer text goes to `abs log` (progress) or `abs note` (lessons), not the board.',
|
|
269
276
|
]
|
|
270
277
|
|
|
271
278
|
/** 当前项目是否有 .brain 图谱 —— 有才加指引(没图谱的项目里这指引是噪音)。
|
|
@@ -526,7 +533,7 @@ export function injectSessionGuideline(options: any, sid: string): boolean {
|
|
|
526
533
|
return true
|
|
527
534
|
}
|
|
528
535
|
|
|
529
|
-
/** 注入 todo 指引。静态
|
|
536
|
+
/** 注入 todo 指引。静态 9 条(行为要求 + 参数规矩) + 实时看板(对齐用)—— 两者职责不同:
|
|
530
537
|
* 静态条说「该怎么做」,看板说「现在是什么」。 */
|
|
531
538
|
export function injectTodoGuidelines(options: any, board?: string | null): boolean {
|
|
532
539
|
if (String(process.env.ABS_TODO_GUIDE || '') === '0') return false
|
|
@@ -556,8 +563,8 @@ const LOAD_MARK = '[开工] '
|
|
|
556
563
|
* 每次开会话都多塞 200 行,而其中大部分(图谱组织/容量纪律/tesardown 流程)
|
|
557
564
|
* 只在真正做 abs 维护时才用得上。
|
|
558
565
|
*
|
|
559
|
-
* 与【常驻
|
|
560
|
-
* -
|
|
566
|
+
* 与【常驻 9 条】TODO_GUIDELINES 的区别(两者职责不同,别合并):
|
|
567
|
+
* - 9 条常驻:行为要求与参数规矩,每轮都在(看板对齐用)。
|
|
561
568
|
* - 这 3 条:**只在开工第一轮出现一次** —— 因为它们是「开工那一刻」的触发器,
|
|
562
569
|
* 每次都说等于噪音(历史教训:静态指引有痕 414 次、行为 0 变化)。
|
|
563
570
|
*
|
package/hooks/event.sh
CHANGED
|
@@ -13,7 +13,11 @@ ABS_BIN="__ABS_BIN__"
|
|
|
13
13
|
NODE_BIN="__NODE_BIN__"
|
|
14
14
|
EVENT="__EVENT__"
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# 全文收下(Stop 的 transcript_path / session_id 可能在 500 字节之外,截断会让
|
|
17
|
+
# teardown-check 解析不到字段)。日志行仍另截 120;指纹用这份全文(上限 64KB),
|
|
18
|
+
# 旧版只指纹前 500 字节:两个不同事件开头相同时会被当重复丢掉。
|
|
19
|
+
# 64KB 对 hook 元数据足够,又不至于把 MB 级 prompt 整个摚进 shell 变量。
|
|
20
|
+
PAYLOAD=$(cat 2>/dev/null | head -c 65536)
|
|
17
21
|
|
|
18
22
|
# ── Stop 收尾注入 (CC/Codex 的"主动推") ─────────────────────────────────
|
|
19
23
|
# 守卫同 pi 插件四条件: ①本会话真改过文件 ②本项目有 .brain ③log.md 今日无条目 ④每会话一次。
|
package/package.json
CHANGED
package/src/brain/gate.js
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
import { setFormatGate } from '../lock.js';
|
|
5
5
|
import { enforceBrainFormat, ensureStateMark } from '../todo.js';
|
|
6
6
|
import { BRAIN_SHAPE, legacyRenames } from './shape.js';
|
|
7
|
+
import { basename } from 'node:path';
|
|
7
8
|
|
|
8
9
|
/** 写入侧格式闸门:lock.js 的 editFile 是所有写入的唯一收口,
|
|
9
10
|
* 这里把「哪个文件名用哪套标准」告诉它 —— 三个目标文件之外的写入一律放行。
|
|
10
11
|
* 非目标文件返回 null 表示不管。 */
|
|
11
12
|
setFormatGate((file, text, current) => {
|
|
12
|
-
|
|
13
|
+
// basename 而不是 split('/'):Windows 路径是反斜杠,split 取不到文件名,
|
|
14
|
+
// 闸门会静默放行(等于没装)。install.js 有 win32 候选,Windows 是支持目标。
|
|
15
|
+
const name = basename(file);
|
|
13
16
|
const spec = BRAIN_SHAPE[name];
|
|
14
17
|
if (!spec) return null;
|
|
15
18
|
const r = enforceBrainFormat(text, { ...spec, renames: legacyRenames() }, current);
|
package/src/brain/ingest.js
CHANGED
|
@@ -332,9 +332,10 @@ export async function cmdIngest({ dir, file, force = false, mark = false, last =
|
|
|
332
332
|
hit = best;
|
|
333
333
|
} else {
|
|
334
334
|
const want = basename(String(file).trim());
|
|
335
|
+
// 子串匹配必须够长(≥8):短词会把「日志」「test」这类撞上任意文件名,
|
|
336
|
+
// 静默选错会话。精确同名仍然放行。
|
|
335
337
|
hit = names.find((n) => n === want)
|
|
336
|
-
||
|
|
337
|
-
|| names.find((n) => n.includes(want));
|
|
338
|
+
|| (want.length >= 8 && names.find((n) => n.includes(want)));
|
|
338
339
|
if (!hit) {
|
|
339
340
|
return `✗ 会话目录里找不到 "${want}"。\n 可用: abs summon(列出待总结的会话)`;
|
|
340
341
|
}
|
package/src/brain/load.js
CHANGED
|
@@ -17,7 +17,7 @@ const SESSION_UNFILLED_SHOW = 5;
|
|
|
17
17
|
/** 开机屏的使用方法(2026-11-08 用户定 load 三件套之一)。一行一个动作,别写成手册。 */
|
|
18
18
|
const LOAD_USAGE = [
|
|
19
19
|
'--- 用法 ---',
|
|
20
|
-
'
|
|
20
|
+
'查经验 abs query <词> 记一句 abs note "…"',
|
|
21
21
|
'立概念 abs concept <id> --title "…" 登记活 abs todo start <id> --note "做什么"',
|
|
22
22
|
'收尾 abs todo done <id> --as 落地|否决|仅方案 "结论"',
|
|
23
23
|
'体检 abs lint 看全量 abs show index|log|status',
|
package/src/install.js
CHANGED
|
@@ -634,7 +634,10 @@ async function uninstallClaudeCode() {
|
|
|
634
634
|
if (settings && settings.mcpServers && settings.mcpServers.abs) {
|
|
635
635
|
delete settings.mcpServers.abs; touched = true;
|
|
636
636
|
}
|
|
637
|
-
if (touched)
|
|
637
|
+
if (touched) {
|
|
638
|
+
await backup(settingsP);
|
|
639
|
+
await atomicWrite(settingsP, JSON.stringify(settings, null, 2));
|
|
640
|
+
}
|
|
638
641
|
if (settings) steps.push(`✓ hooks/MCP 已从 ${settingsP} 移除`);
|
|
639
642
|
// staged hook 脚本目录 —— 只删本 agent 的,绝不整删 ~/.abs/(其它 agent 的 hook / mcp.log 共存)
|
|
640
643
|
await fs.rm(join(homedir(), '.abs', 'hooks', 'claude-code'), { recursive: true, force: true });
|
|
@@ -751,6 +754,7 @@ async function uninstallCodex() {
|
|
|
751
754
|
}
|
|
752
755
|
}
|
|
753
756
|
if (changed) {
|
|
757
|
+
await backup(p);
|
|
754
758
|
await atomicWrite(p, JSON.stringify(cfg, null, 2));
|
|
755
759
|
steps.push(`✓ hooks 已从 ${p} 移除`);
|
|
756
760
|
}
|
|
@@ -761,6 +765,7 @@ async function uninstallCodex() {
|
|
|
761
765
|
// 留下非法行 ["/…/mcp.js"] 让用户的 TOML 解析失败。
|
|
762
766
|
const { text: after, removed } = removeTomlAbsSection(text);
|
|
763
767
|
if (removed) {
|
|
768
|
+
await backup(mcpP);
|
|
764
769
|
await atomicWrite(mcpP, after);
|
|
765
770
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
766
771
|
}
|
|
@@ -889,6 +894,7 @@ async function uninstallOpenCode() {
|
|
|
889
894
|
}
|
|
890
895
|
if (cfg && cfg.mcp && cfg.mcp.abs) {
|
|
891
896
|
delete cfg.mcp.abs;
|
|
897
|
+
await backup(mcpP);
|
|
892
898
|
await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
|
|
893
899
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
894
900
|
}
|
|
@@ -949,6 +955,7 @@ async function uninstallPi() {
|
|
|
949
955
|
}
|
|
950
956
|
if (cfg && cfg.mcpServers && cfg.mcpServers.abs) {
|
|
951
957
|
delete cfg.mcpServers.abs;
|
|
958
|
+
await backup(mcpP);
|
|
952
959
|
await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
|
|
953
960
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
954
961
|
}
|
|
@@ -1001,6 +1008,7 @@ async function uninstallOmp() {
|
|
|
1001
1008
|
}
|
|
1002
1009
|
if (cfg && cfg.mcpServers && cfg.mcpServers.abs) {
|
|
1003
1010
|
delete cfg.mcpServers.abs;
|
|
1011
|
+
await backup(mcpP);
|
|
1004
1012
|
await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
|
|
1005
1013
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
1006
1014
|
}
|
package/src/lock.js
CHANGED
|
@@ -32,10 +32,22 @@ const LOCK_WAIT_MAX_MS = 150; // 指数退避上限
|
|
|
32
32
|
// 抢锁总预算:排队等锁的进程须依次排完。多进程高并发(CLI/MCP/hook 同刻抢一文件)下,
|
|
33
33
|
// 3s 会让后到进程在排队中途 LockTimeout 崩溃丢写入。设 30s 容纳大批排队者正常排完。
|
|
34
34
|
const LOCK_MAX_WAIT_MS = 30 * 1000;
|
|
35
|
-
//
|
|
36
|
-
//
|
|
35
|
+
// 兜底年龄:锁文件里没有有效 PID 时(旧版空锁 / PID 读不出),超过此年龄才当残留摘除。
|
|
36
|
+
// 有 PID 后不再按年龄摘活人的锁 —— mutator 跑慢一点也不能让两个进程同时进临界区。
|
|
37
37
|
const STALE_MS = 60 * 1000;
|
|
38
38
|
|
|
39
|
+
/** 进程还活着吗。true=活 / false=已死 / null=判不了(没写 PID、权限不够等)。 */
|
|
40
|
+
function pidAlive(pid) {
|
|
41
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// ESRCH = 没这个进程;其它(权限等)当活着,宁可等也不误抢
|
|
47
|
+
return e.code === 'ESRCH' ? false : true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
async function acquireLock(lockPath) {
|
|
40
52
|
const start = Date.now();
|
|
41
53
|
let backoff = LOCK_WAIT_BASE_MS;
|
|
@@ -43,20 +55,35 @@ async function acquireLock(lockPath) {
|
|
|
43
55
|
let handle = null;
|
|
44
56
|
try {
|
|
45
57
|
handle = await fs.open(lockPath, 'wx'); // 原子创建(O_EXCL);已存在则抛 EEXIST → 排队等
|
|
58
|
+
// 写入 PID:后来者据此判断持锁人是否已死(崩溃残留可立刻摘,不等 60s)。
|
|
59
|
+
// 写 PID 失败不回滚 —— 空锁退回按年龄判残留,锁本身仍然有效。
|
|
60
|
+
await handle.writeFile(String(process.pid), 'utf8').catch(() => {});
|
|
46
61
|
await handle.close(); // 锁的存在即持有信号;无需保持 fd
|
|
47
62
|
return;
|
|
48
63
|
} catch (e) {
|
|
49
64
|
if (handle) await handle.close().catch(() => {});
|
|
50
65
|
if (e.code !== 'EEXIST') throw e;
|
|
51
|
-
//
|
|
66
|
+
// 残留锁检测:优先看 PID(持锁进程已死 → 立刻摘,不等 60s);
|
|
67
|
+
// PID 读不出再退回按年龄(兼容旧版空锁文件)。
|
|
68
|
+
// ★ 持锁进程还活着就绝不摘 —— 哪怕超过 STALE_MS。
|
|
69
|
+
// 旧逻辑按 60s 硬摘,mutator 跑慢一点就让两个进程同时进临界区(2026-11-10 审查)。
|
|
52
70
|
try {
|
|
53
|
-
const
|
|
54
|
-
|
|
71
|
+
const raw = (await fs.readFile(lockPath, 'utf8')).trim();
|
|
72
|
+
const alive = pidAlive(Number(raw));
|
|
73
|
+
if (alive === false) {
|
|
55
74
|
await fs.rm(lockPath, { force: true });
|
|
56
|
-
backoff = LOCK_WAIT_BASE_MS;
|
|
75
|
+
backoff = LOCK_WAIT_BASE_MS;
|
|
57
76
|
continue;
|
|
58
77
|
}
|
|
59
|
-
|
|
78
|
+
if (alive === null) {
|
|
79
|
+
const st = await fs.stat(lockPath);
|
|
80
|
+
if (Date.now() - st.mtimeMs > STALE_MS) {
|
|
81
|
+
await fs.rm(lockPath, { force: true });
|
|
82
|
+
backoff = LOCK_WAIT_BASE_MS;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch { /* 读锁失败(刚被释放) → 下一轮重试 */ }
|
|
60
87
|
if (Date.now() - start > LOCK_MAX_WAIT_MS) {
|
|
61
88
|
throw new LockTimeout(`写锁排队超时(等 ${Date.now() - start}ms > 预算 ${LOCK_MAX_WAIT_MS}ms): ${lockPath} 仍被占用`);
|
|
62
89
|
}
|
|
@@ -98,7 +125,18 @@ export async function editFile(file, mutator) {
|
|
|
98
125
|
if (gate) write = gate(file, write, current) ?? write;
|
|
99
126
|
}
|
|
100
127
|
if (write && write !== current) {
|
|
101
|
-
|
|
128
|
+
// 原子落盘:先写同目录临时文件再 rename。
|
|
129
|
+
// fs.writeFile 是「截断再写」,进程死在中间 = 图谱剩半截/空文件。
|
|
130
|
+
// rename 在同一文件系统上是原子的 —— 读方要么见旧要么见新,不见半截。
|
|
131
|
+
// tmp 名带随机段:同毫秒两个进程写同一文件也不会撞名(summon.js 同坑)。
|
|
132
|
+
const tmp = join(dirname(file), `.${basename(file)}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
133
|
+
try {
|
|
134
|
+
await fs.writeFile(tmp, write, 'utf8');
|
|
135
|
+
await fs.rename(tmp, file);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
102
140
|
}
|
|
103
141
|
return res;
|
|
104
142
|
} finally {
|
package/src/todo/task-cmd.js
CHANGED
|
@@ -9,15 +9,22 @@ import { ensurePersonPage } from '../note.js';
|
|
|
9
9
|
|
|
10
10
|
// 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
|
|
11
11
|
// log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
|
|
12
|
+
/** 任务 id 的合法形状:一个词,不含空白 / 路径分隔符 / 方括号。
|
|
13
|
+
* 为什么这么严(2026-11-10 审查实测):
|
|
14
|
+
* ① 空白会被 extractId 截断(静默丢信息);换行还会往 todo.md 里注一行。
|
|
15
|
+
* ② `/` `\` 会被 setMemo 拼进文件名写出 .brain/ 外(路径穿越,`../..` 实测逃逸)。
|
|
16
|
+
* ③ `[` `]` 撞行首状态标记 `[进行中]` 的语法。
|
|
17
|
+
* 旧注释写了「不能含方括号」,代码只查了空格 tab —— 这次收口。 */
|
|
18
|
+
function isValidId(s) {
|
|
19
|
+
return !/[\s\\/\0[\]]/.test(s);
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
export async function cmdTask({ dir, action, id, section, note, as, session, n }) {
|
|
13
|
-
// ★ id
|
|
14
|
-
// 不校就是**静默丢信息**:id="带 空格" 写进文件时被 extractId 截成 "带",
|
|
15
|
-
// 后面的 "空格" 与描述一起消失,不报错(实测:`abs todo start "带 空格"` 落成 `- [ ] 带`)。
|
|
16
|
-
// 宁可明确拒掉,也不替人猜哪个词算 id。
|
|
23
|
+
// ★ id 校验:宁可明确拒掉,也不替人猜哪个词算 id、不把怪 id 写出图谱外。
|
|
17
24
|
const idStr = id === undefined || id === null ? '' : String(id);
|
|
18
|
-
if (idStr && (idStr
|
|
25
|
+
if (idStr && !isValidId(idStr)) {
|
|
19
26
|
throw new Error(
|
|
20
|
-
`✗ 任务 id
|
|
27
|
+
`✗ 任务 id 不能含空格、/、\\、[、](收到 "${idStr}")—— id 是一个词,也会被拼进文件名。\n` +
|
|
21
28
|
` 想要带空格的名字:用连字符或下划线;完整名称写进 --note。`,
|
|
22
29
|
);
|
|
23
30
|
}
|
|
@@ -113,7 +120,7 @@ export async function cmdTask({ dir, action, id, section, note, as, session, n }
|
|
|
113
120
|
// --note 传新 id(沿用既有 CLI 习惯,免得再加一个 flag)。
|
|
114
121
|
if (!note) throw new Error('✗ 用法: abs todo rename <旧id> --note "<新id>"');
|
|
115
122
|
const newId = String(note).replace(/[\u200b\u200c\u200d\ufeff]/g, '').trim();
|
|
116
|
-
if (!newId ||
|
|
123
|
+
if (!newId || !isValidId(newId)) throw new Error(`✗ 新 id 不能为空,且不能含空白、/、\\、[、]: "${newId}"`);
|
|
117
124
|
const f = brainPath(root, 'todo.md');
|
|
118
125
|
let done = false;
|
|
119
126
|
await editFile(f, (cur) => {
|