@fanchao8609/agent_brain_sync 1.19.5 → 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.5",
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
@@ -23,7 +23,7 @@ export function setFormatGate(fn) { formatGate = fn; }
23
23
  * 依赖:`store.js` 顶层无条件调 `setFormatGate`(见那里)。若那行被删,受管文件
24
24
  * 会退回静默直写 —— 这是已知的单点,防它靠 review 不靠代码(加守卫就是给不可能发生的
25
25
  * 情况多写 6 行,2026-10-18 审查定)。 */
26
- async function gateFor(file) {
26
+ async function gateFor() {
27
27
  if (!formatGate) await import('./store.js'); // 它的模块顶层会调 setFormatGate
28
28
  return formatGate;
29
29
  }
@@ -94,7 +94,7 @@ export async function editFile(file, mutator) {
94
94
  // 闸门缺失时以前会静默跳过(直写盘),现在改为自己补加载;补不上就硬拒,
95
95
  // 宁可写失败也不要格崩坏——写不进去能重试,写脏了没人知道。
96
96
  if (write) {
97
- const gate = await gateFor(file);
97
+ const gate = await gateFor();
98
98
  if (gate) write = gate(file, write, current) ?? write;
99
99
  }
100
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'];
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);