@fanchao8609/agent_brain_sync 1.25.1 → 1.26.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.
@@ -78,7 +78,7 @@ function isRealUserText(t) {
78
78
  }
79
79
 
80
80
  /** 会话文件 md5。判「内容有没有变」的唯一可靠判据。 */
81
- async function md5Of(file) {
81
+ export async function md5Of(file) {
82
82
  const buf = await fs.readFile(file).catch(() => null);
83
83
  if (!buf) return null;
84
84
  return createHash('md5').update(buf).digest('hex');
@@ -89,14 +89,21 @@ export async function digestSession(file) {
89
89
  let raw;
90
90
  try { raw = await fs.readFile(file, 'utf8'); } catch { return null; }
91
91
  const out = {
92
- file, start: '', endedAt: '', day: '',
93
- users: [], filesEdited: new Map(), toolCalls: 0,
92
+ file, start: '', endedAt: '', day: '', id: '', cwd: '',
93
+ users: [], filesEdited: new Map(), toolCalls: 0, lastAssist: '',
94
94
  };
95
95
  for (const l of raw.split('\n')) {
96
96
  if (!l) continue;
97
97
  let o;
98
98
  try { o = JSON.parse(l); } catch { continue; }
99
- if (o.type === 'session') { out.start = o.timestamp || ''; out.cwd = o.cwd; continue; }
99
+ if (o.type === 'session') {
100
+ out.start = o.timestamp || '';
101
+ out.cwd = o.cwd;
102
+ // 会话 id 是「这份资料属于哪场会话」的唯一凭据 —— pi 把它放在首行,
103
+ // 文件名里也有,但文件名会被改,id 不会。
104
+ out.id = o.id || '';
105
+ continue;
106
+ }
100
107
  if (o.type !== 'message') continue;
101
108
  if (o.timestamp) out.endedAt = o.timestamp;
102
109
  const m = o.message || {};
@@ -104,17 +111,28 @@ export async function digestSession(file) {
104
111
  const t = textOf(m.content).trim();
105
112
  if (isRealUserText(t)) out.users.push(t);
106
113
  } else if (m.role === 'assistant' && Array.isArray(m.content)) {
114
+ let said = '';
107
115
  for (const c of m.content) {
108
- if (!c || c.type !== 'toolCall') continue;
116
+ if (!c) continue;
117
+ if (c.type === 'text' && c.text) said += c.text;
118
+ if (c.type !== 'toolCall') continue;
109
119
  out.toolCalls++;
110
120
  const p = c.arguments && c.arguments.path;
111
121
  if (p && typeof p === 'string' && ['edit', 'write'].includes(c.name)) {
112
122
  out.filesEdited.set(p, (out.filesEdited.get(p) || 0) + 1);
113
123
  }
114
124
  }
125
+ // 只留最后一轮说了什么(会话的收尾结论通常在这),不存全文 ——
126
+ // 全文动辄几百 KB,存进来就是「读取侧随规模增长」。
127
+ if (said.trim()) out.lastAssist = said.trim();
115
128
  }
116
129
  }
117
130
  out.day = (out.start || '').slice(0, 10);
131
+ if (!out.id) {
132
+ // 兜底:文件名形如 `2026-11-05T03-35-14-951Z_<id>.jsonl`
133
+ const m = String(file).match(/^[^_]+_([0-9a-f-]{16,})\.jsonl$/);
134
+ if (m) out.id = m[1];
135
+ }
118
136
  return out;
119
137
  }
120
138
 
@@ -130,6 +148,12 @@ async function daysWithSnapshot(brainSessionsDir) {
130
148
  let files;
131
149
  try { files = await fs.readdir(brainSessionsDir); } catch { return days; }
132
150
  for (const f of files) {
151
+ // ★ 只认「整天快照」,不认单场会话页(2026-11-05 实测修正):
152
+ // `log-<日期>.md` 是人写的一天流水 —— 那天确实整理过了,跳过合理。
153
+ // 但 `session-<日期>-s<id8>.md` 是**一场会话一张**(abs ingest 的产物)。
154
+ // 按天跳过它,会让「同一天只落了一场」把其余几场永久埋掉:
155
+ // 实测真实项目有 14 天存在多场会话(最多一天 10 场),落一场就全跳过。
156
+ if (f.startsWith('session-')) continue;
133
157
  const m = f.match(/(\d{4}-\d{2}-\d{2})/);
134
158
  if (m) days.add(m[1]);
135
159
  }
@@ -148,11 +172,44 @@ export async function readLedger(path = ledgerPath()) {
148
172
  /** 写账本(原子:tmp + rename)。 */
149
173
  export async function writeLedger(ledger, path = ledgerPath()) {
150
174
  await fs.mkdir(join(path, '..'), { recursive: true });
151
- const tmp = `${path}.tmp-${Date.now()}`;
175
+ // tmp 名必须带随机后缀(2026-11-05 实测):
176
+ // 原来用 `${path}.tmp-${Date.now()}`。同一毫秒内起两个进程(同时关两个会话)
177
+ // 会生成**完全相同**的 tmp 名 —— 前一个 rename 把文件改走后,后一个 rename
178
+ // 对着已经不存在的老名字操作,抛 ENOENT。实测 20 个并发直接炸出这个错。
179
+ // 仓库其它落盘点(ingest 的页、notes)早就在用 `${Date.now()}-${random}`,
180
+ // 这里漏了随机段。
181
+ const tmp = `${path}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
152
182
  await fs.writeFile(tmp, JSON.stringify(ledger, null, 2) + '\n');
153
183
  await fs.rename(tmp, path);
154
184
  }
155
185
 
186
+ /** 读-改-写账本的**并发安全**版本。
187
+ *
188
+ * 为何要它(2026-11-05 实测):`readLedger` + 修改 + `writeLedger` 是一组
189
+ * 非原子操作。两个会话同时关闭 → 两个 ingest 进程各自读到同一份旧账本 →
190
+ * 各自加一条 → 后写的盖掉先写的,**先写那条静默消失**。
191
+ * 后果不是报错,是某场会话下次又被当作「未落页」重新催一遍。
192
+ *
193
+ * 用 lock.js 的 editFile(它按文件加 .lock 串行化,是仓库既定的写法)。
194
+ * 账本不在 BRAIN_SHAPE 里,故不会过 index/todo/log 的格式闸门。
195
+ *
196
+ * @param {(book:object)=>void} mutator 就地修改 book
197
+ */
198
+ export async function updateLedger(mutator, path = ledgerPath()) {
199
+ const { promises: fsp } = await import('node:fs');
200
+ await fsp.mkdir(join(path, '..'), { recursive: true });
201
+ const { editFile } = await import('../lock.js');
202
+ await editFile(path, async (current) => {
203
+ let book;
204
+ try {
205
+ const j = JSON.parse(String(current || ''));
206
+ book = (j && typeof j.files === 'object') ? j : { version: 1, files: {} };
207
+ } catch { book = { version: 1, files: {} }; }
208
+ await mutator(book);
209
+ return { text: JSON.stringify(book, null, 2) + '\n' };
210
+ });
211
+ }
212
+
156
213
  /**
157
214
  * 扫一个项目,分三类:
158
215
  * fresh —— 从未总结过(账本里没有)
@@ -295,7 +352,7 @@ export async function summonTail(root, { limit = 8 } = {}) {
295
352
  }
296
353
  }
297
354
  if (pending > limit) lines.push(` …另有 ${pending - limit} 场(abs summon 看全量)`);
298
- lines.push(' 落哪条由你判断: abs capture --layer N "…" | abs summon --mark 记账不再提');
355
+ lines.push(' 落哪条由你判断: abs ingest <会话文件名> | abs capture --layer N "…"');
299
356
  return '\n' + lines.join('\n');
300
357
  } catch {
301
358
  return '';
package/src/install.js CHANGED
@@ -448,6 +448,33 @@ async function reconcileOneStore(p, keys) {
448
448
  * 故这里只做**只读检测 + 告警**,绝不代它删除或改写(那是别人的目录)。
449
449
  * @returns {string|null} 一行告警文案;无需告警时 null
450
450
  */
451
+ /** 安装后自检:hook 指向的入口是否真的跟得上当前代码。
452
+ *
453
+ * 为何要它(2026-11-05 实测踩到):hook 烧进去的是**绝对路径**,指向全局包。
454
+ * 那天先装了 hook(12:23),全局包四分钟后才重装(12:27)——
455
+ * 中间真实关闭会话时,hook 调的是一个当时还不存在的命令,静默失败。
456
+ * fire-and-forget 的错误全被吞掉,所以「装好了」和「能用」看起来一模一样。
457
+ *
458
+ * 判据:看那个文件里有没有本次安装所依赖的命令,**而不是看文件存在**。
459
+ * 文件存在只证明路径对,不证明内容新。
460
+ *
461
+ * @returns {string|null} 有问题时返回要打印的提示,正常返回 null。
462
+ */
463
+ export function staleBinWarning(binOverride) {
464
+ let bin, src;
465
+ try {
466
+ bin = binOverride || stableBinPath('abs.js');
467
+ src = fsSync.readFileSync(bin, 'utf8');
468
+ } catch { return null; } // 自检失败不该影响安装结果
469
+ // ingest 是 2026-11 新增的;缺它说明指向的是旧包,hook 触发时会「未知命令」
470
+ if (/'ingest'/.test(src)) return null;
471
+ return [
472
+ '⚠ hook 指向的 abs 缺少 ingest 命令,会话关闭时不会自动落会话页。',
473
+ ` 实际指向: ${bin}`,
474
+ ' 修法: npm install -g . 然后重跑本安装',
475
+ ].join('\n');
476
+ }
477
+
451
478
  function agentsSkillWarning() {
452
479
  const p = join(homedir(), '.agents', 'skills', 'abs-agent-brain-sync', 'SKILL.md');
453
480
  if (!fsSync.existsSync(p)) return null;
@@ -976,6 +1003,9 @@ export async function runInstall({ agent, all = false, mcp = true, skill = true,
976
1003
  const w = agentsSkillWarning();
977
1004
  if (w) console.log('\n' + w);
978
1005
  }
1006
+ const stale = staleBinWarning();
1007
+ if (stale) console.log('\n' + stale);
1008
+
979
1009
  console.log('\n完成。项目内运行 abs init 建图谱; 会话里说 "abs load" 续接。');
980
1010
  }
981
1011
 
package/src/lint.js CHANGED
@@ -48,6 +48,36 @@ export function hasTail(body) {
48
48
  }
49
49
  const PAGE_MAX_BYTES = 8 * 1024;
50
50
 
51
+ /** 决策/权衡类页的尾巴:要的是「当时依据什么选的」,不是可跑的命令。
52
+ *
53
+ * 为何单开一套词(2026-11-05):这类知识天生没有验证命令 ——
54
+ * 「为什么选 SQLite 不选 Neo4j」只能给取舍依据,硬要写「跑什么命令确认」就是编。
55
+ * 判据仍要看得见的东西:可选方案、各自代价、最后按哪条定的。
56
+ *
57
+ * 词表比 TAIL_WORDS 窄很多 —— 这里要的是「依据」,不是任何动作词都算。 */
58
+ const BASIS_WORDS = '依据|代价|取舍|放弃|为什么|原因|权衡|选[了择定]|不考虑|代价|对比';
59
+
60
+ /** 决策类页有没有给出「当时凭什么这么选」。
61
+ * 与 hasTail 同形(段标题 + 段内有真内容),只是词表不同 ——
62
+ * 不共用一个函数是因为两边词表不同,合用会把「验证」也算成「依据」。 */
63
+ export function hasBasisTail(body) {
64
+ const lines = String(body || '').split('\n');
65
+ const headRe = new RegExp(`^#{2,6}[^\\n]*(${BASIS_WORDS})`);
66
+ for (let i = 0; i < lines.length; i++) {
67
+ if (!headRe.test(lines[i])) continue;
68
+ const lvl = lines[i].match(/^#+/)[0].length;
69
+ for (let j = i + 1; j < lines.length; j++) {
70
+ const t = lines[j].trim();
71
+ const h = lines[j].match(/^(#+)\s/);
72
+ if (h && h[1].length <= lvl) break;
73
+ if (!t) continue;
74
+ if (t.startsWith('<!--') || t === '-->') continue;
75
+ return true;
76
+ }
77
+ }
78
+ return new RegExp(`^\\s*(?:\\d+\\.|[-*])\\s*\\**[^\\n]{0,20}(${BASIS_WORDS})`, 'm').test(String(body || ''));
79
+ }
80
+
51
81
  // source 页超龄未提炼的天数阈值(SOURCE-UNDISTILLED)。
52
82
  // 7 天 = 跨过至少一个完整工作周还没人提炼,基本等于被遗忘。
53
83
  const SOURCE_STALE_DAYS = 7;
@@ -96,6 +126,9 @@ export async function listPages(vault) {
96
126
  indexed: indexText.includes(`[[${f.replace(/\.md$/, '')}]]`),
97
127
  status: statusOfPage(body, fm ? fm[1] : ''),
98
128
  supersededBy: supersededByOf(body),
129
+ // kind 与 status 同源,都在 frontmatter。
130
+ // 尾巴规则要按它分档(见 checkPage 的 NO-TAIL)。
131
+ kind: ((fm ? fm[1] : '').match(/^kind:\s*(.+)$/m) || [])[1]?.trim() || '',
99
132
  });
100
133
  }
101
134
  }
@@ -228,7 +261,20 @@ function checkPage(pg, names, linkedNames, inbound) {
228
261
  }
229
262
  }
230
263
  if (pg.dir === 'concepts' && !hasTail(pg.body)) {
231
- issues.push(`NO-TAIL: ${pg.rel}(无「做完怎么确认」;尾巴写清跑什么/看什么/按什么判据,别只有头)`);
264
+ // 尾巴按 kind 分档(2026-11-05):原来「必须写验证命令」对所有 concepts 页一律要求,
265
+ // 后果是「当时为什么这样选」这类决策知识根本落不了地 —— 决策没有可跑的验证命令,
266
+ // 硬写就是编。用户 2026-11-05 定的方向是让 abs 成为大型知识图谱,
267
+ // 那就不能只有「故障排查」一种知识形态。
268
+ //
269
+ // gotcha / howto → 仍要验证尾巴(现状不变,这两类真有可验证的东西)
270
+ // decision / tradeoff → 写「当时依据什么选的」即可
271
+ // 无 kind 的旧页 → 保持现状,不回溯(不把历史页一夜变红)
272
+ const needsVerify = !pg.kind || pg.kind === 'gotcha' || pg.kind === 'howto';
273
+ if (needsVerify) {
274
+ issues.push(`NO-TAIL: ${pg.rel}(无「做完怎么确认」;尾巴写清跑什么/看什么/按什么判据,别只有头)`);
275
+ } else if (!hasBasisTail(pg.body)) {
276
+ issues.push(`NO-TAIL: ${pg.rel}(kind=${pg.kind} 不要求验证命令,但要有「当时依据什么选的」:写可选方案、各自代价、最后按哪条定的)`);
277
+ }
232
278
  }
233
279
  // 「任务随手记」(abs todo memo)不登记 index:它是**某任务的过程草稿**,
234
280
  // 数量随任务数增长且生命周期短(任务完就没人看),登记会把 index 冲垮 ——
@@ -262,9 +308,14 @@ async function checkGraph(root, pages) {
262
308
  }
263
309
 
264
310
  // SESSIONS-NAMING: sessions/ 的一天一文件契约
311
+ // 例外(2026-11-05):`abs ingest` 落的会话页叫 `session-<日期>-s<id8>.md`,
312
+ // 它不是「当天工作流水」,而是**一场具体会话的结构化记录** ——
313
+ // 同一天可以有好几场,硬套「一天一个 log」会把它挤掉。
314
+ // 判据用 slug 前缀 `session-`:这是 ingest 独占的命名空间,与 log-<日期> 不交叉。
265
315
  const sessByDate = new Map();
266
316
  for (const pg of pages) {
267
317
  if (pg.dir !== 'sessions') continue;
318
+ if (/^session-/.test(pg.slug)) continue;
268
319
  const m = pg.slug.match(/^(?:log-)?(\d{4}-\d{2}-\d{2})/);
269
320
  if (!m) continue;
270
321
  const tags = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
@@ -287,9 +338,9 @@ async function checkGraph(root, pages) {
287
338
  for (const pg of pages) {
288
339
  if (pg.dir !== 'sessions') continue;
289
340
  const tags = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
290
- if (!/session-log|todo-archive|archive/.test(tags)) {
341
+ if (!/session-log|todo-archive|archive|session-digest/.test(tags)) {
291
342
  issues.push(`SESSIONS-MISPLACED: ${pg.rel}(tags: ${tags.trim()} 不属 sessions/;` +
292
- `当天工作写进 \`log-<日期>.md\` 正文,暂存线索用 \`abs note\` 落 sources/)`);
343
+ `当天工作写进 \`log-<日期>.md\` 正文;一场会话的结构化记录用 \`abs ingest\`)`);
293
344
  }
294
345
  }
295
346
 
@@ -181,7 +181,24 @@ export function enforceBrainFormat(text, spec, prev) {
181
181
  // 为什么:不做这个区分,库里只要有一条历史脏行,**所有写入全部失败** ——
182
182
  // 实测真库(corp_agent)就因一条超长断点 + 8 条 `[]()` 条目,
183
183
  // 连 `abs rule add` 都做不到,整个图谱变成只读。
184
- const prevSig = prev == null ? null : violationSignatures(String(prev));
184
+ //
185
+ // ★ prev 也要过同一道 ↳ 迁移(2026-11-05 实测踩到):
186
+ // 上面把 src 里的 ` ↳ 内容: …` 迁成 ` - [ ] …`,行的签名随之变形;
187
+ // 而 prevSig 若直接取 prev 的原始签名,就对不上迁移后的行 ——
188
+ // 存量坏子项(超长/报告体)会被判成「本次新引入」,写入失败。
189
+ // 实测症状:zj_shop 的 load 直接崩 —— readTodo 的惰性迁移一走写盘就抛错,
190
+ // 于是 abs load / abs todo 全部失效,且该状态不会自愈。
191
+ // 修法:让 prev 走**同一次迁移**再取签名,两边签名体系才一致。
192
+ let prevForSig = prev;
193
+ if (prev != null && spec?.h1 === '# 📋 Todo Board') {
194
+ const lp = String(prev).split('\n');
195
+ for (let i = 0; i < lp.length; i++) {
196
+ const conv = migrateLegacyChild(lp[i]);
197
+ if (conv !== null && conv !== lp[i]) lp[i] = conv;
198
+ }
199
+ prevForSig = lp.join('\n');
200
+ }
201
+ const prevSig = prevForSig == null ? null : violationSignatures(String(prevForSig));
185
202
  if (spec?.h1 === '# 📋 Todo Board') assertTodoContent(src, prevSig);
186
203
  // ★ 标签白名单闸门放在**旧标记归一之后**(见下面 legacy 段之后)——
187
204
  // 旧标签(`## Backlog` / `## Today / In Progress`)是**待迁移**的,不是非法新标签。