@fanchao8609/agent_brain_sync 1.16.3 → 1.17.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/bin/abs.js CHANGED
@@ -96,6 +96,9 @@ const FLAG_SPEC = {
96
96
  'section': { type: 'string' },
97
97
  'note': { type: 'string' },
98
98
  'as': { type: 'string' },
99
+ // todo start 用:本会话 id(LLM 自己给,hook 会把当前 sid 摆进 system prompt)。
100
+ // ★ 必须在这声明 —— 未声明的选项会被静默丢掉(本文件自己的注释写过这个坑)。
101
+ 'session': { type: 'string' },
99
102
  'payload': { type: 'string' },
100
103
  'tags': { type: 'string' },
101
104
  'port': { type: 'string' },
@@ -138,7 +141,7 @@ function parseArgv(args) {
138
141
  // 也不能简单地把所有非 `--` token 剔走 —— 那样 `--dir /x` 的值 `/x` 会被误剔。
139
142
  // 解法: 先用 tokens 看清每个 token 的 kind,只把「`--` 开头且 name 在 FLAG_SPEC 里」当真选项,
140
143
  // 其余(包括 `-x` 与 `--unknown`)一律按原序交回位置参数,复刻旧手写逻辑。
141
- const { values: rawValues, tokens } = parseArgs({
144
+ const { tokens } = parseArgs({
142
145
  args,
143
146
  options: FLAG_SPEC,
144
147
  allowPositionals: true,
@@ -476,12 +479,12 @@ async function main() {
476
479
  const usage = `abs todo ${sub} <id>${isDone ? ' [--as 落地|否决|仅方案] [--note "结语"]' : ' --note "…"'}`;
477
480
  if (isDone) {
478
481
  const note = opts.note || (rest2.length ? rest2.join(' ') : undefined);
479
- console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note, as: opts.as }));
482
+ console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note, as: opts.as, session: opts.session }));
480
483
  break;
481
484
  }
482
485
  rejectExtra(rest2, usage);
483
486
  if (!id) throw new Error(`✗ 缺 <id>\n 用法: ${usage}`);
484
- console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note, as: opts.as }));
487
+ console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note, as: opts.as, session: opts.session }));
485
488
  break;
486
489
  }
487
490
  case 'note': {
package/bin/mcp.js CHANGED
@@ -112,20 +112,25 @@ tool(
112
112
 
113
113
  tool(
114
114
  'abs_task',
115
- '任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补断点(改到哪文件哪行) / state 改状态 / done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id。',
115
+ '任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补断点(改到哪文件哪行) / state 改状态 / done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id。多会话并行时,start/add 带上 session(值见 system prompt 的 [会话] 段)以标记「这条任务谁在做」。',
116
116
  {
117
117
  action: z.enum(['add', 'start', 'done', 'note', 'state']),
118
118
  id: z.string().describe('任务幂等键,如 TASK-xxx 或子任务名'),
119
119
  cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
120
120
  note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步); state=进行中|讨论中|滞留中|搁置(搁置=用户改方向/不做了;滞留中=还要做只是卡住); done=结语文字'),
121
121
  as: z.enum(['落地', '否决', '仅方案']).optional().describe('仅 done:结语类型。默认 落地。做了又撤/评估后不做用 否决,只设计过用 仅方案 —— 别让假【落地】污染看板'),
122
+ // 会话 id:多会话并行时标记「这条任务是谁在做」。
123
+ // 值取自 system prompt 里的 `[会话] 本会话 id = xxx`(hook 注入),由模型原样回传。
124
+ // ★ 为何走这个途径而不让 MCP 自己探测:MCP 进程拿不到宿主的 sessionId
125
+ // (协议没有,PI_* 环境变量也没有);模型本来就知道自己的 sid,直接收下最省。
126
+ session: z.string().optional().describe('仅 start/add:本会话 id(取自 system prompt 的 [会话] 段)。多会话并行时用来标记「这条任务谁在做」'),
122
127
  },
123
- async ({ action, id, cwd, note, as }) => {
128
+ async ({ action, id, cwd, note, as, session }) => {
124
129
  const root = await findBrainRoot(cwd || process.cwd());
125
130
  if (!root) return errNoBrain(cwd || process.cwd());
126
131
  // add 是 start 的别名(与 CLI `abs todo add` 对齐)
127
132
  const act = action === 'add' ? 'start' : action;
128
- return { content: [{ type: 'text', text: await cmdTask({ dir: root, action: act, id, note, as }) }] };
133
+ return { content: [{ type: 'text', text: await cmdTask({ dir: root, action: act, id, note, as, session }) }] };
129
134
  }
130
135
  );
131
136
 
package/hooks/abs.pi.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * 别再加回来(指 sendUserMessage 注入;静态 system prompt 内容不在此列,见下方 todo 指引)。
20
20
  */
21
21
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
22
- import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
22
+ import { appendFile, mkdir, readFile, rename, stat } from "node:fs/promises"
23
23
  import { homedir } from "node:os"
24
24
  import { join } from "node:path"
25
25
  import { spawn } from "node:child_process"
@@ -28,14 +28,58 @@ import { spawn } from "node:child_process"
28
28
  // 但允许 ABS_BIN_PATH 覆盖 —— 测试需隔离到沙盒副本,开发机也可能同时有多份。
29
29
  const ABS_BIN = process.env.ABS_BIN_PATH || "@@ABS_BIN@@"
30
30
 
31
+ /** 日志写入串行队列。
32
+ *
33
+ * 为何需要(2026-10-07 实测):logHook 是 fire-and-forget 且会并发调用
34
+ * (一次 session_start 就调 2-3 次:nickname / panel drawn / panel skipped)。
35
+ * 而「检查大小 → 重命名 → 写入」跨 await,两个并发调用会交错:
36
+ * A: stat(1MiB) 超限 → rename(旧 → .1)
37
+ * B: stat 看到的是 A 刚建的小文件 → 不轮转
38
+ * 反过来则是 B 把 A 刚写好的新文件又移成 .1,**盖掉真正的旧内容**。
39
+ * 实测症状:.1 里是新日志(百来字节),而 1MiB 的旧内容消失。
40
+ * 同族坑见 .brain/concepts/guard-check-then-set-across-await.md。
41
+ *
42
+ * 修法:把整段「轮转 + 写入」排进一个 promise 链,同一时刻只跑一个。
43
+ * 日志量小、写入快,串行的代价可忽略;换掉的是「旧日志被静默吃掉」。 */
44
+ let logChain: Promise<void> = Promise.resolve()
45
+
31
46
  async function logHook(evt: string): Promise<void> {
32
47
  const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
33
48
  const d = new Date()
34
49
  const pad = (n: number) => String(n).padStart(2, "0")
35
50
  const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
36
51
  const line = "[" + stamp + "] pi:" + evt + "\n"
37
- await mkdir(dir, { recursive: true })
38
- await appendFile(join(dir, "hooks.log"), line)
52
+ // 排队:前一步出错也不断链(catch 掉再继续),否则后续日志全丢。
53
+ logChain = logChain
54
+ .catch(() => {})
55
+ .then(async () => {
56
+ await mkdir(dir, { recursive: true })
57
+ const file = join(dir, "hooks.log")
58
+ await rotateIfTooBig(file)
59
+ await appendFile(file, line)
60
+ })
61
+ return logChain
62
+ }
63
+
64
+ /** 日志轮转:hooks.log 是 append-only 热路径,无上限会无限长。
65
+ *
66
+ * 为何需要(2026-10-07 实测):本扩展原先只 append、**没有轮转**,
67
+ * 而轮转只写在 hooks/event.sh 里(那是 Claude Code / Codex 走的路)。
68
+ * 后果:pi 这条路上 hooks.log 无上限增长 —— 实测已 1.22MB,
69
+ * 而同一套阈值(1 MiB)在 event.sh 那边是生效的。
70
+ * 同族坑:靠一个副本里的逻辑保护另一条路径,等于没保护。
71
+ *
72
+ * 阈值与策略对齐 event.sh:ABS_LOG_MAX_BYTES 可覆盖;只留一份 .1。
73
+ * 失败静默:轮转出任何错都不能弄坏日志写入本身(与 event.sh 同纪律)。 */
74
+ async function rotateIfTooBig(file: string): Promise<void> {
75
+ try {
76
+ const max = Number(process.env.ABS_LOG_MAX_BYTES || 1048576) // 1 MiB
77
+ const st = await stat(file)
78
+ if (!(max > 0) || st.size <= max) return
79
+ await rename(file, file + ".1")
80
+ } catch {
81
+ // 首次运行(文件不存在)/ 权限不足 / 重命名失败:都不拦日志写入
82
+ }
39
83
  }
40
84
 
41
85
  // 会话结束时快照当前项目滞留任务到 wrapup.log(detached fire-and-forget,wrapup 自身幂等去重不刷屏)。
@@ -278,7 +322,7 @@ const BOARD_MARK = '[看板] '
278
322
  * 仍然静默的情形:无图谱/读失败(readBoardForGuide 捕不到 todo.md);
279
323
  * **以及看板里全是别人的任务**(那不是空,是「这看板不是我在用」——
280
324
  * 摆个「(空)」会误导成「我可以去登记」,跟真的空看板不是一回事)。 */
281
- export function boardGuideline(md: string, who: string, touched: string[] = []): string | null {
325
+ export function boardGuideline(md: string, who: string, touched: string[] = [], mySid = ''): string | null {
282
326
  const { total, rows } = parseOpenTasks(md, PANEL_MAX_ROWS, who)
283
327
  // 看板里有没有**任何**未完成条目(不分作者)。用来区分两种“过滤后为空”:
284
328
  // 没有 → 真的空看板(摆「空」这个事实 + 它是异常信号)
@@ -287,7 +331,11 @@ export function boardGuideline(md: string, who: string, touched: string[] = []):
287
331
  if (!total && anyOpen) return null
288
332
  const lines = rows.map((r) => {
289
333
  // desc 为空时不拖空破折号(MCP 登记只给 id 的常见情形)。
290
- const head = ` ${r.state ? `[${r.state}] ` : ''}${r.id}${r.desc ? ` — ${r.desc}` : ''}`
334
+ // ★ 会话归属标在行首:多会话并行时这是「这条能不能动」的唯一依据。
335
+ // 只摆事实(mine / 无 / 别的 sid),不写「别抢」那类劝告。
336
+ let tag = ''
337
+ if (r.sid) tag = r.sid === mySid ? '[本会话] ' : `[会话 ${r.sid}] `
338
+ const head = ` ${tag}${r.state ? `[${r.state}] ` : ''}${r.id}${r.desc ? ` — ${r.desc}` : ''}`
291
339
  // 断点必须带上:它就是「改到哪一步」,丢了等于对齐了也接不上。
292
340
  return r.note ? `${head}\n ↳ ${r.note}` : head
293
341
  })
@@ -389,15 +437,38 @@ async function autoInit(cwd: string): Promise<boolean> {
389
437
  }
390
438
 
391
439
  /** 读看板拼成对齐段。失败/无图谱/看板空都返 null(静默降级,绝不阻断对话)。 */
392
- async function readBoardForGuide(cwd: string): Promise<string | null> {
440
+ async function readBoardForGuide(cwd: string, mySid = ''): Promise<string | null> {
393
441
  try {
394
442
  const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
395
- return boardGuideline(md, await currentUser(), [...filesSeen])
443
+ return boardGuideline(md, await currentUser(), [...filesSeen], mySid)
396
444
  } catch {
397
445
  return null
398
446
  }
399
447
  }
400
448
 
449
+ /** 本会话 id 在 guidelines 里的前缀。
450
+ *
451
+ * 为何要摆给 LLM(2026-10-09 用户定):todo 行尾的 `(认领 日期 <sid>)` 需要
452
+ * LLM 自己填 —— 用户原话「sid 让 llm 自己给答案吧」。
453
+ * 所以把本会话的 sid 摆进 system prompt,它登记时就能把这个值传给 `abs todo start --session`。
454
+ * 取不到 sid 时不注入(不写空值、不伪造)。 */
455
+ export const SESSION_MARK = '[会话] '
456
+
457
+ /** 注入本会话 id。每轮替换(值不变但保持与看板同构)。 */
458
+ export function injectSessionGuideline(options: any, sid: string): boolean {
459
+ const list: string[] = options.promptGuidelines || (options.promptGuidelines = [])
460
+ for (let i = list.length - 1; i >= 0; i--) {
461
+ if (list[i].startsWith(SESSION_MARK)) list.splice(i, 1)
462
+ }
463
+ if (!sid) return false
464
+ list.push(
465
+ `${SESSION_MARK}本会话 id = ${sid}。登记任务时带上它 —— ` +
466
+ `MCP 工具:abs_task 的 session 参数;CLI:abs todo start <id> --session ${sid}。` +
467
+ `任务行尾会记成 \`(认领 日期 ${sid})\`,这样多会话并行时能看出哪条是谁在做。`,
468
+ )
469
+ return true
470
+ }
471
+
401
472
  /** 注入 todo 指引。静态 6 条(行为要求) + 实时看板(对齐用)—— 两者职责不同:
402
473
  * 静态条说「该怎么做」,看板说「现在是什么」。 */
403
474
  export function injectTodoGuidelines(options: any, board?: string | null): boolean {
@@ -646,13 +717,13 @@ export function pickNickname(rnd: () => number = Math.random): string {
646
717
  * 断点行(缩进的 ↳)归属到它上一条任务,不单独成条。 */
647
718
  export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
648
719
  total: number
649
- rows: { state: string; id: string; desc: string; note: string }[]
720
+ rows: { state: string; id: string; desc: string; note: string; sid: string }[]
650
721
  hidden: number
651
722
  } {
652
723
  // 两阶段:先无过滤地按序解析(断点归属需要“上一条”是原文里真正的前一条),
653
724
  // 再按作者过滤。若边解析边过滤,被滤掉的条目下方的断点会挂到它上面那一条
654
725
  // —— 实测:别人任务的断点会显示在我的任务下面(2026-10-03 审查发现)。
655
- type Row = { state: string; id: string; desc: string; note: string; authors: string[] }
726
+ type Row = { state: string; id: string; desc: string; note: string; authors: string[]; sid: string }
656
727
  const parsed: Row[] = []
657
728
  let inTodo = false
658
729
  for (const raw of String(md || '').split('\n')) {
@@ -675,17 +746,23 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
675
746
  const body = m[3] || ''
676
747
  const authors = [...body.matchAll(/\[\[([^\]]+)\]\]/g)].map((x) => x[1])
677
748
  // 剥掉作者标记(面板上碍眼且无信息量),再取 `—` 后正文
749
+ // ★ 会话 id 要单独抽出来:它是「这条任务是谁在做」的唯一标识,
750
+ // 多会话并行时靠它区分自己的 / 别人的。不能跟认领日期一起被剥掉
751
+ // (2026-10-09 实测:只剥不抽 → sid 到不了看板,功能实际是断的)。
752
+ // 正则必须**先锚住日期再取后面的 token** —— 用宽松的 `[^)]*?\s(\S+)` 会把
753
+ // 日期本身当成 sid(实测:公海任务被误标成 `[会话 2026-10-09]`)。
754
+ const sid = (body.match(/\(认领\s*\d{4}-\d{2}-\d{2}\s+([\w-]+)\)/) || [])[1] || ''
678
755
  const desc = body
679
756
  .replace(/\[\[[^\]]+\]\]/g, '')
680
757
  .replace(/^\s*—\s*/, '')
681
- .replace(/\s*\(认领\s*\d{4}-\d{2}-\d{2}\)\s*$/, '')
758
+ .replace(/\s*\(认领[^)]*\)\s*$/, '')
682
759
  .trim()
683
- parsed.push({ state, id, desc, note: '', authors })
760
+ parsed.push({ state, id, desc, note: '', authors, sid })
684
761
  }
685
762
  const mine = who
686
763
  ? parsed.filter((r) => r.authors.length === 0 || r.authors.includes(who))
687
764
  : parsed
688
- const rows = mine.map(({ state, id, desc, note }) => ({ state, id, desc, note }))
765
+ const rows = mine.map(({ state, id, desc, note, sid }) => ({ state, id, desc, note, sid }))
689
766
  return { total: rows.length, rows: rows.slice(0, max), hidden: Math.max(0, rows.length - max) }
690
767
  }
691
768
 
@@ -1023,7 +1100,11 @@ export default function absPiHook(pi: ExtensionAPI): void {
1023
1100
  return
1024
1101
  }
1025
1102
  }
1026
- const ok = injectTodoGuidelines(event?.systemPromptOptions, await readBoardForGuide(cwd))
1103
+ const sidNow = sessionIdFor(ctx)
1104
+ const ok = injectTodoGuidelines(event?.systemPromptOptions, await readBoardForGuide(cwd, sidNow))
1105
+ // 本会话 id 也摆进去 —— todo 行尾的 `(认领 日期 <sid>)` 要 LLM 自己填。
1106
+ injectSessionGuideline(event?.systemPromptOptions, sidNow)
1107
+ logHook(`before_agent_start session_guide=${sidNow ? 'on' : 'off'} sid=${sidNow || '-'}`).catch(() => {})
1027
1108
  // 开学每个目录垫一次 load 全文(同目录只一次;之后靠上面那个看板段反复对齐)。
1028
1109
  // 注意这里**不置 loadInjected** —— 注入点只是在 prompt 提交后、agent loop 前,
1029
1110
  // 对话还没发出去;此刻置位会让被中断的那轮白烧掉标记(见 loadInjected 注解)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.16.3",
3
+ "version": "1.17.0",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,7 +36,12 @@
36
36
  "abs": "node bin/abs.js",
37
37
  "mcp": "node bin/mcp.js",
38
38
  "test": "node --test 'test/*.test.js' 'skill/**/*.test.js'",
39
+ "lint": "eslint .",
40
+ "check": "node --experimental-vm-modules --no-warnings scripts/check-syntax.mjs && eslint .",
39
41
  "prepack": "node -e \"require('fs').chmodSync('bin/abs.js',0o755);require('fs').chmodSync('bin/mcp.js',0o755)\"",
40
42
  "pack:check": "npm pack --dry-run"
43
+ },
44
+ "devDependencies": {
45
+ "eslint": "^9.39.5"
41
46
  }
42
47
  }
package/src/install.js CHANGED
@@ -141,7 +141,6 @@ function mcpEntryPath() {
141
141
  }
142
142
 
143
143
  const MARK = '// abs-managed (agent-brain-sync)'; // TS plugin 标记
144
- const JSON_MARK_KEY = 'abs-managed'; // JSON 内我们的命名空间
145
144
 
146
145
  // ============================ 宿主 config 根 / skill 落点(统一走 env 或 homedir,测试可注入) ============================
147
146
  /** 某宿主的配置根目录(settings.json 所在目录;env 覆盖优先,默认 ~/.<host> 或 ~/.config/<host>)。 */
@@ -245,9 +244,6 @@ async function readJson(p, { strict = true } = {}) {
245
244
  * 现在改为精确匹配本工具实际写入的路径前缀: <homedir>/.abs/hooks/<agentKey>/abs-
246
245
  * (stageHookScripts 生成的脚本名统一是 abs-<Event>.sh)。
247
246
  */
248
- function absHookDir(agentKey) {
249
- return join(homedir(), '.abs', 'hooks', agentKey);
250
- }
251
247
  /** 本工具会生成的 hook 脚本名(与 stageHookScripts 的 abs-<Event>.sh 一致)。 */
252
248
  const ABS_SCRIPT_NAMES = new Set(
253
249
  [...new Set(HOSTS.flatMap((h) => h.events))].map((ev) => `abs-${ev}.sh`),
@@ -496,7 +492,7 @@ async function removeAbsFromForeignMcpStores() {
496
492
  return steps;
497
493
  }
498
494
 
499
- async function installClaudeCode({ withMcp, withSkill, log }) {
495
+ async function installClaudeCode({ withMcp, withSkill }) {
500
496
  const steps = [];
501
497
  // 1) hooks → settings.json (分区合并: 同事件可挂多个 hook 框架, 追加 abs 而非覆盖, 保留 moshi-hook 等)
502
498
  const scriptMap = await stageHookScripts('claude-code', HOSTS[0].events);
@@ -565,7 +561,7 @@ async function uninstallClaudeCode() {
565
561
  }
566
562
 
567
563
  // ============================ Codex ============================
568
- async function installCodex({ withMcp, withSkill, log }) {
564
+ async function installCodex({ withMcp, withSkill }) {
569
565
  const steps = [];
570
566
  const scriptMap = await stageHookScripts('codex', HOSTS[1].events);
571
567
  const p = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'hooks.json');
@@ -768,7 +764,7 @@ function renderPluginTemplate(name, vars) {
768
764
  return t;
769
765
  }
770
766
 
771
- async function installOpenCode({ withMcp, withSkill, log }) {
767
+ async function installOpenCode({ withMcp, withSkill }) {
772
768
  const steps = [];
773
769
  const dir = join(hostConfigRoot('opencode'), 'plugins');
774
770
  const p = join(dir, 'abs.ts');
@@ -816,7 +812,7 @@ async function uninstallOpenCode() {
816
812
  return steps;
817
813
  }
818
814
 
819
- async function installPi({ withMcp, withSkill, log }) {
815
+ async function installPi({ withMcp, withSkill }) {
820
816
  const steps = [];
821
817
  const dir = join(hostConfigRoot('pi'), 'agent', 'extensions');
822
818
  const p = join(dir, 'abs.ts');
@@ -910,7 +906,7 @@ export async function runInstall({ agent, mcp = true, skill = true, yes = false
910
906
  console.log('\n完成。项目内运行 abs init 建图谱; 会话里说 "abs load" 续接。');
911
907
  }
912
908
 
913
- export async function runUninstall({ agent, yes = false } = {}) {
909
+ export async function runUninstall({ agent } = {}) {
914
910
  const targets = agent ? [agent] : Object.keys(INSTALLERS);
915
911
  for (const key of targets) {
916
912
  const inst = INSTALLERS[key];
package/src/lock.js CHANGED
@@ -65,7 +65,7 @@ async function releaseLock(lockPath) {
65
65
  * - { text: <新内容>, ...meta } → 同上,把 meta 透传给调用方(结果回传用)
66
66
  * - SKIP → 不写盘(幂等命中/未找到目标),返回 SKIP
67
67
  * 返回:写盘后=mutator 返回值;SKIP 时原样返回 SKIP。抛错则锁内不落盘、锁释放、上抛。 */
68
- export async function editFile(file, mutator, { maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
68
+ export async function editFile(file, mutator) {
69
69
  const lockPath = join(dirname(file), `.${basename(file)}.lock`);
70
70
  await acquireLock(lockPath);
71
71
  try {
package/src/note.js CHANGED
@@ -9,12 +9,10 @@ import { requireBrain, brainPath } from './index.js';
9
9
  import { requireUser, atTag } from './userconfig.js';
10
10
  import { today, localStamp, LOG_KINDS } from './todo.js';
11
11
  import { editFile, SKIP } from './lock.js';
12
- import { collapseIndex } from './page.js';
13
12
  import { impactOf } from './codegraph.js';
14
13
  import { clip, slugOf } from './text.js';
15
14
 
16
15
  // ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
17
- const NOTE_DEDUP_MS = 60 * 1000;
18
16
 
19
17
  export async function cmdNote({ dir, text, tags, when, impact, type }) {
20
18
  const clean = String(text || '').trim();
@@ -29,12 +27,12 @@ export async function cmdNote({ dir, text, tags, when, impact, type }) {
29
27
  await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
30
28
  const srcDir = brainPath(root, 'sources');
31
29
  await fs.mkdir(srcDir, { recursive: true });
32
- // 幂等: 同文本 60s 内只落一份
30
+ // 幂等: 同文本已落过就不再落一份(判据是内容包含,不看时间)
33
31
  const existing = (await fs.readdir(srcDir).catch(() => [])).filter((f) => f.endsWith('.md'));
34
32
  for (const f of existing) {
35
33
  const body = await fs.readFile(join(srcDir, f), 'utf8').catch(() => '');
36
34
  if (body.includes(clean)) {
37
- return `• 60s 内已落同文本 → ${f} (跳过重复)`;
35
+ return `• 已落过同文本 → ${f} (跳过重复)`;
38
36
  }
39
37
  }
40
38
  // 影响面(可选):显式传 --impact <符号> 时,借本机 CodeGraph 拿「改它波及谁」。
package/src/page.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // 依赖方向:page.js ← store.js(不可反向)。store.js 里的 cmdSupersede /
8
8
  // cmdReview / cmdResolve 会 re-export 本文件的实现,保持既有调用面不变。
9
9
  import { promises as fs } from 'node:fs';
10
- import { join, resolve, dirname } from 'node:path';
10
+ import { join } from 'node:path';
11
11
  import { requireBrain, brainPath, BRAIN_DIR } from './index.js';
12
12
  import { editFile, SKIP } from './lock.js';
13
13
  // PAGE_DIRS / listPages 早先就在 lint.js(页面清单遍历是 lint 的职责),此处复用而非重写。
package/src/relevant.js CHANGED
@@ -139,13 +139,53 @@ export function matchPage(body, pageName, queryWords) {
139
139
  // 查"zzzz不存在"时 qGrams={zz,不存,存在}, 页里恰好有"存在" → ratio=0.5 误命中。
140
140
  // 两个修正: (1) 重复字符的 gram 不算(zz 去重); (2) 至少得命中 2 个不同 gram。
141
141
  const qGrams = new Set(queryWords.flatMap((w) => ngrams(String(w).toLowerCase())));
142
- const pGrams = new Set(ngrams(text.slice(0, 4000)).concat(ngrams(name)));
142
+ const pGrams = pageGrams(text, name);
143
143
  let overlap = 0;
144
144
  for (const g of qGrams) if (pGrams.has(g)) overlap++;
145
145
  const ratio = qGrams.size ? overlap / qGrams.size : 0;
146
146
  return { exact, overlap, ratio };
147
147
  }
148
148
 
149
+ /** 页正文侧 2-gram 集合的缓存。
150
+ *
151
+ * 为何缓存(2026-10-07 实测):load 慢的根因就在这里。
152
+ * queryHint 走 topicStrength → 对【每个词 × 每一页】调 rankPage → matchPage,
153
+ * 而每次都对同一页重算 ngrams(text.slice(0,4000))。
154
+ * 实测规模:17 个活跃词 × 63 页 = 1071 次 matchPage,实测 156ms,
155
+ * 而不同页只有 63 个 —— 同一次展开算了 17 遍。
156
+ * CPU profile:matchPage + ngrams + 那条正则占 load 总耗时的 82%。
157
+ * 端到端:改看板前 191ms / 空看板 40ms(todo 越大词越多 → 越慢,用户实报的「卡」)。
158
+ *
159
+ * 键的构造(关键):用正文当 Map 的键、页名当二级 Map 的键 —— 两级分开存,
160
+ * 而不是拼成一个字符串键。好处:「同正文不同页名」天然是两个不同的表项,
161
+ * 永远不可能互相覆盖(曾用 name+NUL+text 拼串,那是在赌没人会改错键;
162
+ * 现有 20 个测试实际上抓不住那种错,改成两级后这个 bug 类不存在)。
163
+ * 缓存值是**只读的 Set**,matchPage 只查不改 —— 避免把页名 gram 写进缓存。
164
+ *
165
+ * 为何用普通 Map(非 WeakMap):字符串是原始值,不能当 WeakMap 键。
166
+ * 故给容量上限,满了清空(一轮遍历内复用已足够)——
167
+ * ponytail: 不做 LRU,清空式淘汰对本场景够用。
168
+ */
169
+ const PAGE_GRAMS_CACHE = new Map();
170
+ const PAGE_GRAMS_MAX = 500;
171
+
172
+ /** 取(或算)一页的 2-gram 集合(含页名)。返回的 Set 只读,调用方不得修改。 */
173
+ function pageGrams(text, name) {
174
+ // 两级 Map:外层按正文、内层按页名。不用拼串键 —— 见上方注释。
175
+ let inner = PAGE_GRAMS_CACHE.get(text);
176
+ if (inner) {
177
+ const hit = inner.get(name);
178
+ if (hit) return hit;
179
+ } else {
180
+ if (PAGE_GRAMS_CACHE.size >= PAGE_GRAMS_MAX) PAGE_GRAMS_CACHE.clear();
181
+ inner = new Map();
182
+ PAGE_GRAMS_CACHE.set(text, inner);
183
+ }
184
+ const set = new Set(ngrams(text.slice(0, 4000)).concat(ngrams(name)));
185
+ inner.set(name, set);
186
+ return set;
187
+ }
188
+
149
189
  /** 拆字符 2-gram(中英文都适用)—— 模糊匹配的最小单位。
150
190
  * 先过 [\p{L}\p{N}] 再切,单一字符组成的 gram(如 zz) 没区分度,丢掉。 */
151
191
  function ngrams(s) {
package/src/store.js CHANGED
@@ -2,13 +2,12 @@
2
2
  // 命令: init / board / status / load / task / query / lint
3
3
  import { promises as fs } from 'node:fs';
4
4
  import { join, resolve, dirname } from 'node:path';
5
- import { requireBrain, brainPath, absLogDir, BRAIN_DIR } from './index.js';
5
+ import { requireBrain, brainPath, absLogDir } from './index.js';
6
6
  import { requireUser, atTag, getUser, placeholderWarn } from './userconfig.js';
7
- import { stripStateMark, ensureStateMark, stateOfTaskLine, normalizeTodo, TODO_MAX_LINES, addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, LOG_KINDS, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure, enforceBrainFormat } from './todo.js';
7
+ import { stripStateMark, ensureStateMark, stateOfTaskLine, TODO_MAX_LINES, upsertTask, boardText, readTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, collapseDone, SEC, rebuildStructure, enforceBrainFormat, sessionOf } from './todo.js';
8
8
  import { setFormatGate, editFile, SKIP } from './lock.js';
9
9
  import { appendWrapup, strandedFor } from './wrapup.js';
10
- import { keywords, pickRelevant, renderRelevant, recentFiles, rankPage, topicStrength } from './relevant.js';
11
- import { impactOf } from './codegraph.js';
10
+ import { keywords, recentFiles, topicStrength } from './relevant.js';
12
11
  // 纯文本工具已拆到 text.js(2026-10-05)—— 零依赖,note/log/concept 共用。
13
12
  import { clip, slugOf } from './text.js';
14
13
  export { clip, slugOf } from './text.js';
@@ -221,27 +220,6 @@ export const LEGACY_MARKS = [
221
220
  ['### (未标日期)', '### Undated'],
222
221
  ];
223
222
 
224
- /** 把不符标准的标记改成标准(按整行精确匹配,不碰正文)。
225
- * log.md 特殊:它没有固定分区,只有条目行 —— 不做结构重排,只改 H1。
226
- * 返回 { text, changed };无不一致时 changed 为空。 */
227
- export function fixMarks(text, spec) {
228
- const lines = String(text ?? '').split('\n');
229
- const changed = [];
230
- const h1At = lines.findIndex((l) => l.trim().startsWith('# '));
231
- if (h1At !== -1 && lines[h1At].trim() !== spec.h1) {
232
- const cur = lines[h1At].trim();
233
- const hit = LEGACY_MARKS.find(([o]) => o === cur && o.startsWith('# '));
234
- if (hit) { lines[h1At] = spec.h1; changed.push(`${cur} → ${spec.h1}`); }
235
- }
236
- for (let i = 0; i < lines.length; i++) {
237
- if (i === h1At) continue;
238
- const t = lines[i].trim();
239
- const hit = LEGACY_MARKS.find(([o]) => o === t && !o.startsWith('# '));
240
- if (hit) { lines[i] = hit[1]; changed.push(`${hit[0]} → ${hit[1]}`); }
241
- }
242
- return { text: lines.join('\n'), changed };
243
- }
244
-
245
223
  /**
246
224
  * 核对 index/log/todo 的结构,不符就按标准重建(B 档:重排分区 + 内容按归属回填)。
247
225
  * 它每次 load 都跑,所以改动立即生效,不必等额外命令。
@@ -829,10 +807,17 @@ function recentLogLines(text, n) {
829
807
  // ---------- task: 登记/推进(幂等键 = 行首 id;hook 也调这个) ----------
830
808
  // 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
831
809
  // log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
832
- export async function cmdTask({ dir, action, id, section, note, as }) {
810
+ export async function cmdTask({ dir, action, id, section, note, as, session }) {
833
811
  const root = await requireBrain(dir || process.cwd());
834
812
  // 写操作守卫:无姓名不落盘(hook 调的 wrapup/teardown-check 不经过这里,不受影响)
835
813
  const who = await requireUser();
814
+ // 会话 id(可缺省):塞进已有的 `(认领 日期)` 字段而非另开新字段 ——
815
+ // 归档是整行搬运,塞在旧字段里就能自动跟着走,且 6 处解析里 5 处用的
816
+ // 是 `\(认领[^)]*\)` 通配,零改动兼容(只剩 hook 那处严格正则要改)。
817
+ // 取不到就不写:纯加法,旧行为不变。
818
+ const sid = String(session || process.env.ABS_SESSION_ID || '')
819
+ .replace(/[^\w-]/g, '').slice(0, 16);
820
+ const claim = `(认领 ${today()}${sid ? ' ' + sid : ''})`;
836
821
  await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
837
822
  if (action === 'start') {
838
823
  // 两区制:未完成一律进 Todo,行首带状态标记(默认 进行中)。
@@ -870,11 +855,35 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
870
855
  + ` • 还想商量 → abs todo state <id> --note 讨论中`;
871
856
  }
872
857
  }
858
+ // 接手提示(2026-10-09 用户要求):该任务已属于另一个会话时,接手要告知 ——
859
+ // 用户原话:「不属于我的会话的就别乱进行,但是用户强制要进行那也得接手」。
860
+ // **只提示不拦截**:接手是允许的,但得让它知道这条原来是谁的、自己接手了;
861
+ // 否则静默换了 sid,看板上无声无息地易主。
862
+ let takeover = '';
863
+ if (sid) {
864
+ let prevSid = '';
865
+ await editFile(brainPath(root, 'todo.md'), (cur) => {
866
+ if (cur === null) return SKIP;
867
+ for (const l of cur.split('\n')) {
868
+ if (idOfTaskLine(l) === String(id).replace(/[\u200b\u200c\u200d\ufeff]/g, '')) {
869
+ prevSid = sessionOf(l);
870
+ break;
871
+ }
872
+ }
873
+ return SKIP;
874
+ });
875
+ if (prevSid && prevSid !== sid) {
876
+ takeover = `\n ⚠ 接手 —— 该任务原属会话 ${prevSid}(不是本会话 ${sid})\n`
877
+ + ` 已把会话标记改为 ${sid}\n`
878
+ + ` 不想接手只想看看 → abs todo state <id> --note 滞留中(释放占用)`;
879
+ }
880
+ }
873
881
  const r = await upsertTask(root, {
874
882
  section: SEC.todo,
875
- text: `[${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''} (认领 ${today()})`,
883
+ text: `[${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''} ${claim}`,
884
+ session: sid,
876
885
  });
877
- return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n [${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''}${warning}`;
886
+ return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n [${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''}${sid ? ' ' + claim : ''}${takeover}${warning}`;
878
887
  }
879
888
  if (action === 'rename') {
880
889
  // 改任务 id(2026-10-05):人工起错名、或旧 id 不可读时用。
@@ -935,13 +944,13 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
935
944
  if (inlineKind && as && inlineKind !== as) {
936
945
  throw new Error(`✗ as="${as}" 与 note 里的【${inlineKind}】矛盾 —— 只留一个(改 as,或改 note 里的【】)`);
937
946
  }
938
- const res = await markDone(brainPath(root, 'todo.md'), id, as || inlineKind || '落地', conclusion);
947
+ const res = await markDone(brainPath(root, 'todo.md'), id, as || inlineKind || '落地', conclusion, sid);
939
948
  return res;
940
949
  }
941
950
  throw new Error(`unknown task action: ${action}`);
942
951
  }
943
952
 
944
- async function markDone(file, id, kind = '落地', conclusion) {
953
+ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
945
954
  const res = await editFile(file, (text) => {
946
955
  const lines = text.split('\n');
947
956
  let changed = false;
@@ -954,10 +963,22 @@ async function markDone(file, id, kind = '落地', conclusion) {
954
963
  changed = true;
955
964
  // 结语(MCP note / CLI 位置参数)拼在日期前 —— 与 CLI `abs todo done <id> [--as ..] <结语>` 同一落点。
956
965
  const tail = conclusion ? ` — ${conclusion}` : '';
957
- const head = withDoneKind(
958
- stripStateMark(l).replace('- [ ]', '- [x]').replace(/\(认领[^)]*\)/, '') + `${tail} (完成 ${today()})`,
959
- kind,
960
- );
966
+ // 认领与完成可能是**不同会话**(A 认领、B 收尾)。两个都要留:
967
+ // 只留完成者 → A 的线索断;只留认领者 → (完成 日期) 配着别人,语义拧。
968
+ // 规则(2026-10-09 用户定):
969
+ // 同一个 sid → 只写 `(完成 日期 sid)`
970
+ // 不同 sid → `(认领 日期 A) (完成 日期 B)`
971
+ // 认领无 sid → 只写 `(完成 日期 B)`
972
+ // done 未给 sid → 沿用认领的(无新信息,不凭空编)
973
+ const claimSid = sessionOf(l);
974
+ const doneSid = bySid || claimSid;
975
+ const base = stripStateMark(l).replace('- [ ]', '- [x]').replace(/\s*\(认领[^)]*\)/, '');
976
+ const parts = [];
977
+ if (claimSid && doneSid && claimSid !== doneSid) {
978
+ parts.push(`(认领 ${today()} ${claimSid})`);
979
+ }
980
+ parts.push(`(完成 ${today()}${doneSid ? ' ' + doneSid : ''})`);
981
+ const head = withDoneKind(`${base}${tail} ${parts.join(' ')}`, kind);
961
982
  const bp = [];
962
983
  while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('↳')) bp.push(lines[++i]);
963
984
  moved = [head, ...bp];