@fanchao8609/agent_brain_sync 1.9.6 → 1.9.8
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 +22 -3
- package/hooks/abs.opencode.ts +15 -86
- package/hooks/abs.pi.ts +25 -165
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +6 -4
- package/src/codegraph.js +87 -0
- package/src/install.js +5 -5
- package/src/lint.js +327 -0
- package/src/query.js +146 -0
- package/src/store.js +112 -503
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, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede } from '../src/store.js';
|
|
5
|
+
import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede, cmdReview } 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';
|
|
@@ -76,6 +76,13 @@ const FLAG_SPEC = {
|
|
|
76
76
|
'port': { type: 'string' },
|
|
77
77
|
// note 的触发条件(何时该读这条经验)—— 供 load 的相关页推荐匹配。
|
|
78
78
|
'when': { type: 'string' },
|
|
79
|
+
// note 可选:借本机 CodeGraph 带出「改某符号会影响哪些代码」。
|
|
80
|
+
'impact': { type: 'string' },
|
|
81
|
+
// note 可选:经验类型(fact/pref/constraint/event,对齐 TencentDB L1 四分类)。
|
|
82
|
+
'type': { type: 'string' },
|
|
83
|
+
// review 用:--accept / --reject 各跟一个页名列表(逗号分隔或位置参数)。
|
|
84
|
+
'accept': { type: 'string' },
|
|
85
|
+
'reject': { type: 'string' },
|
|
79
86
|
// concept 骨架用: 不在 FLAG_SPEC 里的 `--title` 会被静默当布尔 true(见 parseArgv 注释),
|
|
80
87
|
// 于是 `--title "一句话"` 的正文会进位置参数 → 必须在这声明。
|
|
81
88
|
'title': { type: 'string' },
|
|
@@ -194,6 +201,7 @@ const usage = `abs — 跨会话记忆工具(.brain/ 图谱)
|
|
|
194
201
|
query <词…> [--all] 检索知识页 (多词 OR)
|
|
195
202
|
resolve <页名…> 反查页面路径
|
|
196
203
|
supersede <页名> [--by 页] 标经验已失效
|
|
204
|
+
review [--accept 页…|--reject 页…] 待确认页队列 (draft 升 active/否决)
|
|
197
205
|
lint 图谱体检 (死链/超限)
|
|
198
206
|
rule [add "一句话"] 硬规则读写
|
|
199
207
|
install|uninstall [--agent <宿主>] [--no-mcp] [--no-skill]
|
|
@@ -281,11 +289,13 @@ const subUsage = {
|
|
|
281
289
|
'abs note — 经验实时暂存 → sources/(幂等去重;先记后提炼)',
|
|
282
290
|
'',
|
|
283
291
|
'用法:',
|
|
284
|
-
' abs note "一句话经验" [--tags 坑,docker] [--when "什么时候该读这条"]',
|
|
292
|
+
' abs note "一句话经验" [--tags 坑,docker] [--when "什么时候该读这条"] [--impact 符号名] [--type fact|pref|constraint|event]',
|
|
285
293
|
'',
|
|
286
294
|
'说明:',
|
|
287
295
|
' • --tags 首个标签建议带类别(坑/技巧/决策…),检索按词 OR 命中',
|
|
288
296
|
' • --when 供 load 的相关页推荐匹配「何时该读」',
|
|
297
|
+
' • --impact 借本机 CodeGraph 带出「改该符号会影响哪些代码」(未装则静默跳过)',
|
|
298
|
+
' • --type 经验分类:fact 事实 / pref 偏好 / constraint 约束 / event 事件(默认不分类)',
|
|
289
299
|
' • sources 是暂存区: 提炼成 concept 后应清理',
|
|
290
300
|
].join('\n'),
|
|
291
301
|
query: [
|
|
@@ -447,7 +457,7 @@ async function main() {
|
|
|
447
457
|
break;
|
|
448
458
|
}
|
|
449
459
|
case 'note': {
|
|
450
|
-
console.log(await cmdNote({ dir: opts.dir, text: opts._.join(' '), tags: opts.tags, when: opts.when }));
|
|
460
|
+
console.log(await cmdNote({ dir: opts.dir, text: opts._.join(' '), tags: opts.tags, when: opts.when, impact: opts.impact, type: opts.type }));
|
|
451
461
|
break;
|
|
452
462
|
}
|
|
453
463
|
case 'concept': {
|
|
@@ -496,6 +506,15 @@ async function main() {
|
|
|
496
506
|
console.log(await cmdResolve({ dir: opts.dir, refs: opts._ }));
|
|
497
507
|
break;
|
|
498
508
|
}
|
|
509
|
+
case 'review': {
|
|
510
|
+
// 无 --accept/--reject → 列出全部 draft 页;有则对页名列表执行确认/否决。
|
|
511
|
+
const action = opts.accept ? 'accept' : opts.reject ? 'reject' : '';
|
|
512
|
+
const refs = action === 'accept' ? (opts.accept || '').split(',').map(s => s.trim()).filter(Boolean)
|
|
513
|
+
: action === 'reject' ? (opts.reject || '').split(',').map(s => s.trim()).filter(Boolean)
|
|
514
|
+
: opts._;
|
|
515
|
+
console.log(await cmdReview({ dir: opts.dir, refs, action }));
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
499
518
|
case 'lint': {
|
|
500
519
|
rejectExtra(opts._, 'abs lint');
|
|
501
520
|
console.log(await cmdLint({ dir: opts.dir }));
|
package/hooks/abs.opencode.ts
CHANGED
|
@@ -3,25 +3,16 @@
|
|
|
3
3
|
* 纯触发: 生命周期事件 → 技术日志一行 (~/.abs/log/hooks.log, ABS_LOG_DIR 可覆盖)。fire-and-forget。
|
|
4
4
|
* 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
|
|
5
5
|
* Opencode 事件: session.created / session.idle / session.deleted (idle = 每轮结束, 对应 pi 的 agent_end)。
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 旧设计只在 idle(轮末) 提醒收尾 —— 那时用户已想结束, AI 只想收尾不想登记。
|
|
10
|
-
* 故 tool.execute.after 检测到**项目文件**写入即注入登记提示(不等 idle):
|
|
11
|
-
* · 频繁没关系 —— 每次写都提醒(用户明确要求)
|
|
12
|
-
* · 判据 = 整个项目, 但**排除 .brain/ 自身**(写图谱是记录行为, 不是任务)
|
|
13
|
-
* · 文案明说「纯讨论可跳过」—— 不逼 AI 造任务
|
|
6
|
+
* 收尾注入已删除(2026-09-18):曾用 session.idle + promptAsync 推一条收尾提醒, 用户实报
|
|
7
|
+
* 「每次干活干一会就出来, 任务就中断了」。根因同 pi 侧: 主动往对话里插 turn = 打断,
|
|
8
|
+
* 不是频率问题(2026-09-15 删登记注入时已得出同一结论)。保留 session.idle:seen 埋点。
|
|
14
9
|
*/
|
|
15
|
-
import { appendFile, mkdir,
|
|
10
|
+
import { appendFile, mkdir, stat } from "node:fs/promises"
|
|
16
11
|
import { homedir } from "node:os"
|
|
17
|
-
import { join
|
|
18
|
-
const server = async ({
|
|
19
|
-
//
|
|
20
|
-
let wroteFiles = false
|
|
21
|
-
let nudged = false
|
|
12
|
+
import { join } from "node:path"
|
|
13
|
+
const server = async ({ directory }) => {
|
|
14
|
+
// 本会话首次 idle 是否已埋点
|
|
22
15
|
let idleSeen = false
|
|
23
|
-
// 当前 session id(由事件里拿)—— 收尾注入需要它找到会话。
|
|
24
|
-
let currentSessionID = ""
|
|
25
16
|
async function logHook(evt) {
|
|
26
17
|
try {
|
|
27
18
|
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
@@ -39,94 +30,32 @@ const server = async ({ client, directory }) => {
|
|
|
39
30
|
try { const st = await stat(join(d, ".brain")); return st.isDirectory() ? join(d, ".brain") : null } catch { return null }
|
|
40
31
|
}
|
|
41
32
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// 三宿主(pi/CC/opencode)判据必须一致 —— 见 [[hook-throttle-alignment]]。
|
|
47
|
-
async function loggedToday(brain) {
|
|
48
|
-
try {
|
|
49
|
-
const txt = await readFile(join(brain, "log.md"), "utf8")
|
|
50
|
-
const d = new Date()
|
|
51
|
-
const pad = (n) => String(n).padStart(2, "0")
|
|
52
|
-
const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
53
|
-
return new RegExp("^## \\[" + today + " \\d{2}:\\d{2}\\] (?:\\[[^\\]]+\\]\\] )?dev \\|", "m").test(txt)
|
|
54
|
-
} catch { return false }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const TEARDOWN_MSG =
|
|
59
|
-
"[abs] 本会话改过文件,.brain/ 今日无记录。\n" +
|
|
60
|
-
"这条是信息不是命令:该登记/沉淀就登记,没有可沉淀的直接回一句「无可沉淀」,不用凑。\n" +
|
|
61
|
-
"先看任务:本会话做完的事有没有进看板?(abs todo / abs todo done <id>;干到一半 abs todo note <id> --note 断点)\n" +
|
|
62
|
-
"再看沉淀:经验/坑 → abs note \"...\";成果流水 → abs log \"...\""
|
|
63
|
-
|
|
64
|
-
// bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
|
|
65
|
-
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/
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
33
|
+
// 收尾注入已停用(2026-09-18):promptAsync 会在每轮 idle 抢一个 turn,用户实报
|
|
34
|
+
// 「每次干活干一会就出来, 任务就中断了」。wroteFiles / nudged / READONLY_CMD /
|
|
35
|
+
// tool.execute.after / loggedToday 随注入一起删除(注入没了,它们已无消费方)。
|
|
36
|
+
// 保留 session.idle:seen 埋点:区分「事件没触发」与「被守卫拦下」。
|
|
70
37
|
return {
|
|
71
|
-
// 观测真实写操作: write/edit/patch 类工具成功即标记。
|
|
72
|
-
// 坑: 曾漏掉 bash —— opencode 里很多修改是经 bash(heredoc/sed) 完成的, 纯 bash 会话
|
|
73
|
-
// 永远不置位 wroteFiles, 于是收尾提醒静默不发。与 pi 侧 WRITE_TOOLS 保持一致。
|
|
74
|
-
"tool.execute.after": async ({ tool, args }) => {
|
|
75
|
-
const t = String(tool || "").toLowerCase()
|
|
76
|
-
const a = args || {}
|
|
77
|
-
if (["write", "edit", "patch", "multiedit", "apply_patch"].includes(t)) {
|
|
78
|
-
wroteFiles = true
|
|
79
|
-
return
|
|
80
|
-
}
|
|
81
|
-
// bash 里只有非只读命令算改文件(ls/cat/git status 之类不算)
|
|
82
|
-
if (t === "bash") {
|
|
83
|
-
const cmd = String(a.command || a.cmd || "")
|
|
84
|
-
if (cmd && !READONLY_CMD.test(cmd)) wroteFiles = true
|
|
85
|
-
}
|
|
86
|
-
},
|
|
87
|
-
|
|
88
38
|
event: async ({ event }) => {
|
|
89
39
|
const type = event && event.type
|
|
90
40
|
if (!type) return
|
|
91
41
|
if (type === "session.created" || type === "session.deleted") {
|
|
92
42
|
await logHook(type)
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
if (sid) currentSessionID = String(sid)
|
|
96
|
-
// 新会话 = 重置节流状态。坑(2026-09-13 pi 侧同构实测): 状态声明在 server 工厂闭包里,
|
|
97
|
-
// 同一进程的第二个会话会继承上个会话的 true → 永久不再提醒(后半场全部静默)。
|
|
43
|
+
// 新会话 = 重置埋点状态。坑(2026-09-13 pi 侧同构实测): 状态声明在 server 工厂闭包里,
|
|
44
|
+
// 同一进程的第二个会话会继承上个会话的 true → 永久不再留痕。
|
|
98
45
|
// pi 侧在 session_start 里重置; opencode 的对应事件就是 session.created。
|
|
99
46
|
if (type === "session.created") {
|
|
100
|
-
wroteFiles = false
|
|
101
|
-
nudged = false
|
|
102
47
|
idleSeen = false
|
|
103
48
|
}
|
|
104
49
|
return
|
|
105
50
|
}
|
|
106
|
-
if (type !== "session.idle") return // idle = 每轮结束, 不记日志(太吵),
|
|
107
|
-
const sid0 = event.properties && event.properties.sessionID
|
|
108
|
-
if (sid0) currentSessionID = String(sid0)
|
|
51
|
+
if (type !== "session.idle") return // idle = 每轮结束, 不记日志(太吵), 只做可观测性埋点
|
|
109
52
|
const cwd = directory || process.cwd()
|
|
110
53
|
const brain = await findBrain(cwd)
|
|
111
|
-
// 可观测性: 首次 idle
|
|
54
|
+
// 可观测性: 首次 idle 留一行痕(否则无法区分“事件没触发”与“被守卫拦下”)
|
|
112
55
|
if (!idleSeen) {
|
|
113
56
|
idleSeen = true
|
|
114
57
|
await logHook(`session.idle:seen cwd=${cwd} brain=${brain || 'none'}`)
|
|
115
58
|
}
|
|
116
|
-
if (nudged || !wroteFiles) return
|
|
117
|
-
const sessionID = event.properties && event.properties.sessionID
|
|
118
|
-
if (!sessionID) return
|
|
119
|
-
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
120
|
-
if (await loggedToday(brain)) return // 今天已收尾过
|
|
121
|
-
nudged = true
|
|
122
|
-
await logHook("session.idle:teardown-nudge")
|
|
123
|
-
try {
|
|
124
|
-
await client.session.promptAsync({
|
|
125
|
-
path: { id: sessionID },
|
|
126
|
-
query: { directory: cwd },
|
|
127
|
-
body: { parts: [{ type: "text", text: TEARDOWN_MSG }] },
|
|
128
|
-
})
|
|
129
|
-
} catch {} // 注入失败不阻塞宿主
|
|
130
59
|
},
|
|
131
60
|
}
|
|
132
61
|
}
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -4,49 +4,22 @@
|
|
|
4
4
|
* 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
|
|
5
5
|
* Pi 事件: session_start / session_shutdown (对应 host hook 的 SessionStart/SessionEnd)。
|
|
6
6
|
* session_shutdown 额外触发 abs wrapup: 把当前项目未完成任务快照到 wrapup.log (跨会话收尾保险)。
|
|
7
|
-
* agent_end
|
|
8
|
-
* (被动记日志不够 —— 没人提醒就不会有人收尾)。每会话最多一次, 且已收尾后不再打扰。
|
|
7
|
+
* agent_end 只留可观测性埋点(agent_end:seen), 不再向对话注入任何东西。
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* 已删除(2026-09-15):曾加过一个「写文件即任务开始 → 每轮注入登记提醒」的功能。
|
|
17
|
-
* 已发布到 1.8.0 后用户实报「pi 里一直报 Follow-up」,判 C 整个删掉。
|
|
18
|
-
* 教训:每次改文件就往对话里插一句话,本身就是设计错误,不是频率问题 ——
|
|
19
|
-
* 改守卫(从每轮一次改成每会话一次)治不了它。别再加回来。
|
|
9
|
+
* 已删除(2026-09-15):「写文件即任务开始 → 每轮注入登记提醒」。实报「pi 里一直报 Follow-up」。
|
|
10
|
+
* 已删除(2026-09-18):收尾注入本身(agent_end + sendUserMessage deliverAs=followUp)。
|
|
11
|
+
* 实报「每次干活干一会出来, 任务就中断了」。两次同一个根因:
|
|
12
|
+
* **往对话里插一句话本身就是设计错误,不是频率问题** —— 改守卫(每轮→每会话)治不了它。
|
|
13
|
+
* 别再加回来。素材锚点(before_agent_start/turn_end)随注入一起删除, 已无消费方。
|
|
20
14
|
*/
|
|
21
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
22
|
-
import { appendFile, mkdir,
|
|
16
|
+
import { appendFile, mkdir, stat } from "node:fs/promises"
|
|
23
17
|
import { homedir } from "node:os"
|
|
24
|
-
import { join
|
|
18
|
+
import { join } from "node:path"
|
|
25
19
|
import { spawn } from "node:child_process"
|
|
26
20
|
|
|
27
21
|
const ABS_BIN = "@@ABS_BIN@@"
|
|
28
22
|
|
|
29
|
-
// 素材累积锚点:会话内存里累积,收尾时拼进注入提示。**不落盘、不 spawn、不然上下文**。
|
|
30
|
-
// 预算:只留最近几条(收尾回忆只需线索,不需全史)。
|
|
31
|
-
const NOTES_MAX = 12
|
|
32
|
-
const NOTE_LEN = 120
|
|
33
|
-
const sessionNotes: string[] = []
|
|
34
|
-
|
|
35
|
-
function addNote(kind: string, text: string): void {
|
|
36
|
-
const clean = String(text ?? "").replace(/\s+/g, " ").trim().slice(0, NOTE_LEN)
|
|
37
|
-
if (!clean) return
|
|
38
|
-
sessionNotes.push(`${kind}: ${clean}`)
|
|
39
|
-
if (sessionNotes.length > NOTES_MAX) sessionNotes.shift()
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** 把本会话素材拼成一段(收尾注入用)。无素材则空字符串。
|
|
43
|
-
* 用途:让 AI 据线索**归纳**话题,而不是凭记忆回想整场对话(那正是 7% 记录率的原因)。 */
|
|
44
|
-
function notesBlock(notes: string[]): string {
|
|
45
|
-
if (!notes.length) return ""
|
|
46
|
-
return " 本会话素材(hook 机械记录,供归纳)\n" +
|
|
47
|
-
notes.map((n) => " · " + n).join("\n") + "\n"
|
|
48
|
-
}
|
|
49
|
-
|
|
50
23
|
async function logHook(evt: string): Promise<void> {
|
|
51
24
|
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
52
25
|
const d = new Date()
|
|
@@ -68,81 +41,30 @@ function snapshotWrapup(cwd: string): void {
|
|
|
68
41
|
} catch {} // fire-and-forget
|
|
69
42
|
}
|
|
70
43
|
|
|
71
|
-
// ---- 收尾注入判定 ----
|
|
72
|
-
// 真改过文件的工具(read/grep/ls 不算, 那些不产生可沉淀的产出)
|
|
73
|
-
const WRITE_TOOLS = new Set(["write", "edit", "multi_edit", "apply_patch", "bash"])
|
|
74
|
-
// bash 里只跑查询类命令不算改文件
|
|
75
|
-
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/
|
|
76
|
-
|
|
77
|
-
function hasWriteWork(toolResults: any[]): boolean {
|
|
78
|
-
for (const r of toolResults || []) {
|
|
79
|
-
const name = String(r?.toolName || "")
|
|
80
|
-
if (!WRITE_TOOLS.has(name)) continue
|
|
81
|
-
if (r?.isError) continue
|
|
82
|
-
if (name === "bash") {
|
|
83
|
-
const cmd = String(r?.input?.command ?? r?.args?.command ?? "")
|
|
84
|
-
if (READONLY_CMD.test(cmd)) continue
|
|
85
|
-
}
|
|
86
|
-
return true
|
|
87
|
-
}
|
|
88
|
-
return false
|
|
89
|
-
}
|
|
90
|
-
|
|
91
44
|
/** 定位当前项目的 .brain/:只看 cwd 本身,不向上搜索(防爬到家目录图谱)。 */
|
|
92
45
|
async function findBrain(cwd: string): Promise<string | null> {
|
|
93
46
|
const d = cwd || process.cwd()
|
|
94
47
|
try { const st = await stat(join(d, ".brain")); return st.isDirectory() ? join(d, ".brain") : null } catch { return null }
|
|
95
48
|
}
|
|
96
49
|
|
|
97
|
-
/**
|
|
98
|
-
* .brain/log.md 今天有记录吗? 有=已收尾, 不再打扰。
|
|
99
|
-
* 必须匹配条目头 '## [YYYY-MM-DD HH:MM]' —— 裸日期 substring 会被正文里任意一处
|
|
100
|
-
* 今天的日期误命中, 导致"已收尾"误判、nudge 永不触发。
|
|
101
|
-
*/
|
|
102
|
-
async function loggedToday(brain: string): Promise<boolean> {
|
|
103
|
-
try {
|
|
104
|
-
const txt = await readFile(join(brain, "log.md"), "utf8")
|
|
105
|
-
const d = new Date()
|
|
106
|
-
const pad = (n: number) => String(n).padStart(2, "0")
|
|
107
|
-
const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
108
|
-
// 只认**工作成果**条目(kind=dev),不能只看“今天有没有行”。
|
|
109
|
-
// 坑(2026-09-13 实测):`abs note` 也写 log.md(kind=note),
|
|
110
|
-
// 旧判据 (^## [今天 HH:MM]) 把“沉淀了一条经验”当成“今天已收尾”→
|
|
111
|
-
// 整天不再提醒 → 会话后半场全部漏登。
|
|
112
|
-
// 行格式: `## [YYYY-MM-DD HH:MM] [[name]] dev | 内容`。
|
|
113
|
-
return new RegExp("^## \\[" + today + " \\d{2}:\\d{2}\\] (?:\\[\\[[^\\]]+\\]\\] )?dev \\|", "m").test(txt)
|
|
114
|
-
} catch { return false }
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
/** 收尾注入的节流状态。
|
|
50
|
+
/** agent_end 埋点状态。
|
|
119
51
|
*
|
|
120
52
|
* 为何在**模块级**而不在 absPiHook() 里(2026-09-17 实测):扩展会被重复注册
|
|
121
|
-
* (session_start 11 分钟内触发 8 次),每次注册都新建一份闭包 → flag
|
|
122
|
-
*
|
|
53
|
+
* (session_start 11 分钟内触发 8 次),每次注册都新建一份闭包 → flag 不共享。
|
|
54
|
+
* 模块级状态在同一进程内只有一份,注册多少次都共享。
|
|
123
55
|
*
|
|
124
|
-
*
|
|
125
|
-
* teardownInFlight = 正在判定中(抢在 await 之前置位,堵住并发重入)
|
|
126
|
-
* agentEndSeen = 本会话已留过 seen 痕(可观测性,同样每会话一次)
|
|
56
|
+
* agentEndSeen = 本会话已留过 seen 痕(可观测性,每会话一次)
|
|
127
57
|
*/
|
|
128
|
-
let teardownNudged = false
|
|
129
|
-
let teardownInFlight = false
|
|
130
58
|
let agentEndSeen = false
|
|
131
59
|
|
|
132
|
-
/**
|
|
133
|
-
* 不重置 → 同进程第二个会话继承 true
|
|
60
|
+
/** 会话边界重置埋点状态。
|
|
61
|
+
* 不重置 → 同进程第二个会话继承 true 永久不留痕(2026-09-13 实测过的坑)。 */
|
|
134
62
|
function resetThrottle(): void {
|
|
135
|
-
teardownNudged = false
|
|
136
|
-
teardownInFlight = false
|
|
137
63
|
agentEndSeen = false
|
|
138
64
|
}
|
|
139
65
|
|
|
140
66
|
export default function absPiHook(pi: ExtensionAPI): void {
|
|
141
|
-
//
|
|
142
|
-
// 因为**扩展会被重复注册**:实测 session_start 在 11 分钟内触发 8 次
|
|
143
|
-
// (2026-09-17 00:07:58 一口气 3 次)。注册几份就得到几份独立闭包,
|
|
144
|
-
// 各自的 flag 彼此看不见 → “每会话一次”变成“每实例一次” → 实测同会话 5 分钟注入 6 次。
|
|
145
|
-
// 模块级状态在同一进程内共享,注册多少次都只有一份。
|
|
67
|
+
// 埋点状态同上,故意声明在**模块级**(不在被反复调用的工厂函数里)。
|
|
146
68
|
resetThrottle()
|
|
147
69
|
|
|
148
70
|
pi.on("session_start", (event: any, _ctx: any) => {
|
|
@@ -150,73 +72,19 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
150
72
|
return logHook("session_start").catch(() => {})
|
|
151
73
|
})
|
|
152
74
|
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
pi.on("before_agent_start", async (event: any, _ctx: any) => {
|
|
156
|
-
addNote("user", String(event?.prompt || ""))
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
// 每轮结束 = 这轮干了什么(改了哪些文件)。机械事实,供收尾时回忆。
|
|
160
|
-
pi.on("turn_end", async (event: any, _ctx: any) => {
|
|
161
|
-
const files = new Set<string>()
|
|
162
|
-
for (const r of event?.toolResults || []) {
|
|
163
|
-
const name = String(r?.toolName || "")
|
|
164
|
-
if (!WRITE_TOOLS.has(name)) continue
|
|
165
|
-
if (r?.isError) continue
|
|
166
|
-
const f = r?.input?.file_path ?? r?.args?.file_path ?? r?.input?.path ?? r?.args?.path
|
|
167
|
-
if (f) files.add(String(f))
|
|
168
|
-
else if (name === "bash") {
|
|
169
|
-
const cmd = String(r?.input?.command ?? r?.args?.command ?? "")
|
|
170
|
-
if (!READONLY_CMD.test(cmd)) files.add("bash: " + cmd.slice(0, 60))
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
if (files.size) addNote("tool", [...files].join(", "))
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
// 收尾注入: 每个会话最多一次, 避免反复打扰。
|
|
75
|
+
// 收尾注入已停用(2026-09-18):sendUserMessage(deliverAs:"followUp") 会在每轮
|
|
76
|
+
// agent_end 抢一个 follow-up turn,用户实报「每次干活干一会出来, 任务就中断了」。
|
|
177
77
|
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (teardownNudged || teardownInFlight) return
|
|
186
|
-
teardownInFlight = true
|
|
187
|
-
// 从这里往下任何提前 return 都必须解除 in-flight(否则本次会话永久不再提醒)。
|
|
188
|
-
const release = () => { teardownInFlight = false }
|
|
78
|
+
// 为何是删除而非调参:这跟 2026-09-15 删掉「写文件即注入登记提醒」是同一个错
|
|
79
|
+
// ——「往对话里插一句话」本身就不是频率问题(见文件头)。每轮弹、每会话弹、
|
|
80
|
+
// 每项目弹,都还是在打断用户。故不再有 teardownNudged/inFlight/loggedToday 判据。
|
|
81
|
+
// 只保留 agent_end:seen 埋点:区分「事件没触发」与「被守卫拦下」。
|
|
82
|
+
pi.on("agent_end", async (_event: any, ctx: any) => {
|
|
83
|
+
if (agentEndSeen) return
|
|
84
|
+
agentEndSeen = true
|
|
189
85
|
const cwd = (ctx && ctx.cwd) || process.cwd()
|
|
190
86
|
const brain = await findBrain(cwd)
|
|
191
|
-
|
|
192
|
-
// 没有它就无法区分三种状态: ①事件没触发 ②触发了但被守卫拦下 ③真注入了。
|
|
193
|
-
if (!agentEndSeen) {
|
|
194
|
-
agentEndSeen = true
|
|
195
|
-
// 带上 cwd 与命中的 brain —— 否则事后无法解释“为何这条 nudge 会出现”
|
|
196
|
-
await logHook(`agent_end:seen cwd=${cwd} brain=${brain || 'none'}`).catch(() => {})
|
|
197
|
-
}
|
|
198
|
-
if (!brain) return release() // 无图谱=不在这项目沉淀, 不打扰
|
|
199
|
-
if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return release()
|
|
200
|
-
// 今日已收尾 → 只在**本会话还没真正干事**时才静默。
|
|
201
|
-
// 旧行为:今天 log 有一行就整天闭口 —— 于是收尾之后的产出全部没人提醒。
|
|
202
|
-
// 注意不能用 sessionNotes.length 当判据:用户每说一句话就会 push 一条,
|
|
203
|
-
// 那会让 nudge 每轮都触发(噪音)。判据保持「今天已收尾」但配合下面的笔记消费。
|
|
204
|
-
if (await loggedToday(brain)) return release()
|
|
205
|
-
teardownNudged = true
|
|
206
|
-
teardownInFlight = false
|
|
207
|
-
await logHook("agent_end:teardown-nudge").catch(() => {})
|
|
208
|
-
// 素材交给 AI 后清空:同一会话再触发时不该重复喂旧料。
|
|
209
|
-
const notes = sessionNotes.splice(0, sessionNotes.length)
|
|
210
|
-
try {
|
|
211
|
-
pi.sendUserMessage(
|
|
212
|
-
"[abs] 本会话改过文件,.brain/ 今日无记录。\n" +
|
|
213
|
-
"这条是信息不是命令:该登记/沉淀就登记,没有可沉淀的直接回一句「无可沉淀」,不用凑。\n" +
|
|
214
|
-
"先看任务:本会话做完的事有没有进看板?(abs todo / abs todo done <id>;干到一半 abs todo note <id> --note 断点)\n" +
|
|
215
|
-
"再看沉淀:经验/坑 → abs note \"...\";成果流水 → abs log \"...\"" +
|
|
216
|
-
notesBlock(notes),
|
|
217
|
-
{ deliverAs: "followUp" },
|
|
218
|
-
)
|
|
219
|
-
} catch {}
|
|
87
|
+
await logHook(`agent_end:seen cwd=${cwd} brain=${brain || 'none'}`).catch(() => {})
|
|
220
88
|
})
|
|
221
89
|
|
|
222
90
|
pi.on("session_shutdown", (_e: any, ctx: any) => {
|
|
@@ -225,12 +93,4 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
225
93
|
})
|
|
226
94
|
}
|
|
227
95
|
|
|
228
|
-
/** agent_end 的 event.messages 里翻出 toolResult 消息。 */
|
|
229
|
-
function collectToolResults(messages: any[]): any[] {
|
|
230
|
-
const out: any[] = []
|
|
231
|
-
for (const m of messages || []) {
|
|
232
|
-
if (m && m.role === "toolResult") out.push(m)
|
|
233
|
-
}
|
|
234
|
-
return out
|
|
235
|
-
}
|
|
236
96
|
@@MARK@@
|
package/package.json
CHANGED
|
@@ -305,10 +305,12 @@ abs todo archive # 一条命令搞定,无需手工搬
|
|
|
305
305
|
> Stop 时 hook 把「未完成任务 + 断点」快照进 `~/.abs/log/wrapup.log`(`abs wrapup`,机械幂等),
|
|
306
306
|
> 下会话 `abs load` 会自动把滞留顶到顶部(`⏳ 上会话滞留`)—— 所以收尾不靠自觉,是开场被强制接上。
|
|
307
307
|
>
|
|
308
|
-
>
|
|
309
|
-
> `
|
|
310
|
-
>
|
|
311
|
-
>
|
|
308
|
+
> **不再主动注入**(2026-09-18):pi 扩展曾在 `agent_end` 往对话里推一条 `[abs 收尾提醒]`
|
|
309
|
+
> (Claude/Codex 靠 `Stop` 事件、opencode 靠 `session.idle` 达成同样效果)。
|
|
310
|
+
> 已全部删除:用户实报「每次干活干一会就出来, 任务就中断了」。**主动往对话里插一个 turn
|
|
311
|
+
> 就是打断,不是频率问题** —— 每轮弹、每会话弹都还是打断。收尾靠**你自己在该收尾时走**,
|
|
312
|
+
> 以及 `wrapup.log` + `abs load` 的机制兵底(开场强制接上滞留任务)。
|
|
313
|
+
> 挂钩只在 `~/.abs/log/hooks.log` 留痕,不再往对话里说话。
|
|
312
314
|
|
|
313
315
|
当用户**明确说要收尾/结束/切别的事**时,按下面走(不是每个词都触发,见开头总则):
|
|
314
316
|
|
package/src/codegraph.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/codegraph.js — 借用本机 CodeGraph 的「代码影响面」,写回 abs 图谱。
|
|
2
|
+
//
|
|
3
|
+
// 为何要这个模块(而非直接塞进 cmdNote):
|
|
4
|
+
// abs 的定位是纯 markdown 图谱、零依赖、可进 git;CodeGraph 是二进制 SQLite、
|
|
5
|
+
// 不可进 git、且不是每个机器都装。两者职责不同,唯一值得打通的点是一个维度:
|
|
6
|
+
// abs 的经验页里「这个改动会影响哪些代码」是空白的,而 codegraph impact 正是干这个的。
|
|
7
|
+
//
|
|
8
|
+
// 融合姿势 = 借结果、不引依赖:
|
|
9
|
+
// 只在「用户显式传了 --impact <符号>」时才调 codegraph CLI,
|
|
10
|
+
// 把它的纯文本输出写进 .brain/ 的 markdown 页(可进 git)。
|
|
11
|
+
// codegraph 不存在 / 调用失败 / 超时 → 静默降级为「无影响面」,绝不阻断 note 落盘。
|
|
12
|
+
//
|
|
13
|
+
// 为什么走 child_process 调 CLI、不引 npm 包、不走 MCP:
|
|
14
|
+
// - CLI 是 codegraph 自己装的稳定接口(impact/callers/callees),零依赖;
|
|
15
|
+
// - MCP 走宿主钩子,之前实测在 pi 下报 _elicitationHandler 错(宿主 bug),CLI 稳定。
|
|
16
|
+
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
const CG_BIN = 'codegraph';
|
|
20
|
+
const CG_TIMEOUT_MS = 3000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 探测本机 codegraph 是否可用(不抛错,任何失败都返回 false)。
|
|
24
|
+
* 结果只影响「要不要在 note 里带影响面」,绝不影响 note 本身落盘。
|
|
25
|
+
*/
|
|
26
|
+
async function codegraphAvailable() {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
execFile(CG_BIN, ['--version'], { timeout: 1500 }, (err) => resolve(!err));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 索引是否新鲜(无 pending 改动)。
|
|
34
|
+
* codegraph 有文件 watcher,但 daemon 可能没在跑 / 停了很久,索引会过期。
|
|
35
|
+
* 过期时 impact 会返回旧数据 —— 把它写进经验页等于污染 abs 图谱。
|
|
36
|
+
* 用 `codegraph status --json` 的 pendingChanges 判定:全 0 才新鲜;
|
|
37
|
+
* 任何失败(命令不在/超时/解析失败)一律当「不新鲜」,宁缺毋滥。
|
|
38
|
+
*/
|
|
39
|
+
async function codegraphFresh(root) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
execFile(
|
|
42
|
+
CG_BIN,
|
|
43
|
+
['status', '--json', root],
|
|
44
|
+
{ timeout: 2000 },
|
|
45
|
+
(err, stdout) => {
|
|
46
|
+
if (err) return resolve(false);
|
|
47
|
+
try {
|
|
48
|
+
const j = JSON.parse(stdout);
|
|
49
|
+
const pc = j.pendingChanges || {};
|
|
50
|
+
const fresh = (pc.added || 0) + (pc.modified || 0) + (pc.removed || 0) === 0;
|
|
51
|
+
resolve(fresh);
|
|
52
|
+
} catch {
|
|
53
|
+
resolve(false);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 拿某符号的「影响面」:改它会波及哪些函数/文件。
|
|
62
|
+
* @param {string} symbol 符号名(如 cmdTask、requireBrain)
|
|
63
|
+
* @param {string} root 项目根(codegraph -p 参数)
|
|
64
|
+
* @returns {Promise<string|null>} 纯文本影响面,失败/不可用/索引过期返回 null
|
|
65
|
+
*/
|
|
66
|
+
export async function impactOf(symbol, root) {
|
|
67
|
+
const s = String(symbol || '').trim();
|
|
68
|
+
if (!s) return null;
|
|
69
|
+
if (!(await codegraphAvailable())) return null;
|
|
70
|
+
// 索引不新鲜 → 不写影响面(避免把过期结构写进经验页)。
|
|
71
|
+
// 代价:用户刚改完代码立刻 note 时拿不到影响面;收益:绝不写错数据。
|
|
72
|
+
// 新鲜度恢复只需 codegraph 的 watcher 约 1s 同步,或手动 codegraph sync。
|
|
73
|
+
if (!(await codegraphFresh(root))) return null;
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
execFile(
|
|
76
|
+
CG_BIN,
|
|
77
|
+
['impact', s, '--depth', '2', '-p', root],
|
|
78
|
+
{ timeout: CG_TIMEOUT_MS },
|
|
79
|
+
(err, stdout) => {
|
|
80
|
+
if (err) return resolve(null);
|
|
81
|
+
const text = String(stdout || '').trim();
|
|
82
|
+
// codegraph 无命中时输出形如 "No ..." 或空,统一当「无影响面」。
|
|
83
|
+
resolve(text ? text : null);
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
}
|
package/src/install.js
CHANGED
|
@@ -822,11 +822,11 @@ async function installPi({ withMcp, withSkill, log }) {
|
|
|
822
822
|
const p = join(dir, 'abs.ts');
|
|
823
823
|
await atomicWrite(p, piPluginSource()); // Pi 用专用模板, 语法与 OpenCode 不同构
|
|
824
824
|
steps.push(`✓ hook(ts extension) → ${p}`);
|
|
825
|
-
// MCP → ~/.pi/agent/mcp.json 的 mcpServers (stdio)
|
|
826
|
-
//
|
|
827
|
-
//
|
|
825
|
+
// MCP → ~/.pi/agent/mcp-adapter.json 的 mcpServers (stdio)
|
|
826
|
+
// 坑(2026-09-30 用户实报):曾写 mcp.json,但 pi-mcp-adapter 已不再读它
|
|
827
|
+
// (pi 自有 mcp.json 被 adapter 忽略,adapter 改读 mcp-adapter.json)→ 写了等于没写。
|
|
828
828
|
if (withMcp) {
|
|
829
|
-
const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp.json');
|
|
829
|
+
const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp-adapter.json');
|
|
830
830
|
await backup(mcpP);
|
|
831
831
|
const cfg = await readJson(mcpP);
|
|
832
832
|
cfg.mcpServers = cfg.mcpServers || {};
|
|
@@ -845,7 +845,7 @@ async function uninstallPi() {
|
|
|
845
845
|
await fs.rm(join(hostConfigRoot('pi'), 'agent', 'extensions', 'abs.ts'), { force: true });
|
|
846
846
|
steps.push(`✓ extension 已删除`);
|
|
847
847
|
steps.push(...await uninstallSkills('pi'));
|
|
848
|
-
const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp.json');
|
|
848
|
+
const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp-adapter.json');
|
|
849
849
|
const cfg = await readJson(mcpP, { strict: false });
|
|
850
850
|
if (cfg === null) {
|
|
851
851
|
steps.push(`⚠ ${mcpP} 无法解析为 JSON,已跳过(未改动你的配置)`);
|