@fanchao8609/agent_brain_sync 1.13.2 → 1.14.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 CHANGED
@@ -188,8 +188,12 @@ agent_brain_sync/
188
188
  ├── bin/mcp.js MCP server (stdio)
189
189
  ├── src/index.js 图谱定位(只认当前目录的 .brain/)+ 全局技术日志目录
190
190
  ├── src/lock.js 并发写保护(原子锁 + 排队 + SKIP)
191
- ├── src/todo.js todo.md 分区读写
191
+ ├── src/todo.js todo.md 分区读写 + 格式闸门
192
192
  ├── src/store.js CLI 命令实现
193
+ ├── src/lint.js 图谱体检(死链/孤岛/超尺寸/堆积;同类问题折叠计数)
194
+ ├── src/query.js 检索知识页(多词 OR)
195
+ ├── src/relevant.js 相关页推荐(按"当前在干什么"挑该读的页)
196
+ ├── src/codegraph.js 借本机 CodeGraph 取"代码影响面",写回图谱
193
197
  ├── src/hosts.js 四宿主接入定义
194
198
  ├── src/install.js 安装/卸载(分区共存合并)
195
199
  ├── src/userconfig.js 使用者姓名配置(作者标记;占位名如 tester/foo 会被拒)
@@ -197,7 +201,7 @@ agent_brain_sync/
197
201
  ├── hooks/event.sh hook 模板
198
202
  ├── hooks/abs.pi.ts pi 扩展模板(含 todo 面板 / 随机昵称 / 常驻指引)
199
203
  ├── skill/<名称>/SKILL.md 技能(每个子目录 = 一个 skill,装到各智能体)
200
- └── test/ 单测(400+,node:test)
204
+ └── test/ 单测(448,node:test,零外部测试依赖)
201
205
  ```
202
206
 
203
207
  MIT
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, cmdReview } from '../src/store.js';
5
+ import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede, cmdReview, cmdAutoTask, cmdAutoTaskSweep } 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';
@@ -92,6 +92,10 @@ const FLAG_SPEC = {
92
92
  // ★ 漏声明就是真 bug(2026-10-05 本机实测):不在 FLAG_SPEC 的选项会被静默丢掉,
93
93
  // 于是 `abs log "x" --kind note` 全写成默认的 dev,而 cmdLog 的枚举校验看不见它。
94
94
  'kind': { type: 'string' },
95
+ // auto-task 用(hook 调):--session 作幂等键(一会话一条),--prompt 抽任务关键词。
96
+ // ★ 同上:不在这声明就会被 parseArgv 静默丢掉,登记变成"永远首次"、闸门形同虚设。
97
+ 'session': { type: 'string' },
98
+ 'prompt': { type: 'string' },
95
99
  'by': { type: 'string' },
96
100
  'all': { type: 'boolean' },
97
101
  'keep-days': { type: 'string' },
@@ -527,7 +531,16 @@ async function main() {
527
531
  // 内部命令(hook 专用,不出现在 help):Stop 时机械快照未完成任务
528
532
  case 'wrapup': {
529
533
  rejectExtra(opts._, 'abs wrapup(内部命令, 供 hook 调用)');
530
- console.log(await cmdWrapup({ dir: opts.dir }));
534
+ const r = await cmdWrapup({ dir: opts.dir });
535
+ // 收尾时顺带清理 hook 自动登记的条目(它们不代表人工接管的成果)
536
+ const swept = await cmdAutoTaskSweep({ dir: opts.dir }).catch(() => null);
537
+ console.log([r, swept].filter(Boolean).join('\n') || '(无滞留任务)');
538
+ break;
539
+ }
540
+ // hook 自动登记(pi/opencode 侧调): 一会话一条,同会话只更新断点
541
+ case 'auto-task': {
542
+ const r = await cmdAutoTask({ dir: opts.dir, session: opts.session, prompt: opts.prompt, note: opts.note });
543
+ if (r) console.log(r); // 无事发生则静默(不制造空动作噪音)
531
544
  break;
532
545
  }
533
546
  // Stop hook 用: 判定是否注入收尾指令。stdout 给 shell (`push:<json>` / `{}`), 无额外输出。
@@ -3,6 +3,10 @@
3
3
  * 纯触发: 生命周期事件 → 技术日志一行 (~/.abs/log/hooks.log, ABS_LOG_DIR 可覆盖)。fire-and-forget。
4
4
  * 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
5
5
  * Op​encode 事件: session.created / session.idle / session.deleted (idle = 每轮结束, 对应 pi 的 agent_end)。
6
+ * Opencode v2 API: 默认导出 { id, setup }, setup(api) 里用 api.event.subscribe 订阅。
7
+ * (v1 的 { id, server } + return { event } 在 v2 已移除, 会让插件加载失败报
8
+ * "Plugin failed", 见 concept host-plugin-silent-failure。本文件与
9
+ * ~/.config/opencode/plugins/abs.ts 必须同改, 否则下次 abs install 覆盖回去。)
6
10
  * 收尾注入已删除(2026-09-18):曾用 session.idle + promptAsync 推一条收尾提醒, 用户实报
7
11
  * 「每次干活干一会就出来, 任务就中断了」。根因同 pi 侧: 主动往对话里插 turn = 打断,
8
12
  * 不是频率问题(2026-09-15 删登记注入时已得出同一结论)。保留 session.idle:seen 埋点。
@@ -10,32 +14,32 @@
10
14
  import { appendFile, mkdir, stat } from "node:fs/promises"
11
15
  import { homedir } from "node:os"
12
16
  import { join } from "node:path"
13
- const server = async ({ directory }) => {
17
+ // 定位当前项目的 .brain/:只看 cwd 本身,不向上搜索(防爬到家目录图谱)
18
+ async function findBrain(cwd) {
19
+ const d = cwd || process.cwd()
20
+ try { const st = await stat(join(d, ".brain")); return st.isDirectory() ? join(d, ".brain") : null } catch { return null }
21
+ }
22
+
23
+ const setup = async (api) => {
14
24
  // 本会话首次 idle 是否已埋点
15
25
  let idleSeen = false
16
- async function logHook(evt) {
17
- try {
18
- const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
19
- const d = new Date()
20
- const pad = (n) => String(n).padStart(2, "0")
21
- const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
22
- await mkdir(dir, { recursive: true })
23
- await appendFile(join(dir, "hooks.log"), "[" + stamp + "] opencode:" + evt + "\n")
24
- } catch {} // fire-and-forget: 永不阻塞宿主
25
- }
26
+ async function logHook(evt) {
27
+ try {
28
+ const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
29
+ const d = new Date()
30
+ const pad = (n) => String(n).padStart(2, "0")
31
+ const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
32
+ await mkdir(dir, { recursive: true })
33
+ await appendFile(join(dir, "hooks.log"), "[" + stamp + "] opencode:" + evt + "\n")
34
+ } catch {} // fire-and-forget: 永不阻塞宿主
35
+ }
26
36
 
27
- // 定位当前项目的 .brain/:只看 cwd 本身,不向上搜索(防爬到家目录图谱)
28
- async function findBrain(cwd) {
29
- const d = cwd || process.cwd()
30
- try { const st = await stat(join(d, ".brain")); return st.isDirectory() ? join(d, ".brain") : null } catch { return null }
31
- }
32
-
33
- // 收尾注入已停用(2026-09-18):promptAsync 会在每轮 idle 抢一个 turn,用户实报
34
- // 「每次干活干一会就出来, 任务就中断了」。wroteFiles / nudged / READONLY_CMD /
35
- // tool.execute.after / loggedToday 随注入一起删除(注入没了,它们已无消费方)。
36
- // 保留 session.idle:seen 埋点:区分「事件没触发」与「被守卫拦下」。
37
- return {
38
- event: async ({ event }) => {
37
+ // 收尾注入已停用(2026-09-18):promptAsync 会在每轮 idle 抢一个 turn,用户实报
38
+ // 「每次干活干一会就出来, 任务就中断了」。wroteFiles / nudged / READONLY_CMD /
39
+ // tool.execute.after / loggedToday 随注入一起删除(注入没了,它们已无消费方)。
40
+ // 保留 session.idle:seen 埋点:区分「事件没触发」与「被守卫拦下」。
41
+ api.event.subscribe(async (event) => {
42
+ try {
39
43
  const type = event && event.type
40
44
  if (!type) return
41
45
  if (type === "session.created" || type === "session.deleted") {
@@ -49,19 +53,19 @@ const server = async ({ directory }) => {
49
53
  return
50
54
  }
51
55
  if (type !== "session.idle") return // idle = 每轮结束, 不记日志(太吵), 只做可观测性埋点
52
- const cwd = directory || process.cwd()
56
+ const cwd = api.location?.directory || process.cwd()
53
57
  const brain = await findBrain(cwd)
54
58
  // 可观测性: 首次 idle 留一行痕(否则无法区分“事件没触发”与“被守卫拦下”)
55
59
  if (!idleSeen) {
56
60
  idleSeen = true
57
61
  await logHook(`session.idle:seen cwd=${cwd} brain=${brain || 'none'}`)
58
62
  }
59
- },
60
- }
63
+ } catch {} // 订阅回调抛错会污染宿主, 一律吞掉
64
+ })
61
65
  }
62
66
 
63
67
  export default {
64
68
  id: "abs",
65
- server,
69
+ setup,
66
70
  }
67
71
  @@MARK@@
package/hooks/abs.pi.ts CHANGED
@@ -18,7 +18,9 @@ import { homedir } from "node:os"
18
18
  import { join } from "node:path"
19
19
  import { spawn } from "node:child_process"
20
20
 
21
- const ABS_BIN = "@@ABS_BIN@@"
21
+ // abs CLI 路径。安装时替换为 stableBinPath(全局优先),
22
+ // 但允许 ABS_BIN_PATH 覆盖 —— 测试需隔离到沙盒副本,开发机也可能同时有多份。
23
+ const ABS_BIN = process.env.ABS_BIN_PATH || "@@ABS_BIN@@"
22
24
 
23
25
  async function logHook(evt: string): Promise<void> {
24
26
  const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
@@ -32,6 +34,51 @@ async function logHook(evt: string): Promise<void> {
32
34
 
33
35
  // 会话结束时快照当前项目滞留任务到 wrapup.log(detached fire-and-forget,wrapup 自身幂等去重不刷屏)。
34
36
  // cwd 用事件 ctx.cwd(当前项目),让 abs 在该目录定位 .brain/。
37
+
38
+ /** 会话 id(作自动登记的幂等键,一会话一条)。取不到返回空串 —— 那时不登记,
39
+ * 因为空键会让每条指令都"首次",看板被刷爆。 */
40
+ function sessionIdFor(ctx: any): string {
41
+ try {
42
+ const id = ctx?.sessionManager?.getSessionId?.()
43
+ return String(id || '').replace(/[^\w-]/g, '').slice(0, 16)
44
+ } catch { return '' }
45
+ }
46
+
47
+ /** 调 abs auto-task(hook 自动登记)。detached fire-and-forget,不阻塞 turn。
48
+ *
49
+ * 为何走 spawn 而非 import:与 snapshotWrapup 同源 —— 扩展进程里不能阻塞用户 turn,
50
+ * 且 abs 的写入要经过它自己的文件锁(跨进程锁才有效)。 */
51
+ function spawnAutoTask(cwd: string, args: string[]): void {
52
+ try {
53
+ const child = spawn(process.execPath, [ABS_BIN, "auto-task", ...args], {
54
+ cwd: cwd || process.cwd(), stdio: "ignore", detached: true,
55
+ })
56
+ child.unref()
57
+ } catch {} // fire-and-forget: 登记失败不得影响宿主
58
+ }
59
+
60
+ /** 会改文件的工具名。用于判定「真的开始干活了」—— 排查类会话先跑一堆读命令,
61
+ * 若用「第一次 tool_call」当判据,断点会被写在与任务无关的文件上。
62
+ *
63
+ * 为何含 bash:大量修改是经 bash 完成的(heredoc / sed -i / > 重定向),
64
+ * 漏掉它 = 纯 bash 会话永远不记断点(见 concept opencode-inject-channel-verdict
65
+ * 的同源教训:白名单必须按宿主实际用法穷举,漏一个就把功能静默关掉)。
66
+ */
67
+ const WRITE_TOOL = /^(write|edit|apply_patch|patch|multiedit|notebookedit|bash|shell)$/i
68
+
69
+ /** bash 命令里没有写入迹象则不算「干活」—— 否则 `ls`/`git status` 也会记断点。 */
70
+ const BASH_WRITES = /(^|[^\w])(>|>>|sed\s+-i|tee\b|mkdir\b|rm\b|mv\b|cp\b|touch\b|cat\s*>)/
71
+
72
+ /** 这次 tool_call 是否算「真的动了文件」。 */
73
+ function isWriteCall(name: string, input: any): boolean {
74
+ if (!WRITE_TOOL.test(String(name || ''))) return false
75
+ // bash 要额外甄别:只有带写入操作的命令才算
76
+ if (/^(bash|shell)$/i.test(String(name || ''))) {
77
+ return BASH_WRITES.test(String(input?.command ?? ''))
78
+ }
79
+ return true
80
+ }
81
+
35
82
  function snapshotWrapup(cwd: string): void {
36
83
  try {
37
84
  const child = spawn(process.execPath, [ABS_BIN, "wrapup"], {
@@ -61,6 +108,18 @@ let agentEndSeen = false
61
108
  * 同样在模块级,理由同上(闭包 flag 在重复注册时不共享)。 */
62
109
  let pendingTodoTool = false
63
110
 
111
+ /** 本会话是否已记过自动任务断点。模块级(重复注册时闭包 flag 不共享 —— 同 agentEndSeen 的坑)。
112
+ * 只记第一次写文件,不是每次都记:断点是「记到哪」不是流水账。 */
113
+ let autoTaskSeen = false
114
+
115
+ /** 本会话最后被写到的文件(agent_end 落断点用)。
116
+ * 只存文件名,不存内容 —— 断点是"记到哪",不是快照。 */
117
+ let lastTouchedFile = ''
118
+
119
+ /** 最近一次渲染拿到的 tui 引用。hidePanel 用它请求重绘 —— 面板消失时动画
120
+ * 定时器已停,没有别的重绘通道(见 hidePanel 的坑注解)。 */
121
+ let lastTui: any = null
122
+
64
123
  /** 会话边界重置埋点状态。
65
124
  * 不重置 → 同进程第二个会话继承 true 永久不留痕(2026-09-13 实测过的坑)。 */
66
125
  function resetThrottle(): void {
@@ -502,15 +561,28 @@ export function renderPanelLines(
502
561
  if (data.hidden > 0) lines.push(clipToWidth(fg('dim', ` +${data.hidden} more`), width))
503
562
  return lines
504
563
  }
564
+ /** 隐藏面板。**必须主动 requestRender** —— 否则数据清了、屏幕还留着旧画面。
565
+ *
566
+ * 坑(2026-10-05 用户实报): 任务全标完后面板不消失, 按 ESC 或重启才正常。
567
+ * 根因: 唯一的重绘入口是动画定时器里的 requestRender, 而"任务全完成"恰好让
568
+ * needAnim=false → stopAnimTimer() → 从此没有任何人请求重绘 → 屏幕冻结在旧帧。
569
+ * 即"消失"这个动作本身把它自己的重绘通道关掉了。
570
+ * 故消失路径要自己 requestRender 一次。 */
571
+ function hidePanel(ui: any): void {
572
+ try { ui.setWidget(PANEL_KEY, undefined) } catch {}
573
+ stopAnimTimer()
574
+ try { lastTui?.requestRender?.() } catch {}
575
+ }
576
+
505
577
  async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
506
578
  if (String(process.env.ABS_TODO_PANEL || '') === '0') return
507
579
  if (!ui || typeof ui.setWidget !== 'function') return
508
580
  try {
509
- if (!(await hasBrain(cwd))) { ui.setWidget(PANEL_KEY, undefined); stopAnimTimer(); return }
581
+ if (!(await hasBrain(cwd))) { hidePanel(ui); return }
510
582
  const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
511
583
  const who = await currentUser()
512
584
  const data = parseOpenTasks(md, PANEL_MAX_ROWS, who)
513
- if (data.total === 0) { ui.setWidget(PANEL_KEY, undefined); stopAnimTimer(); return }
585
+ if (data.total === 0) { hidePanel(ui); return }
514
586
  // factory 形式:render(width) 每帧拿真实宽度 → 按宽度截断,不再断字。
515
587
  // 动画:phase 存在模块级,render 时读当前值;定时器只在本帧重绘,不重建 widget。
516
588
  let tuiRef: any = null
@@ -518,6 +590,7 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
518
590
  PANEL_KEY,
519
591
  (tui: any, theme: any) => {
520
592
  tuiRef = tui
593
+ lastTui = tui // 供 hidePanel 在"无动画"时也能请求重绘
521
594
  return {
522
595
  render: (width: number) =>
523
596
  renderPanelLines(
@@ -538,8 +611,7 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
538
611
  if (needAnim) startAnimTimer(() => tuiRef)
539
612
  else stopAnimTimer()
540
613
  } catch {
541
- try { ui.setWidget(PANEL_KEY, undefined) } catch {}
542
- stopAnimTimer()
614
+ hidePanel(ui)
543
615
  }
544
616
  }
545
617
 
@@ -548,6 +620,9 @@ export default function absPiHook(pi: ExtensionAPI): void {
548
620
  resetThrottle()
549
621
 
550
622
  pi.on("session_start", (event: any, ctx: any) => {
623
+ // 新会话 = 重新登记:不重置则同进程的第二个会话永远不再落断点
624
+ autoTaskSeen = false
625
+ lastTouchedFile = ''
551
626
  resetThrottle()
552
627
  // 本会话的随机昵称 —— 在这里抽一次(不是每帧抽,否则面板会疯狂闪)。
553
628
  // 每次 session_start(startup/reload/new/resume/fork)重抽 → “每次打开 pi 都是随机的”。
@@ -574,7 +649,7 @@ export default function absPiHook(pi: ExtensionAPI): void {
574
649
  // 故:tool_call 认出“刚才调的是 todo 工具”→ 置标记;tool_execution_end 看到标记就刷 → 清标记。
575
650
  // 始终保留 turn_end 兜底(实测每条 assistant 消息都触发,约 4 秒一次)。
576
651
  // 标记在模块级(重复注册时闭包 flag 不共享 —— 见 agentEndSeen 的注解)。
577
- pi.on("tool_call", (event: any) => {
652
+ pi.on("tool_call", (event: any, ctx: any) => {
578
653
  const name = String(event?.toolName || '')
579
654
  const input = event?.input || {}
580
655
  // 两种形态:① MCP 代理入口 mcp__abs + input.tool=abs_task;② 直接工具名 abs_task
@@ -583,6 +658,18 @@ export default function absPiHook(pi: ExtensionAPI): void {
583
658
  pendingTodoTool = true
584
659
  logHook(`tool_call todo_tool name=${name} target=${target || '-'}`).catch(() => {})
585
660
  }
661
+ // 自动登记断点(2026-10-05):真的动文件时,把「改到哪个」记进本会话的自动任务。
662
+ // 为何不用「第一次 tool_call」:排查类会话先跑一堆读命令,断点会落在无关文件上。
663
+ if (!autoTaskSeen && isWriteCall(name, input)) {
664
+ autoTaskSeen = true
665
+ const cwd = (ctx && ctx.cwd) || process.cwd()
666
+ const file = String(input?.path ?? input?.file_path ?? input?.filePath ?? '')
667
+ const bp = file ? `改了 ${file.slice(0, 120)}` : '开始改文件'
668
+ if (file) lastTouchedFile = file.slice(0, 120)
669
+ const sid = sessionIdFor(ctx)
670
+ if (sid) spawnAutoTask(cwd, ['--session', sid, '--note', bp])
671
+ logHook(`tool_call auto_task name=${name} sid=${sid || '-'}`).catch(() => {})
672
+ }
586
673
  })
587
674
 
588
675
  pi.on("tool_execution_end", (_event: any, ctx: any) => {
@@ -612,6 +699,15 @@ export default function absPiHook(pi: ExtensionAPI): void {
612
699
  }
613
700
  const ok = injectTodoGuidelines(event?.systemPromptOptions)
614
701
  logHook(`before_agent_start todo_guide=${ok ? 'on' : 'off'} cwd=${cwd}`).catch(() => {})
702
+
703
+ // 自动登记(2026-10-05):有图谱 + 指令有实质内容 → 先把任务落到看板上。
704
+ // 抽不出关键词("在吗"/"继续")时 abs 侧会静默跳过 —— 宁可不登记也不脏看板。
705
+ // 一会话一条:同会话后续的 before_agent_start 会命中已有条目,只更新断点。
706
+ // ★ 位置必须在 no_brain 分支【之后】—— 放到里面等于"只有没图谱时才登记",逻辑全反。
707
+ const sid = sessionIdFor(ctx)
708
+ if (sid && event?.prompt) {
709
+ spawnAutoTask(cwd, ['--session', sid, '--prompt', String(event.prompt).slice(0, 200)])
710
+ }
615
711
  } catch (e: any) {
616
712
  logHook(`before_agent_start error=${e?.message || 'unknown'}`).catch(() => {})
617
713
  }
@@ -626,11 +722,23 @@ export default function absPiHook(pi: ExtensionAPI): void {
626
722
  // 每项目弹,都还是在打断用户。故不再有 teardownNudged/inFlight/loggedToday 判据。
627
723
  // 只保留 agent_end:seen 埋点:区分「事件没触发」与「被守卫拦下」。
628
724
  pi.on("agent_end", async (_event: any, ctx: any) => {
629
- if (agentEndSeen) return
630
- agentEndSeen = true
631
725
  const cwd = (ctx && ctx.cwd) || process.cwd()
632
726
  const brain = await findBrain(cwd)
633
- await logHook(`agent_end:seen cwd=${cwd} brain=${brain || 'none'}`).catch(() => {})
727
+ // 可观测性: 每会话首次留一行痕(区分"事件没触发"与"被守卫拦下")。
728
+ if (!agentEndSeen) {
729
+ agentEndSeen = true
730
+ await logHook(`agent_end:seen cwd=${cwd} brain=${brain || 'none'}`).catch(() => {})
731
+ }
732
+ // 每轮结束落一次断点(2026-10-05): 本会话若真动过文件, 把"干到哪"推进到最新。
733
+ // 为何只在动过文件后: 纯对话/查询轮不需要推进断点, 否则白 spawn 一堆进程。
734
+ // 为何不放在 session_shutdown: 那是进程退出, 而"一轮干完"才是自然的落盘点 ——
735
+ // 用户可能一直开着 pi 跨很多轮, 断点必须活着的时候就已经是最新的。
736
+ if (!brain || !autoTaskSeen) return
737
+ const sid = sessionIdFor(ctx)
738
+ if (!sid) return
739
+ const last = lastTouchedFile
740
+ spawnAutoTask(cwd, ['--session', sid, '--note', last ? `本轮到 ${last}` : '本轮有改动'])
741
+ logHook(`agent_end auto_task breakpoint sid=${sid}`).catch(() => {})
634
742
  })
635
743
 
636
744
  pi.on("session_shutdown", (_e: any, ctx: any) => {
@@ -640,4 +748,4 @@ export default function absPiHook(pi: ExtensionAPI): void {
640
748
  })
641
749
  }
642
750
 
643
- @@MARK@@
751
+ @@MARK@@
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.13.2",
3
+ "version": "1.14.0",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: abs-agent-brain-sync
3
- description: abs (agent-brain-sync) 跨会话记忆与任务续接。适用于「开工续接状态」「沉淀会话收获」「任务登记不丢」「经验落成知识页」「图谱体检」等任务。
3
+ description: "USE FOR abs / agent-brain-sync 跨会话记忆与任务续接。触发词:用户直接输入 abs(单独一个词,或 abs + 命令,如 'abs 开工'、'abs 沉淀');以及「开工续接状态」「沉淀会话收获」「任务登记不丢」「经验落成知识页」「图谱体检」等任务。"
4
4
  ---
5
5
 
6
6
  # abs — 跨会话记忆 (agent-brain-sync)
@@ -9,6 +9,8 @@ description: abs (agent-brain-sync) 跨会话记忆与任务续接。适用于
9
9
 
10
10
  **关键词不是触发器,意图才是。**句子里出现 abs 词(收尾/todo/沉淀/load/log/note)不等于要执行 abs 动作:谈论、提问、吐槽、定规则("太啰嗦""这个设计怎样")**只回应,不执行动作**;明确让我做事才执行。
11
11
 
12
+ **例外(唯一):用户直接输入 `abs`** —— 单独一个词、或 `abs` 后跟命令/意图(如 `abs`、`abs 开工`、`abs 沉淀`、`abs load`)。这是明确的"我要用 abs",不算关键词误触发,**直接按 abs 流程执行**(无子命令时默认走开工续接)。
13
+
12
14
  - 拿不准时**先问**。规则变更("以后简短点")写入本文,不执行动作。
13
15
  - 输出简短:收尾/提示/todo 汇报一两句。本总则适用于所有工具。
14
16
 
package/src/store.js CHANGED
@@ -1162,6 +1162,127 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
1162
1162
  throw new Error(`unknown task action: ${action}`);
1163
1163
  }
1164
1164
 
1165
+ // ---------- 自动登记(hook 专用,2026-10-05) ----------
1166
+ // 背景: pi 的 promptGuidelines 常驻指引已注入(实检 414 次有痕), 但行为没变 ——
1167
+ // 指引假设 agent 已决定"要开一个任务", 而排查类工作没有清晰起点("我先看看怎么回事"
1168
+ // 看明白了活儿已经干完一半)。故改为 hook 侧自动登记, 不靠 agent 自觉。
1169
+ //
1170
+ // 粒度: **一会话一条**(幂等键 = 会话 id), 后续改动只更新断点 —— 不是一文件一条。
1171
+ // 否则一个会话改 20 个文件就塞 20 条, 看板被垃圾淹没(违"读取侧不得随规模增长")。
1172
+ const AUTO_TASK_PREFIX = 'auto-';
1173
+
1174
+ /** 从用户指令抽任务关键词。抽不到返回 null —— 宁可不登记, 也不脏看板。
1175
+ *
1176
+ * 为何不抽不到就用机器 id: 看板是给人看的, `auto-c3f1` 认不出是什么;
1177
+ * "在吗"/"继续"/"abs" 这类纯对话开头抽出的是噪声, 登了反而增加负担。
1178
+ */
1179
+ export function autoTaskId(text) {
1180
+ const s = String(text || '').replace(/[\u200b\u200c\u200d\ufeff]/g, '').trim();
1181
+ if (!s) return null;
1182
+ // 纯对话/寒暄不算指令
1183
+ if (/^(在吗|在不在|你好|hi|hello|ok|好的|收到|继续|谢了|谢谢|abs|嗯|对|是的|行)[\s!!。.~]*$/i.test(s)) return null;
1184
+ // 疑问句不算开工指令(2026-10-05 实报): 用户问"为什么任务是这个啊"被登记成了任务 ——
1185
+ // 提问/质疑/求助不是"让我动手",登记它等于把用户的疑问变成待办,很荒唐。
1186
+ // 判据用两档: ①句中有疑问词 ②句尾是问号(含全角)。
1187
+ if (/[??]\s*$/.test(s)) return null;
1188
+ if (/(为什么|为啥|怎么会|怎么|如何|什么是|是什么|哪里|哪个|能不能|可不可以|是否|吗\s*[??。!!]?$|呢\s*[??。!!]?$)/.test(s)) return null;
1189
+ // 取首个意群(标点/换行切)作为 id 来源
1190
+ const head = s.split(/[\n。!?!?;;,,]/)[0].trim();
1191
+ if (head.length < 4) return null;
1192
+ // 中文取字,英文/数字取词;保留可读性,限长
1193
+ const words = head.match(/[A-Za-z0-9_.-]+/g) || [];
1194
+ const cjk = head.replace(/[A-Za-z0-9_.\-\s]+/g, '').slice(0, 12);
1195
+ const slug = (words.length && cjk.length < 2 ? words.slice(0, 4).join('-') : cjk)
1196
+ .replace(/[^\w\u4e00-\u9fa5-]/g, '')
1197
+ .slice(0, 24);
1198
+ return slug.length >= 2 ? slug : null;
1199
+ }
1200
+
1201
+ /** hook 自动登记: 一会话一条 + 断点更新。
1202
+ *
1203
+ * @param {object} o
1204
+ * @param {string} o.dir 项目目录
1205
+ * @param {string} o.session 会话 id(幂等键)
1206
+ * @param {string} o.prompt 用户指令原文(抽 id 用,仅首次需要)
1207
+ * @param {string} o.note 断点内容(后续调用时更新到同一条)
1208
+ * @returns {Promise<string|null>} 动作说明;无事发生返回 null
1209
+ */
1210
+ export async function cmdAutoTask({ dir, session, prompt, note }) {
1211
+ if (String(process.env.ABS_AUTO_TASK || '') === '0') return null;
1212
+ const root = await requireBrain(dir || process.cwd());
1213
+ const sid = String(session || '').replace(/[^\w-]/g, '').slice(0, 16);
1214
+ if (!sid) return null;
1215
+
1216
+ // 幂等键藏在标记里而不是 id 里(2026-10-05 实报): 原先 id = `auto-<会话uuid前16位>`,
1217
+ // 用户看到 `auto-01a10b96-372d-71` 完全认不出是什么。id 是给人看的,改用关键词;
1218
+ // 会话 id 退成机器用的隐藏键(`(自动登记 <sid>)`),sweep 靠它认领自动条目。
1219
+ const MARK = `自动登记 ${sid}`;
1220
+ const todoPath = brainPath(root, 'todo.md');
1221
+
1222
+ // 已登记过 → 只更新断点(一会话一条)
1223
+ let existingId = null;
1224
+ await editFile(todoPath, (cur) => {
1225
+ if (cur === null) return SKIP;
1226
+ for (const l of cur.split('\n')) {
1227
+ const id = idOfTaskLine(l);
1228
+ if (id && id.startsWith(AUTO_TASK_PREFIX) && l.includes(MARK)) { existingId = id; break; }
1229
+ }
1230
+ return SKIP;
1231
+ });
1232
+ if (existingId) {
1233
+ if (!note) return null;
1234
+ const r = await setBreakpoint(root, { id: existingId, text: note });
1235
+ return r?.changed ? `↳ ${existingId} 断点更新` : null;
1236
+ }
1237
+
1238
+ // 首次: 抽不出关键词就不登记
1239
+ const slug = autoTaskId(prompt);
1240
+ if (!slug) return null;
1241
+ // id = 关键词(可读);同 slug 已存在则加序号,避免两条不同活撞同一个 id。
1242
+ let id = `${AUTO_TASK_PREFIX}${slug}`;
1243
+ let n = 1;
1244
+ await editFile(todoPath, (cur) => {
1245
+ if (cur === null) return SKIP;
1246
+ const ids = new Set(cur.split('\n').map((l) => idOfTaskLine(l)).filter(Boolean));
1247
+ while (ids.has(id)) id = `${AUTO_TASK_PREFIX}${slug}-${++n}`;
1248
+ return SKIP;
1249
+ });
1250
+ const who = await requireUser();
1251
+ await ensurePersonPage(root, who);
1252
+ await upsertTask(root, {
1253
+ section: SEC.todo,
1254
+ text: `[进行中] ${id} ${atTag(who)} — ${slug} (${MARK})`,
1255
+ });
1256
+ if (note) await setBreakpoint(root, { id, text: note });
1257
+ return `✓ 自动登记 ${id} — ${slug}`;
1258
+ }
1259
+
1260
+ /** 收尾: 把自动登记的未完成任务标 done(用户没显式接管它们)。
1261
+ * 结语用【仅方案】—— 自动条目本身不代表"成果落地",不该冒充【落地】。 */
1262
+ export async function cmdAutoTaskSweep({ dir }) {
1263
+ const root = await requireBrain(dir || process.cwd());
1264
+ const todoPath = brainPath(root, 'todo.md');
1265
+ const ids = [];
1266
+ await editFile(todoPath, (cur) => {
1267
+ if (cur === null) return SKIP;
1268
+ for (const l of cur.split('\n')) {
1269
+ const id = idOfTaskLine(l);
1270
+ // 自动条目的判据是「自动登记」标记, 不是 id 前缀 —— id 现在是给人看的关键词,
1271
+ // `auto-fix-panel` 这种名字人工也可能起得出来, 靠前缀认领会误杀真任务。
1272
+ if (id && /自动登记\s+\S+/.test(l)) ids.push(id);
1273
+ }
1274
+ return SKIP;
1275
+ });
1276
+ let n = 0;
1277
+ for (const id of ids) {
1278
+ try {
1279
+ await markDone(todoPath, id, '仅方案', '自动登记未接管,收尾清理');
1280
+ n++;
1281
+ } catch { /* 单条失败不阻塞其余 */ }
1282
+ }
1283
+ return n ? `✓ 自动任务收尾 ${n} 条` : null;
1284
+ }
1285
+
1165
1286
  async function markDone(file, id, kind = '落地', conclusion) {
1166
1287
  const res = await editFile(file, (text) => {
1167
1288
  const lines = text.split('\n');