@fanchao8609/agent_brain_sync 1.19.4 → 1.19.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.19.4",
3
+ "version": "1.19.6",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/lock.js CHANGED
@@ -15,22 +15,17 @@ export class LockTimeout extends Error {}
15
15
  let formatGate = null;
16
16
  export function setFormatGate(fn) { formatGate = fn; }
17
17
 
18
- /** 三个受管文件名 —— 它们存在就是为了当“数据库表”,格式必须恒定。 */
19
- const MANAGED = new Set(['index.md', 'log.md', 'todo.md']);
20
-
21
18
  /** 懒加载闸门:**漏了 setFormatGate 就等于没闸门**,而注册是 import 副作用,
22
19
  * 谁少 import 一次 store.js 就静默裂开(2026-10-18 实测:只 import lock.js 时
23
20
  * 裸散文直接写进了 index.md)。故这里不信任外部注册,缺失时自己补上。
24
21
  *
25
- * 补不上(= store.js 没调 setFormatGate)就对受管文件硬拒:宁可写失败也不要格崩坏
26
- * ——写不进去能重试,写脏了没人知道。 */
27
- async function gateFor(file) {
22
+ * 返回 null 表示「该文件不归闸门管」(非受管文件,或闸门自己说不管)—— 原样写盘。
23
+ * 依赖:`store.js` 顶层无条件调 `setFormatGate`(见那里)。若那行被删,受管文件
24
+ * 会退回静默直写 —— 这是已知的单点,防它靠 review 不靠代码(加守卫就是给不可能发生的
25
+ * 情况多写 6 行,2026-10-18 审查定)。 */
26
+ async function gateFor() {
28
27
  if (!formatGate) await import('./store.js'); // 它的模块顶层会调 setFormatGate
29
- if (formatGate) return formatGate;
30
- if (MANAGED.has(basename(file))) {
31
- throw new Error(`✗ ${basename(file)} 是受管文件,但格式闸门未注册 —— 拒绝写入(防止格崩坏)`);
32
- }
33
- return null;
28
+ return formatGate;
34
29
  }
35
30
  const LOCK_WAIT_BASE_MS = 15; // 指数退避起始重试间隔
36
31
  const LOCK_WAIT_MAX_MS = 150; // 指数退避上限
@@ -99,7 +94,7 @@ export async function editFile(file, mutator) {
99
94
  // 闸门缺失时以前会静默跳过(直写盘),现在改为自己补加载;补不上就硬拒,
100
95
  // 宁可写失败也不要格崩坏——写不进去能重试,写脏了没人知道。
101
96
  if (write) {
102
- const gate = await gateFor(file);
97
+ const gate = await gateFor();
103
98
  if (gate) write = gate(file, write, current) ?? write;
104
99
  }
105
100
  if (write && write !== current) {
package/src/note.js CHANGED
@@ -27,11 +27,16 @@ export async function cmdNote({ dir, text, tags, when, impact, type }) {
27
27
  await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
28
28
  const srcDir = brainPath(root, 'sources');
29
29
  await fs.mkdir(srcDir, { recursive: true });
30
- // 幂等: 同文本已落过就不再落一份(判据是内容包含,不看时间)
30
+ // 幂等: 同文本已落过就不再落一份。
31
+ // ★ 判据必须是**整条相等**,不是 `body.includes(clean)`(2026-10-19 实测修正):
32
+ // 子串判据会把「新经验恰好是某条旧经验的子串」当成重复丢掉 —— 实测已落
33
+ // "load 慢的根因在 matchPage 缓存" 后再 `note "缓存"`,直接被判「已落过」跳过,
34
+ // 内容永久丢失且只报一句「跳过重复」。比对落盘时写下的 `TITLE: <原文>` 整行。
35
+ const titleLine = `TITLE: ${clean}`;
31
36
  const existing = (await fs.readdir(srcDir).catch(() => [])).filter((f) => f.endsWith('.md'));
32
37
  for (const f of existing) {
33
38
  const body = await fs.readFile(join(srcDir, f), 'utf8').catch(() => '');
34
- if (body.includes(clean)) {
39
+ if (body.split('\n').some((l) => l.trim() === titleLine)) {
35
40
  return `• 已落过同文本 → ${f} (跳过重复)`;
36
41
  }
37
42
  }
@@ -261,13 +266,19 @@ export async function cmdLog({ dir, title, kind = 'dev' }) {
261
266
  // 作者前置于 kind:`## [时间] @name dev | 内容`。
262
267
  // 一眼先看到谁做的(与 todo 行 `ID @name — 说明` 排版对齐)。
263
268
  const line = `## [${stamp}] ${atTag(who)} ${kind} | ${clean}`;
264
- await editFile(p, (cur) => {
269
+ const res = await editFile(p, (cur) => {
265
270
  const text = cur ?? '# 🗒 Activity Log\n';
266
271
  // 倒序:新行插在标题后(若已是模板占位行则替换它)
267
272
  const lines = text.split('\n');
268
273
  const headerIdx = lines.findIndex((l) => l.startsWith('#'));
274
+ // ★ 幂等(2026-10-19 实测补):同一分钟内同文重复调(cmdNote 结尾自动写一条 -
275
+ // 收尾又写一条 / 重试)会落成多行一模一样的流水。时间戳只到分钟,同段重写
276
+ // 就是完全意义上的重复记录。判据用整行相等,不靠计数。
277
+ if (lines.slice(headerIdx + 1).some((l) => l === line)) return SKIP;
269
278
  lines.splice(headerIdx + 1, 0, line);
270
279
  return { text: lines.join('\n') };
271
280
  });
281
+ // 重复时不报「✓ 已写入」—— 那是在说假话(用户会以为多了一条)。
282
+ if (res === SKIP) return `• 同一分钟已有同一内容,已跳过 → ${p}\n ${line}`;
272
283
  return `✓ log → ${p}\n ${line}`;
273
284
  }
package/src/store.js CHANGED
@@ -13,24 +13,28 @@ import { clip, slugOf } from './text.js';
13
13
  export { clip, slugOf } from './text.js';
14
14
 
15
15
  // 页面生命周期已拆到 page.js(2026-10-05)。
16
- // 必须【先 import 再 export】—— 只写 `export {x} from` 只是转发,本文件作用域里
17
- // 拿不到 x,内部调用会 ReferenceError(拆完先跑测试就抓到了这个)。
18
- import {
19
- idOfPage, backfillPageId, statusOfPage, supersededByOf,
20
- cmdSupersede, resolvePage, cmdReview, cmdResolve, collapseIndex,
21
- } from './page.js';
16
+ // ★ 只用 `export {x} from` 转发 —— 本文件内部**不再**调用它们(2026-10-19 核对:
17
+ // 11 个符号在 store.js 里除了 import/export 两行之外零出现)。
18
+ // 历史注释说「必须先进 import 再 export」,那是拆分当时 idOfPage 等还在内部用的写法;
19
+ // 现在它们已全部只在 page.js/note.js 内部使用,多留一份 import 就是死代码。
20
+ // 若将来本文件又要内部调用某个符号,再单独 import 它(单独 import 才不触发 lint)。
22
21
  export {
23
22
  idOfPage, backfillPageId, PAGE_STATUS, statusOfPage, supersededByOf,
24
23
  cmdSupersede, resolvePage, cmdReview, cmdResolve, collapseIndex,
25
24
  } from './page.js';
25
+ // 本文件内部真在调用的两个,必须**另行 import**(`export {x} from` 不建立本地绑定)。
26
+ // 两者既转发又内部用,所以 export 行与 import 行各留一份——这不是冗余。
27
+ import { collapseIndex } from './page.js';
26
28
 
27
- // 经验暂存/知识页脚手架已拆到 note.js(2026-10-05)—— 同样先 import 再 export。
28
- import { cmdLog, cmdNote, cmdConcept, ensurePersonPage, registerInIndex } from './note.js';
29
+ // 经验暂存/知识页脚手架已拆到 note.js(2026-10-05)—— 同样转发。
29
30
  export { cmdLog, cmdNote, cmdConcept, ensurePersonPage, registerInIndex } from './note.js';
31
+ import { ensurePersonPage } from './note.js';
30
32
 
31
33
  // Re-export lint.js symbols so external importers (e.g. bin/mcp.js, bin/abs.js) still work.
32
34
  export { cmdLint, listPages, hasTail, PAGE_DIRS, foldSameKind } from './lint.js';
33
- import { SOURCES_MAX, PAGE_DIRS, listPages } from './lint.js';
35
+ // SOURCES_MAX / listPages 本文件内部真在用(load 的 sources 提醒 + queryHint),
36
+ // 必须单独 import;PAGE_DIRS 内部零使用,已在上一行转发,不重复 import。
37
+ import { SOURCES_MAX, listPages } from './lint.js';
34
38
 
35
39
  // ---------- init: 建 .brain/ 骨架 ----------
36
40
  const BRAIN_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
@@ -259,7 +263,14 @@ export async function checkBrainShape(root) {
259
263
  if (next2 === cur) return SKIP;
260
264
  changed = r.fixed.length ? r.fixed : ['结构按标准重排'];
261
265
  return { text: next2 };
262
- }).catch(() => {});
266
+ }).catch((e) => {
267
+ // 不能吞:load 是开机第一入口,静默跳过会让人以为库是健康的。
268
+ // 典型触发:文件里有孤儿子任务(真错,不修而报)。
269
+ // 只报**具体问题行**(以 `·` 开头的那些),不把整个错误头拼进去。
270
+ const msg = e && e.message ? e.message : '未知错误';
271
+ const detail = msg.split('\n').filter((x) => x.trim().startsWith('·')).map((x) => x.trim().slice(1).trim());
272
+ warn.push(`${file}: 未能自动归一 —— ${detail.length ? detail.join('; ') : msg.split('\n')[0]}`.slice(0, 200));
273
+ });
263
274
  if (changed.length) fixed.push(`${file}: ${changed.join('; ')}`);
264
275
  if (!knownH1) warn.push(`${file}: 标题非标准(读到 "${clip(l1, 24) || '(空)'}")`);
265
276
  }
@@ -315,7 +326,10 @@ export async function cmdLoad({ dir }) {
315
326
  ...shape.fixed.map((a) => ` ✓ 已补: ${a}`),
316
327
  ...shape.warn.map((a) => ` ⚠ ${a}`),
317
328
  ];
318
- if (shape.warn.length) rows.push(' (无头文件不自动改:结构可能整体脱轨,请手工对齐 .brain/ 模板)');
329
+ // 那句提示只适用于「标题非标准」:其他 warn(如格式不合规)说这句会误导。
330
+ if (shape.warn.some((a) => a.includes('标题非标准'))) {
331
+ rows.push(' (无头文件不自动改:结构可能整体脱轨,请手工对齐 .brain/ 模板)');
332
+ }
319
333
  sections.push('--- 文件形状核对 ---', ...rows, '');
320
334
  }
321
335
  // 未设姓名/姓名是占位名时开场就提醒 —— load 是开机第一屏,不在这里提,
@@ -986,7 +1000,6 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
986
1000
  let changed = false;
987
1001
  let moved = null;
988
1002
  const kept = [];
989
- const leftBehind = [];
990
1003
  const wantId = String(id).replace(/\u200b/g, '');
991
1004
  for (let i = 0; i < lines.length; i++) {
992
1005
  const l = lines[i];
@@ -1012,15 +1025,9 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
1012
1025
  const head = withDoneKind(`${base}${tail} ${parts.join(' ')}`, kind);
1013
1026
  const bp = [];
1014
1027
  const leftover = [];
1015
- // 子项搬运:已勾选的随任务进 Done;**未勾选的不搬**,留在 Todo 区。
1016
- //
1017
- // 为何(2026-10-18 实测):Done 区被 collapseDone 折叠成计数,搬进去即「看板里
1018
- // 消失」。实测 factory-docs-slim 就带着「待做六链」的 `- [ ]` 落地,那条待办
1019
- // 此后无处可寻,同时父任务盖着【落地】——看板与事实矛盾。
1020
- //
1021
- // 为何不是硬拦(A 方案被否):`abs todo note` 写的子项与断点记录**同形**(都是
1022
- // ` - [ ] …`),无法从字段上区分「待办」与「过程记录」;硬拦会把所有带断点的
1023
- // 任务一起拦住(实测弄挂两条既有测试)。改为「不搬 + 点名」,信息不丢且不误伤。
1028
+ // 子项搬运:**已勾选**的随任务进 Done;未勾选的不搬(下方直接拒)。
1029
+ // 已勾的必须搬:否则任务进 Done 了、子项剩在原区,就是无主孤儿行,
1030
+ // 重建分区时会被清掉(历史上就是这么丢过数据)。
1024
1031
  while (i + 1 < lines.length) {
1025
1032
  const nx = lines[i + 1];
1026
1033
  if (!nx.trim()) break;
@@ -1068,15 +1075,11 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
1068
1075
  }
1069
1076
  if (!changed || !moved) return SKIP;
1070
1077
  // 归位 + 按日期分组:insertDoneGrouped 一次重建 Done 区(新日期在前,未标日期归尾)
1071
- return { text: insertDoneGrouped(kept.join('\n'), moved), leftBehind };
1078
+ return { text: insertDoneGrouped(kept.join('\n'), moved) };
1072
1079
  });
1073
1080
  return res === SKIP
1074
1081
  ? `[NO_MATCH] 未找到含 "${id}" 的未完成任务行`
1075
- : `✓ 已完成并归位 Done: ${id} 【${kind}】` +
1076
- (res.leftBehind?.length
1077
- ? `\n ⚠ ${res.leftBehind.length} 条未勾子项留在 Todo 区(未跟入 Done,仍可在看板看到):\n` +
1078
- res.leftBehind.map((p) => ` - [ ] ${p}`).join('\n')
1079
- : '');
1082
+ : `✓ 已完成并归位 Done: ${id} 【${kind}】`;
1080
1083
  }
1081
1084
 
1082
1085
  // ---------- show: 查看 index/todo/log(只读面) ----------
package/src/text.js CHANGED
@@ -10,8 +10,13 @@
10
10
  * 用户与后续会话看到的天然是半句 —— "摘要读起来抽象"的真因在写入口, 不在表述能力。 */
11
11
  export function clip(text, n) {
12
12
  const s = String(text || '').trim();
13
- if (s.length <= n) return s;
14
- const head = s.slice(0, n);
13
+ // 按**码点**切,不按 UTF-16 下标(2026-10-19 实测修正):emoji 等星平面字符
14
+ // 占两个 code unit,s.slice 会把它拦腰切开 → 写出半个代理对(实测
15
+ // clip('😀😀😀😀😀😀', 3) 得 "😀\ud83d…",落到 readme 里就是乱码)。
16
+ // Array.from 按码点拆,再逐步长度判断。
17
+ const cps = Array.from(s);
18
+ if (cps.length <= n) return s;
19
+ const head = cps.slice(0, n).join('');
15
20
  // 优先在标点处断开(中文句读 + 英文句读),其次空格,最后才硬切
16
21
  const cut = Math.max(
17
22
  head.lastIndexOf('。'), head.lastIndexOf(';'), head.lastIndexOf('!'), head.lastIndexOf('?'),
@@ -19,16 +24,18 @@ export function clip(text, n) {
19
24
  head.lastIndexOf('.'), head.lastIndexOf(' '),
20
25
  );
21
26
  // 边界太靠前(< 一半)说明这一段本就是长句,宁可硬切也不留个残破的短头
22
- const keep = cut > n / 2 ? cut : n;
23
- return `${s.slice(0, keep).replace(/[\s,,、;;.。]+$/, '')}…`;
27
+ const keep = cut > n / 2 ? head.slice(0, cut) : head;
28
+ return `${keep.replace(/[\s,,、;;.。]+$/, '')}…`;
24
29
  }
25
30
 
26
31
  /** slug:取前 n 码点 → 非词字符折叠为 '-'。只用于**文件名**,完整标题另存 TITLE 行。
27
32
  * 在标点/空格边界收口,不在字中间切断(否则出 `...-硬切-不` 这种残尾)。 */
28
33
  export function slugOf(text, n = 24) {
29
34
  const s = String(text || '').trim();
30
- let head = s.slice(0, n);
31
- if (s.length > n) {
35
+ // 同 clip:按码点切,避免把 emoji 切成半个代理对(见 clip 注释)。
36
+ const cps = Array.from(s);
37
+ let head = cps.slice(0, n).join('');
38
+ if (cps.length > n) {
32
39
  const cut = Math.max(head.lastIndexOf(','), head.lastIndexOf('。'), head.lastIndexOf('、'),
33
40
  head.lastIndexOf(':'), head.lastIndexOf(','), head.lastIndexOf('.'), head.lastIndexOf(' '));
34
41
  if (cut > n / 2) head = head.slice(0, cut);
package/src/todo/spec.js CHANGED
@@ -84,12 +84,16 @@ export const ENTRY_SHAPES = {
84
84
  // 摘要里不许带 Markdown 结构符(实测污染就是摘要取了页面正文开头)
85
85
  if (/^\s*-\s*\[\[[^\]]+\]\]\s*[—-]\s*#+\s/.test(l)) return '摘要不能带 `## ` 标题前缀(取页面首行标题了)';
86
86
  // ★ 2026-10-18 补:摘要正文本身必须是**纯文本一句话**。
87
- // 原规则只拦了 `## ` 前缀,实测下列全放行:摘要写成 `- 列表项`(变成嵌套列
88
- // 表)、```js(开代码围栏,把后面所有条目吞进去)、`| a | b |`(表格)。
89
- // index 是表,摘要是一列纯文本,不能携带任何块级结构。
87
+ // 原规则只拦了 `## ` 前缀,实测下列全放行:摘要写成 `- 列表项`(看着就像
88
+ // 另一条条目)、```js(开代码围栏把后面全吞)、`| a | b |`(表格)。
89
+ // index 是表,摘要是一列纯文本。
90
+ //
91
+ // 但别拦过头:摘要位置在 `- [[页名]] — ` 之后,是**一行中间**,
92
+ // `+ ` / `* ` / `> ` / `1. ` 在这里不会真渲染成块级结构,都是人话开头
93
+ // (实测放过一次「+ 一点补充」被误拒)。只拦真正会破坏解析的几种。
90
94
  const desc = l.replace(/^\s*-\s*\[\[[^\]]+\]\]\s*[—-]\s*/, '');
91
- if (/^(?:[-*+]\s|```|~~~|\||>\s|#{1,6}\s)/.test(desc)) {
92
- return '摘要必须以文字开头(不能是列表项/代码围栏/表格/引用/标题)';
95
+ if (/^(?:-\s|```|~~~|\|)/.test(desc) || /^#{1,6}\s/.test(desc)) {
96
+ return '摘要必须以文字开头(不能是列表项/代码围栏/表格/标题)';
93
97
  }
94
98
  if (desc.includes('```')) return '摘要不能含代码围栏 ```(会吞掉后续所有条目)';
95
99
  return null;