@fanchao8609/agent_brain_sync 0.1.0 → 0.2.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/package.json +10 -2
- package/skill/SKILL.md +5 -0
- package/src/install.js +118 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fanchao8609/agent_brain_sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,7 +15,15 @@
|
|
|
15
15
|
"README.md"
|
|
16
16
|
],
|
|
17
17
|
"keywords": [
|
|
18
|
-
"memory",
|
|
18
|
+
"memory",
|
|
19
|
+
"agent",
|
|
20
|
+
"brain",
|
|
21
|
+
"markdown",
|
|
22
|
+
"cross-session",
|
|
23
|
+
"claude",
|
|
24
|
+
"mcp",
|
|
25
|
+
"knowledge-graph",
|
|
26
|
+
"obsidian"
|
|
19
27
|
],
|
|
20
28
|
"engines": {
|
|
21
29
|
"node": ">=18"
|
package/skill/SKILL.md
CHANGED
|
@@ -128,6 +128,11 @@ monorepo 若多个子包各自独立交付,可各建一份 `.brain/`;横向
|
|
|
128
128
|
> (经 `abs wrapup`,机械、幂等去重,不替你做判断)。**下会话 `abs load` 会自动把滞留顶到顶部**
|
|
129
129
|
> (`⏳ 上会话滞留`),所以收尾不是靠自觉记日志,而是开场被强制接上。要不要把某个任务标 done,
|
|
130
130
|
> 仍由你判断(快照只记录「哪些还开着」,不猜完成)。
|
|
131
|
+
>
|
|
132
|
+
> **主动注入(pi 已实现,别等它、也别嫌它吵)**:pi 扩展在 `agent_end` 检测「本会话真改过文件
|
|
133
|
+
> (write/edit/非只读 bash)」且「`.brain/log.md` 今日无记录」时,会注入一条 `[abs 收尾提醒]` 消息
|
|
134
|
+
> 逼你走本循环(每会话最多一次,已收尾/无图谱则不打扰)。**收到就照做,别复述提醒、别解释为什么在收尾**;
|
|
135
|
+
> 确无可沉淀产出回一句「无可沉淀」即可。Claude/Codex 侧靠 `Stop` 事件(见 event.sh)达成同样效果。
|
|
131
136
|
|
|
132
137
|
收到 Stop / "结束/先这样/切别的事" / 长任务告一段落,立即执行(快、准、不啰嗦):
|
|
133
138
|
|
package/src/install.js
CHANGED
|
@@ -157,14 +157,26 @@ async function installCodex({ withMcp, withSkill, log }) {
|
|
|
157
157
|
const p = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'hooks.json');
|
|
158
158
|
await backup(p);
|
|
159
159
|
const cfg = await readJson(p);
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
|
|
160
|
+
// Codex 真实结构是 {hooks: {EventName: [{matcher?, hooks:[{type,command}]}]}} (与 Claude 同形)。
|
|
161
|
+
// 兼容历史 bug 写出的扁平数组 {hooks: [{event, command}]}: 迁移成对象形态, 不丢既有 hook。
|
|
162
|
+
let hooks = cfg.hooks;
|
|
163
|
+
if (Array.isArray(hooks)) {
|
|
164
|
+
const migrated = {};
|
|
165
|
+
for (const h of hooks) {
|
|
166
|
+
if (!h || !h.event) continue;
|
|
167
|
+
(migrated[h.event] = migrated[h.event] || []).push({ hooks: [{ type: 'command', command: h.command }] });
|
|
168
|
+
}
|
|
169
|
+
hooks = migrated;
|
|
170
|
+
}
|
|
171
|
+
if (!hooks || typeof hooks !== 'object') hooks = {};
|
|
163
172
|
for (const [ev, script] of Object.entries(scriptMap)) {
|
|
164
|
-
|
|
173
|
+
const existing = Array.isArray(hooks[ev]) ? hooks[ev] : [];
|
|
174
|
+
const kept = existing.filter((e) => !entryHasAbs(e)); // 去掉旧 abs 条目, 幂等; 保留既有 hook
|
|
175
|
+
hooks[ev] = [...kept, { hooks: [{ type: 'command', command: script }] }];
|
|
165
176
|
}
|
|
177
|
+
cfg.hooks = hooks;
|
|
166
178
|
await atomicWrite(p, JSON.stringify(cfg, null, 2));
|
|
167
|
-
steps.push(`✓ hooks → ${p} (${Object.keys(scriptMap).length}
|
|
179
|
+
steps.push(`✓ hooks → ${p} (${Object.keys(scriptMap).length} 事件, 与既有 hook 共存)`);
|
|
168
180
|
|
|
169
181
|
if (withMcp) {
|
|
170
182
|
const mcpP = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'config.toml');
|
|
@@ -192,14 +204,27 @@ async function uninstallCodex() {
|
|
|
192
204
|
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
193
205
|
const p = join(home, 'hooks.json');
|
|
194
206
|
const cfg = await readJson(p);
|
|
195
|
-
|
|
207
|
+
let changed = false;
|
|
208
|
+
if (Array.isArray(cfg.hooks)) { // 历史扁平数组形态
|
|
196
209
|
const before = cfg.hooks.length;
|
|
197
210
|
cfg.hooks = cfg.hooks.filter((h) => !String(h.command || '').includes('/.abs/hooks/'));
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
211
|
+
changed = cfg.hooks.length !== before;
|
|
212
|
+
} else if (cfg.hooks && typeof cfg.hooks === 'object') { // 对象形态 {EventName: [...]}
|
|
213
|
+
for (const ev of Object.keys(cfg.hooks)) {
|
|
214
|
+
const arr = cfg.hooks[ev];
|
|
215
|
+
if (!Array.isArray(arr)) continue;
|
|
216
|
+
const kept = arr.filter((e) => !entryHasAbs(e));
|
|
217
|
+
if (kept.length !== arr.length) {
|
|
218
|
+
changed = true;
|
|
219
|
+
if (kept.length) cfg.hooks[ev] = kept;
|
|
220
|
+
else delete cfg.hooks[ev]; // 无共存 hook 时整删该事件
|
|
221
|
+
}
|
|
201
222
|
}
|
|
202
223
|
}
|
|
224
|
+
if (changed) {
|
|
225
|
+
await atomicWrite(p, JSON.stringify(cfg, null, 2));
|
|
226
|
+
steps.push(`✓ hooks 已从 ${p} 移除`);
|
|
227
|
+
}
|
|
203
228
|
const mcpP = join(home, 'config.toml');
|
|
204
229
|
try {
|
|
205
230
|
const text = await fs.readFile(mcpP, 'utf8');
|
|
@@ -256,11 +281,13 @@ function piPluginSource() {
|
|
|
256
281
|
* 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
|
|
257
282
|
* Pi 事件: session_start / session_shutdown (对应 host hook 的 SessionStart/SessionEnd)。
|
|
258
283
|
* session_shutdown 额外触发 abs wrapup: 把当前项目未完成任务快照到 wrapup.log (跨会话收尾保险)。
|
|
284
|
+
* agent_end 收尾注入: 本会话真改过文件 + .brain 今日无记录 → 注入一条收尾指令, 逼 agent 走收尾循环
|
|
285
|
+
* (被动记日志不够 —— 没人提醒就不会有人收尾)。每会话最多一次, 且已收尾后不再打扰。
|
|
259
286
|
*/
|
|
260
287
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
261
|
-
import { appendFile, mkdir } from "node:fs/promises"
|
|
288
|
+
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
262
289
|
import { homedir } from "node:os"
|
|
263
|
-
import { join } from "node:path"
|
|
290
|
+
import { join, dirname } from "node:path"
|
|
264
291
|
import { spawn } from "node:child_process"
|
|
265
292
|
|
|
266
293
|
const ABS_BIN = "${join(ABS_DIR, 'bin', 'abs.js')}"
|
|
@@ -286,13 +313,92 @@ function snapshotWrapup(cwd: string): void {
|
|
|
286
313
|
} catch {} // fire-and-forget
|
|
287
314
|
}
|
|
288
315
|
|
|
316
|
+
// ---- 收尾注入判定 ----
|
|
317
|
+
// 真改过文件的工具(read/grep/ls 不算, 那些不产生可沉淀的产出)
|
|
318
|
+
const WRITE_TOOLS = new Set(["write", "edit", "multi_edit", "apply_patch", "bash"])
|
|
319
|
+
// bash 里只跑查询类命令不算改文件
|
|
320
|
+
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/
|
|
321
|
+
|
|
322
|
+
function hasWriteWork(toolResults: any[]): boolean {
|
|
323
|
+
for (const r of toolResults || []) {
|
|
324
|
+
const name = String(r?.toolName || "")
|
|
325
|
+
if (!WRITE_TOOLS.has(name)) continue
|
|
326
|
+
if (r?.isError) continue
|
|
327
|
+
if (name === "bash") {
|
|
328
|
+
const cmd = String(r?.input?.command ?? r?.args?.command ?? "")
|
|
329
|
+
if (READONLY_CMD.test(cmd)) continue
|
|
330
|
+
}
|
|
331
|
+
return true
|
|
332
|
+
}
|
|
333
|
+
return false
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 从 cwd 向上找最近含 .brain/ 的祖先目录。 */
|
|
337
|
+
async function findBrain(cwd: string): Promise<string | null> {
|
|
338
|
+
let d = cwd || process.cwd()
|
|
339
|
+
for (;;) {
|
|
340
|
+
try {
|
|
341
|
+
const st = await stat(join(d, ".brain"))
|
|
342
|
+
if (st.isDirectory()) return join(d, ".brain")
|
|
343
|
+
} catch {}
|
|
344
|
+
const up = dirname(d)
|
|
345
|
+
if (up === d) return null
|
|
346
|
+
d = up
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** .brain/log.md 今天有记录吗? 有=已收尾, 不再打扰。 */
|
|
351
|
+
async function loggedToday(brain: string): Promise<boolean> {
|
|
352
|
+
try {
|
|
353
|
+
const txt = await readFile(join(brain, "log.md"), "utf8")
|
|
354
|
+
const d = new Date()
|
|
355
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
356
|
+
const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
357
|
+
return txt.includes(today)
|
|
358
|
+
} catch { return false }
|
|
359
|
+
}
|
|
360
|
+
|
|
289
361
|
export default function absPiHook(pi: ExtensionAPI): void {
|
|
290
362
|
pi.on("session_start", () => logHook("session_start").catch(() => {}))
|
|
291
|
-
|
|
363
|
+
|
|
364
|
+
// 收尾注入: 每个会话最多一次, 避免反复打扰。
|
|
365
|
+
let teardownNudged = false
|
|
366
|
+
pi.on("agent_end", async (event: any, ctx: any) => {
|
|
367
|
+
if (teardownNudged) return
|
|
368
|
+
const cwd = (ctx && ctx.cwd) || process.cwd()
|
|
369
|
+
const brain = await findBrain(cwd)
|
|
370
|
+
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
371
|
+
if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
|
|
372
|
+
if (await loggedToday(brain)) return // 今天已收尾过
|
|
373
|
+
teardownNudged = true
|
|
374
|
+
await logHook("agent_end:teardown-nudge").catch(() => {})
|
|
375
|
+
try {
|
|
376
|
+
pi.sendUserMessage(
|
|
377
|
+
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\\n" +
|
|
378
|
+
"1) 跑 abs load 看 Today 还有哪些未完成;\\n" +
|
|
379
|
+
"2) 实际做完漏登记的 abs task done <id>,做到一半的 abs task note <id> --note \\"断点\\";\\n" +
|
|
380
|
+
"3) 值得留的经验 abs note \\"...\\"(宁少勿滥,能从代码 grep 到的不记);\\n" +
|
|
381
|
+
"4) abs log \\"完成 X:...\\" 记一行工作成果,新页同步进 index。\\n" +
|
|
382
|
+
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\\"无可沉淀\\"即可。",
|
|
383
|
+
{ deliverAs: "followUp" },
|
|
384
|
+
)
|
|
385
|
+
} catch {}
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
pi.on("session_shutdown", (_e: any, ctx: any) => {
|
|
292
389
|
snapshotWrapup((ctx && ctx.cwd) || process.cwd())
|
|
293
390
|
logHook("session_shutdown").catch(() => {})
|
|
294
391
|
})
|
|
295
392
|
}
|
|
393
|
+
|
|
394
|
+
/** agent_end 的 event.messages 里翻出 toolResult 消息。 */
|
|
395
|
+
function collectToolResults(messages: any[]): any[] {
|
|
396
|
+
const out: any[] = []
|
|
397
|
+
for (const m of messages || []) {
|
|
398
|
+
if (m && m.role === "toolResult") out.push(m)
|
|
399
|
+
}
|
|
400
|
+
return out
|
|
401
|
+
}
|
|
296
402
|
${MARK}
|
|
297
403
|
`;
|
|
298
404
|
}
|