@fanchao8609/agent_brain_sync 1.21.0 → 1.23.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/src/brain/db.js CHANGED
@@ -24,16 +24,23 @@
24
24
  import { DatabaseSync } from 'node:sqlite';
25
25
  import { promises as fs } from 'node:fs';
26
26
  import { join, dirname } from 'node:path';
27
+ import { unlinkSync } from 'node:fs';
27
28
  import { homedir } from 'node:os';
28
29
  import { resolveLayer, anchorsOf, decayOf, DEFAULT_LAYER } from './layers.js';
29
30
 
30
- export const DB_VERSION = 2;
31
+ export const DB_VERSION = 4;
31
32
 
32
33
  /** 库路径。ABS_DB 可覆盖(测试隔离 / 多库切换)。 */
33
34
  export function dbPath() {
34
35
  return process.env.ABS_DB || join(homedir(), '.abs', 'graph.db');
35
36
  }
36
37
 
38
+ /** 本次进程里最后一次 openDb 是否因损坏而重建过库。
39
+ * 记录属内容决策,工具只报不改 —— 这里只标记,由调用方决定怎么告知,
40
+ * 不自己打印(库层不该有输出)。 */
41
+ let _rebuiltAfterCorruption = false;
42
+ export function lastOpenRebuilt() { return _rebuiltAfterCorruption; }
43
+
37
44
  export class DbError extends Error {
38
45
  constructor(message, cause) {
39
46
  super(message);
@@ -66,6 +73,20 @@ CREATE TABLE IF NOT EXISTS nodes (
66
73
  -- 因此任何查询都能命中它。实测(2026-11-01)刚 capture 一条「并发写」,log 页
67
74
  -- 亮度 3.87 排第一,压过真正的内容页 —— 这是纯噪音,必须从检索候选里排除。
68
75
  is_ledger INTEGER NOT NULL DEFAULT 0,
76
+ -- 被哪一页推翻(对应 frontmatter 的 superseded-by)。
77
+ -- 为何要进库(2026-11-02):知识自我进化的第一个需要 —— 能查「哪些页被推翻了、
78
+ -- 被谁推翻」。原来这关系只存在于 markdown 的一行 frontmatter 里,
79
+ -- 库只存 status 不存走向,于是「进化」在检索侧完全看不见。
80
+ superseded_by TEXT NOT NULL DEFAULT '',
81
+ -- 知识的「作用」分档(借 clo​ude-mem 的 observation 类型,2026-11-02):
82
+ -- gotcha 关键坑(怎么都想不到、踩了会白干)
83
+ -- decision 决策(当时为什么这么选)
84
+ -- tradeoff 权衡(A 好但 B 也有理,最后选了哪个)
85
+ -- howto 做法(这类事该怎么做)
86
+ -- 为何要它:七层按「性质」分(工具/认知/混沌),分不出「同一层里哪种更该先看」。
87
+ -- 实测 L4 有 382 页,其中「坑」106 页,而「决策」「权衡」根本没被记下来。
88
+ -- 留空 = 未分档(不强制补,旧页保持空)。
89
+ kind TEXT NOT NULL DEFAULT '',
69
90
  UNIQUE(project, dir, slug)
70
91
  );
71
92
  CREATE INDEX IF NOT EXISTS i_nodes_layer ON nodes(layer);
@@ -129,14 +150,35 @@ export function bigrams(s) {
129
150
  const seg = m[0];
130
151
  for (let i = 0; i + 2 <= seg.length; i++) out.push(seg.slice(i, i + 2));
131
152
  }
132
- // 单个汉字:query 是「锁」「锁」这类一字词时,2-gram 切不出来,
133
- // 上面循环对 1 字串一次都不执行 → 恒返回空 → 搜不到(实测「锁」0 命中)。
134
- // 补成单字 gram,存储侧同样产出它(见下)。
135
153
  for (const m of text.matchAll(/[\u4e00-\u9fa5]/g)) out.push(m[0]);
136
154
  for (const m of text.matchAll(/[A-Za-z][A-Za-z0-9_-]{1,}/g)) out.push(m[0].toLowerCase());
137
155
  return [...new Set(out)];
138
156
  }
139
157
 
158
+ /** 按「词」切 2-gram,不加单字。用于**查询侧**构造 MATCH 表达式。
159
+ *
160
+ * 为何要和平分两套(2026-11-02 深度测试发现):
161
+ * 把单字也放进查询表达式会放大误命中 —— 实测查 `zzzz不存在zzz` 时,
162
+ * gram 里含「不」「存」「在」,任何提到这三个字的页都会命中,
163
+ * 于是「一个根本不存在的词」返回了 7 页。这不是模糊匹配有用,是噪音。
164
+ *
165
+ * 存储侧仍保留单字(bigrams 不动):这样查询本身**就是一个字**时
166
+ * (如「锁」),还能通过单字 gram 命中;而多字查询不会靠单字瞎连。
167
+ * 两侧不要求同一函数 —— 要求的是「查询产出的每个 gram 在存储侧都存在」,
168
+ * 而存储侧是超集,恒成立。 */
169
+ export function queryBigrams(s) {
170
+ const text = String(s || '');
171
+ const out = new Set();
172
+ for (const m of text.matchAll(/[\u4e00-\u9fa5]{2,}/g)) {
173
+ const seg = m[0];
174
+ for (let i = 0; i + 2 <= seg.length; i++) out.add(seg.slice(i, i + 2));
175
+ }
176
+ // 整条查询只有单个汉字时才用单字 gram(「锁」这种一字查询仍能搜到)
177
+ if (out.size === 0 && /^[\u4e00-\u9fa5]$/.test(text.trim())) out.add(text.trim());
178
+ for (const m of text.matchAll(/[A-Za-z][A-Za-z0-9_-]{1,}/g)) out.add(m[0].toLowerCase());
179
+ return [...out];
180
+ }
181
+
140
182
  /** 丢掉所有表(版本升级用)。库是派生品,删了由 rebuild 从 markdown 重建。 */
141
183
  function dropAll(db) {
142
184
  for (const r of db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all()) {
@@ -146,6 +188,7 @@ function dropAll(db) {
146
188
 
147
189
  /** 打开(不存在就建)库。返回 { db, close }。 */
148
190
  export function openDb(path = dbPath()) {
191
+ _rebuiltAfterCorruption = false;
149
192
  let db;
150
193
  try {
151
194
  db = new DatabaseSync(path);
@@ -172,6 +215,24 @@ export function openDb(path = dbPath()) {
172
215
  }
173
216
  } catch (e) {
174
217
  try { db.close(); } catch { /* 已关 */ }
218
+
219
+ // 库文件根本不是 SQLite / 结构彻底坏掉 → 删掉重建。
220
+ // 为何不报错让用户手删(2026-11-02 深度测试发现):库是**派生物**
221
+ // (markdown 才是真相),删了由 rebuild 从 .brain/ 全量重扫,内容不丢。
222
+ // 原实现只报「库结构初始化失败: file is not a database」,而 rebuild 走同一条
223
+ // 打开路径 —— 于是用户被卡死:修不好也删不掉(不知道删哪),只能去查源码。
224
+ if (/not a database|file is encrypted|malformed/i.test(e.message || '')) {
225
+ try {
226
+ for (const suffix of ['', '-wal', '-shm']) {
227
+ try { unlinkSync(`${path}${suffix}`); } catch { /* 不存在 */ }
228
+ }
229
+ const h = openDb(path); // 文件已删,重来一次走全新库
230
+ _rebuiltAfterCorruption = true; // 告知调用方:刚重建过,库是空的
231
+ return h;
232
+ } catch (e2) {
233
+ throw new DbError(`库文件损坏且无法重建 ${path}: ${e2.message}`, e2);
234
+ }
235
+ }
175
236
  if (e instanceof DbError) throw e;
176
237
  throw new DbError(`库结构初始化失败: ${e.message}`, e);
177
238
  }
@@ -186,13 +247,46 @@ export async function ensureDbDir(path = dbPath()) {
186
247
  await fs.mkdir(dirname(path), { recursive: true });
187
248
  }
188
249
 
250
+ /** 估算一段文字要占多少 token。中文按 1.5 字符/token、其余按 4 字符/token。
251
+ *
252
+ * 为何要估算而不是精确算(2026-11-02):精确分词要引依赖,而这里只需
253
+ * 「让用户知道取这一页大概多少钱」—— 数量级对就够了。
254
+ * 实测校准:本项目 40 页正文平均 824 token/页,与按段落读下来的体感一致。
255
+ */
256
+ export function estTokens(s) {
257
+ const t = String(s || '');
258
+ const cjk = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
259
+ const rest = t.length - cjk;
260
+ return Math.round(cjk / 1.5 + rest / 4);
261
+ }
262
+
263
+ /** 从页正文取一句话摘要:优先 frontmatter 的 description,退化到首个非标题行。
264
+ * 与 relevant.js 的 digest 同思路,但这里在库侧用(不读文件)。 */
265
+ export function digest(body, maxLen = 90) {
266
+ const lines = String(body || '').split('\n');
267
+ const fmEnd = lines[0] === '---' ? lines.indexOf('---', 1) : -1;
268
+ if (fmEnd > 0) {
269
+ const d = lines.slice(1, fmEnd).find((l) => /^description\s*:/.test(l));
270
+ if (d) return d.replace(/^description\s*:\s*/, '').trim().slice(0, maxLen);
271
+ }
272
+ // 跳过 frontmatter 与标题,取第一句正文
273
+ const start = fmEnd > 0 ? fmEnd + 1 : 0;
274
+ const first = lines.slice(start).find((l) => {
275
+ const x = l.trim();
276
+ return x && !x.startsWith('#') && !x.startsWith('---') && !x.startsWith('>') && !x.startsWith('|');
277
+ });
278
+ return String(first || '').replace(/^[-*]\s*/, '').replace(/^TITLE\s*:\s*/, '').trim().slice(0, maxLen);
279
+ }
280
+
189
281
  /** 从 markdown 正文抽 title / tags / status(与页面 frontmatter 同源)。 */
190
282
  function metaOf(body) {
191
283
  const t = String(body || '');
192
284
  const title = (t.match(/^title:\s*(.+)$/m) || [])[1]?.trim() || '';
193
285
  const tags = (t.match(/^tags:\s*(.+)$/m) || [])[1]?.replace(/^\[|\]$/g, '').trim() || '';
194
286
  const st = (t.match(/^status:\s*(.+)$/m) || [])[1]?.trim() || 'active';
195
- return { title, tags, status: st };
287
+ const sup = (t.match(/^superseded-by:\s*(.+)$/m) || [])[1]?.trim() || '';
288
+ const kind = (t.match(/^kind:\s*(.+)$/m) || [])[1]?.trim() || '';
289
+ return { title, tags, status: st, supersededBy: sup, kind };
196
290
  }
197
291
 
198
292
  /** 全量重建:清表 → 重扫所有注册项目 → 重建边与断点词。
@@ -207,8 +301,8 @@ export function rebuild(handle, pages) {
207
301
  db.exec('DELETE FROM anchors; DELETE FROM edges; DELETE FROM nodes; DELETE FROM rules; DELETE FROM nodes_bigram;');
208
302
  try { db.exec("INSERT INTO fts(fts) VALUES('delete-all')"); } catch { /* 空表时无可删 */ }
209
303
 
210
- const insNode = db.prepare(`INSERT INTO nodes(project,dir,slug,layer,title,tags,status,body,mtime,is_ledger)
211
- VALUES(?,?,?,?,?,?,?,?,?,?)`);
304
+ const insNode = db.prepare(`INSERT INTO nodes(project,dir,slug,layer,title,tags,status,body,mtime,is_ledger,superseded_by,kind)
305
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`);
212
306
  const insGram = db.prepare('INSERT OR REPLACE INTO nodes_bigram(id,g) VALUES(?,?)');
213
307
  const insEdge = db.prepare('INSERT OR IGNORE INTO edges(src,dst,raw) VALUES(?,?,?)');
214
308
  const insAnchor = db.prepare('INSERT OR IGNORE INTO anchors(word,node) VALUES(?,?)');
@@ -217,11 +311,11 @@ export function rebuild(handle, pages) {
217
311
  // 第一遍:建节点,拿 id 映射 key = `project/dir/slug`
218
312
  const idOf = new Map();
219
313
  for (const p of pages) {
220
- const { title, tags, status } = metaOf(p.body);
314
+ const { title, tags, status, supersededBy, kind } = metaOf(p.body);
221
315
  const { layer } = resolveLayer(p.body, p.dir);
222
316
  const ledger = (p.dir === '' && LEDGER_SLUGS.has(p.slug)) ? 1 : 0;
223
317
  const r = insNode.run(p.project, p.dir, p.slug, layer, title, tags, status,
224
- p.body, p.mtime || now, ledger);
318
+ p.body, p.mtime || now, ledger, supersededBy, kind);
225
319
  const id = Number(r.lastInsertRowid);
226
320
  // FTS 源:正文 + 标题 + tags 切 2-gram。查询侧必须用同一个 bigrams()。
227
321
  insGram.run(id, bigrams([p.slug, title, tags, p.body].join('\n')).join(' '));
@@ -256,19 +350,38 @@ export function rebuild(handle, pages) {
256
350
  const isLedger = p.dir === '' && (p.slug === 'todo' || p.slug === 'log');
257
351
  if (isLedger) continue;
258
352
  for (const word of anchorsOf(p.body)) {
259
- insAnchor.run(word, srcId);
353
+ const { project: explicitProj, page } = parseAnchor(word);
354
+ // 断点词(L6)只记页名,不记 @项目前缀 —— 那是链接语法不是索引词
355
+ insAnchor.run(page, srcId);
260
356
  stat.anchors++;
261
- // 就近优先:本项目 → 再全局
262
- const dstId = idOf.get(`${p.project}/concepts/${word}`)
263
- ?? idOf.get(`${p.project}/entities/${word}`)
264
- ?? idOf.get(`${p.project}/sessions/${word}`)
265
- ?? findGlobal(idOf, word);
357
+ let dstId;
358
+ if (explicitProj) {
359
+ // 显式跨项目:只在这个项目里找,找不到就是悬挂,不退回全局兜底
360
+ // (退回会静默连到别的项目,正是这个语法要避免的)
361
+ dstId = idOf.get(`${explicitProj}/concepts/${page}`)
362
+ ?? idOf.get(`${explicitProj}/entities/${page}`)
363
+ ?? idOf.get(`${explicitProj}/sessions/${page}`)
364
+ ?? idOf.get(`${explicitProj}/syntheses/${page}`);
365
+ if (dstId === undefined) stat.crossProjectMissing = (stat.crossProjectMissing || 0) + 1;
366
+ } else {
367
+ // 就近优先:本项目 → 再全局
368
+ dstId = idOf.get(`${p.project}/concepts/${page}`)
369
+ ?? idOf.get(`${p.project}/entities/${page}`)
370
+ ?? idOf.get(`${p.project}/sessions/${page}`)
371
+ ?? findGlobal(idOf, page);
372
+ }
266
373
  if (dstId === undefined) {
267
- insEdge.run(srcId, 0, word);
268
- stat.dangling++;
374
+ // 平铺文件名(index/todo/log)当链接目标是**讨论语法的示例文本**,
375
+ // 不是真链接 —— 实测 .brain 里 `[[todo]]` 出现在「lint 误报它死链」这句里。
376
+ // 这类不记悬挂(它们不是死链,是正文引用了文件名)。
377
+ if (!LEDGER_SLUGS.has(page)) {
378
+ insEdge.run(srcId, 0, word);
379
+ stat.dangling++;
380
+ }
269
381
  } else {
270
382
  insEdge.run(srcId, dstId, word);
271
383
  stat.edges++;
384
+ if (explicitProj) stat.crossProject = (stat.crossProject || 0) + 1;
272
385
  }
273
386
  }
274
387
  }
@@ -284,8 +397,32 @@ export function rebuild(handle, pages) {
284
397
  return stat;
285
398
  }
286
399
 
287
- /** 同名页全局兜底:取第一个命中的(跨项目引用靠 @前缀 显式写,这里只做兜底)。 */
400
+ /** 解析双链目标:`[[@项目/页面]]` → 显式跨项目;`[[页面]]` → 就近。
401
+ *
402
+ * 为何要这个语法(2026-11-02 用户定「跨项目双链连接」):
403
+ * 默认就近(本项目优先)能覆盖绝大多数情况,但「我要明确引用另一个项目的某页」
404
+ * 就没法表达 —— 只能靠全局兜底,而那会撞上同名页。
405
+ * `@` 前缀让跨项目引用显式,读页面时也一眼看得出「这条链出了项目」。
406
+ *
407
+ * 返回 { project: '项目名'|null, page: '页名' }。
408
+ */
409
+ export function parseAnchor(word) {
410
+ const w = String(word || '').trim();
411
+ const m = w.match(/^@([^/\\]+)[/\\](.+)$/);
412
+ if (m) return { project: m[1].trim(), page: m[2].trim() };
413
+ return { project: null, page: w };
414
+ }
415
+
416
+ /** 同名页全局兜底:取第一个命中的(跨项目引用靠 @前缀 显式写,这里只做兜底)。
417
+ *
418
+ * 必须排除两类同名目标(2026-11-02 实测踩到):
419
+ * · 平铺文件 index/todo/log —— 每个项目都有一份,兜底会连到别的项目去。
420
+ * 实测 `[[index]]` 把 agent_brain_sync 的页连到了 pA/index,纯误连。
421
+ * · 流水页(dir 为空)同理。
422
+ * 判据:兜底只在「知识页」之间发生,索引/流水不算知识页。
423
+ */
288
424
  function findGlobal(idOf, word) {
425
+ if (LEDGER_SLUGS.has(word)) return undefined;
289
426
  for (const [k, v] of idOf) {
290
427
  if (k.endsWith(`/${word}`)) return v;
291
428
  }
@@ -299,25 +436,100 @@ function findGlobal(idOf, word) {
299
436
  * 返回 [{ id, project, dir, slug, title, layer, score }],按 FTS 相关度排序。 */
300
437
  export function search(handle, query, { limit = 20 } = {}) {
301
438
  const { db } = handle;
302
- const grams = bigrams(query);
303
- // 查询切成 2-gram 后用 OR 连 —— 越多 gram 命中越相关(bm25 自己会排序)。
439
+ const grams = queryBigrams(query);
304
440
  if (!grams.length) return [];
305
441
  const expr = grams.map((g) => `"${g.replace(/"/g, '""')}"`).join(' OR ');
442
+ // 命中门槛:按 gram 的**文档频率加权**算覆盖率,不再用「命中几个」的平均数。
443
+ //
444
+ // 为何要加权(2026-11-02 深度测试发现,两轮才修对):
445
+ // FTS 的 OR 语义是「命中任意 gram 就算」。先试「命中数 ≥ 一半」,
446
+ // 但中文高频组合会绕过它 —— 查 `zzzz不存在zzz` 时,「不存在」切出
447
+ // [不存, 存在] 两个 gram,命中 2/4 就达标,而「不存在」在图谱里到处都是。
448
+ // 实测真实图谱:`验证` 出现在 84% 的页里、`关联` 79%、`连接` 79% ——
449
+ // 这些 gram 没有区分度,却和稀有 gram 等权。
450
+ // 现在每个 gram 的权重 = log((总页数+1)/(出现页数+0.5)):
451
+ // 稀有 gram(如专有名词)权重高,常见虚词组合权重低到几乎不计。
452
+ // 覆盖率 = 命中 gram 的权重和 / 查询 gram 的总权重,低于 MIN_COVER 丢弃。
453
+ const MIN_COVER = 0.35;
454
+ const totalNodes = db.prepare('SELECT count(*) c FROM nodes_bigram').get().c || 1;
455
+ const df = new Map();
456
+ for (const r of db.prepare('SELECT g FROM nodes_bigram').all()) {
457
+ for (const g of String(r.g).split(' ')) df.set(g, (df.get(g) || 0) + 1);
458
+ }
459
+ const wOf = (g) => {
460
+ const d = df.get(g) || 0;
461
+ return d === 0 ? 3 : Math.log((totalNodes + 1) / (d + 0.5)); // 库里没有的 gram 权重最高
462
+ };
463
+ const wantWeight = grams.reduce((a, g) => a + wOf(g), 0) || 1;
306
464
  try {
465
+ // 取 g 列才能数命中数;多取一些候选,过滤后仍可能不够 limit
307
466
  const rows = db.prepare(`
308
- SELECT n.id, n.project, n.dir, n.slug, n.title, n.layer,
309
- bm25(fts) AS rank
310
- FROM fts JOIN nodes n ON n.id = fts.rowid
467
+ SELECT n.id, n.project, n.dir, n.slug, n.title, n.layer, n.kind, n.body,
468
+ bm25(fts) AS rank, b.g
469
+ FROM fts
470
+ JOIN nodes_bigram b ON b.id = fts.rowid
471
+ JOIN nodes n ON n.id = fts.rowid
311
472
  WHERE fts MATCH ? AND n.is_ledger = 0
473
+ -- 被推翻的经验不出现在检索里。
474
+ -- 它的存在意义是「别再用它」,而不是回答「我上次怎么解决 X」——
475
+ -- 那会拿到一个已知错误的答案。与 abs query 的既有判据一致。
476
+ AND n.status != 'superseded'
312
477
  ORDER BY rank
313
478
  LIMIT ?
314
- `).all(expr, limit);
315
- // bm25 返回**负值且越负越相关**,不能直接当亮度用(否则最相关的最暗)。
316
- // 归一:取最相关的一条为 1.0,其余按比例落到 0.3~1.0 区间 ——
317
- // 保留下限是为了让「勉强命中的种子」仍能扩散,只是传得更弱。
318
- if (!rows.length) return [];
319
- const worst = Math.abs(rows[rows.length - 1].rank) || 1e-9;
320
- return rows.map((r) => ({ ...r, bright: Math.max(0.3, Math.abs(r.rank) / worst) }));
479
+ `).all(expr, Math.max(limit * 4, 40));
480
+
481
+ const kept = rows.map((r) => {
482
+ const hitGrams = grams.filter((q) => String(r.g).includes(q));
483
+ const cover = hitGrams.reduce((a, g) => a + wOf(g), 0) / wantWeight;
484
+ return { ...r, cover, hit: hitGrams.length };
485
+ }).filter((r) => r.cover >= MIN_COVER);
486
+ if (!kept.length) return [];
487
+
488
+ // bm25 返回**负值且越负越相关**。归一成 0.3~1.0 的亮度,保证「最相关 = 1.0」。
489
+ //
490
+ // 坑(2026-11-02 深度测试发现):原来分母取 `kept[最后一条].rank` —— 而 kept 按
491
+ // rank 升序(第一行最相关),最后一条恰恰是**最不相关**的那条,其 rank 接近 0。
492
+ // 实测一个 4 条结果的查询里,最相关那条亮度算出 1168694.07(= |−2.42| / 2e-6),
493
+ // 输出一行 165 万的亮度条。分母必须取最相关的那条(第一条)。
494
+ // 种子亮度里要体现「层」—— 否则分层只在扩散时起作用,种子仍按纯字面排。
495
+ // 实测(2026-11-02,14 个项目 586 页):查「docker」时三个 sessions/ 流水页
496
+ // 排在最前,压过真正讲了 docker 的知识页 —— 流水页正文长、词频高,bm25 天然占优,
497
+ // 但对「我上次怎么解决 X」没有价值。
498
+ // 权重 = 1 - decay:L3 工具 0.6、L4 认知 0.4、L5 会话 0.25、L7 混沌 0.05。
499
+ // L1/L2 不参与(铁律不立页)。
500
+ const layerWeight = (layer) => Math.max(0.05, 1 - decayOf(layer));
501
+ const best = Math.abs(kept[0].rank) || 1e-9;
502
+ // 亮度 = 档位 × 档内名次。
503
+ // 档位由加权覆盖率决定(0.6~1.0):全命中最高,勉强沾边压到 0.6 档再往下。
504
+ // 档内名次由相对 bm25 决定(0.1~1.0):覆盖率相同但 bm25 不同的页仍可分。
505
+ // 下限 0.3 保留 —— 勉强命中的种子仍要能向外扩散,只是传得更弱。
506
+ return kept.slice(0, limit)
507
+ .map(({ g: _g, ...r }) => {
508
+ const rel = Math.min(1, Math.abs(r.rank) / best);
509
+ const band = 0.6 + 0.4 * r.cover;
510
+ // 亮度保持 0.3~1.0 的可读区间,**不乘层权重**。
511
+ // 坑(2026-11-02 实测):把层权重乘进亮度后,L4 的满分页只剩 0.40,
512
+ // 输出里一片 0.3~0.5,用户看不出谁更相关 —— 亮度失去了「一眼看强弱」的作用。
513
+ // 层的作用改由**排序**承担(见下面 sort),亮度回归「这一页与查询多贴」本身。
514
+ const bright = Math.max(0.3, Math.min(1, band * (0.1 + 0.9 * rel)));
515
+ return { ...r, bright };
516
+ })
517
+ // 排序:层优先,同层再看亮度。
518
+ // 为何层要排第一(2026-11-02,586 页实测):查「静默失效」的前三名全是 L7
519
+ // 混沌页(临时笔记),字面 cover=1.00 但内容与主题无关;
520
+ // 而 L4 认知层里真正讲这个主题的页被压到后面。
521
+ // 判据:知识层的页即便贴得稍微松一点,也比混沌层的碎片更该先看。
522
+ // 例外:L1/L2 不参与排序(铁律不立页,单独在最前输出)。
523
+ .sort((a, b) => {
524
+ const wa = layerWeight(a.layer);
525
+ const wb = layerWeight(b.layer);
526
+ return (wb * b.bright - wa * a.bright) || (b.bright - a.bright) || (a.id - b.id);
527
+ })
528
+ // 索引优先(2026-11-02 借 clo​ude-mem 的 Progressive Disclosure):
529
+ // 返回值只给「摘要 + 取全文成本」,**不带正文** —— 正文要用 getPage 再取。
530
+ // 实测这一层的价值:ABS 单页平均 824 token,8 页全带就是 6600 token,
531
+ // 而其中真正被用上的往往只有 1-2 页(clo​ude-mem 实测 90% 是浪费)。
532
+ .map(({ body, ...r }) => ({ ...r, digest: digest(body, 90), tokens: estTokens(body) }));
321
533
  } catch {
322
534
  // 方言/语法问题不能让搜索整体挂掉 —— 退化成空种子,由调用方决定提示
323
535
  return [];
@@ -377,10 +589,26 @@ export function illuminate(handle, seeds, { hops = HOPS, limit = 10 } = {}) {
377
589
 
378
590
  const out = [];
379
591
  for (const [id, v] of bright) {
380
- const n = db.prepare('SELECT id,project,dir,slug,title,layer FROM nodes WHERE id = ? AND is_ledger = 0').get(id);
381
- if (n) out.push({ ...n, bright: v.bright, from: v.from });
592
+ // 扩散出口也要排除 superseded —— 入口(search)排了,出口不排就绕过去:
593
+ // 实测旧结论页自己不出现在种子里,却被邻居扩散点亮(亮度 0.24 带 ↝)。
594
+ const n = db.prepare("SELECT id,project,dir,slug,title,layer,kind,body FROM nodes WHERE id = ? AND is_ledger = 0 AND status != 'superseded'").get(id);
595
+ if (n) {
596
+ // 与 search 保持同一形态:索引优先,只给摘要与成本,不带正文。
597
+ // 坑(2026-11-02 实测):这里原来只查少数字段,于是 CLI 侧读不到 digest/tokens,
598
+ // 摘要与成本全丢 —— search 改了、出口没改,典型的「改一半」。
599
+ const { body, ...rest } = n;
600
+ out.push({ ...rest, bright: v.bright, from: v.from, digest: digest(body, 90), tokens: estTokens(body) });
601
+ }
382
602
  }
383
- return out.sort((a, b) => b.bright - a.bright || a.id - b.id).slice(0, limit);
603
+ // 排序口径必须与 search 一致:层优先,同层再比亮度。
604
+ // 坑(2026-11-02 实测):原来这里只按 bright 排,把 search 已经排好的层序冲掉了 ——
605
+ // 于是 L7 混沌页(bright 1.00)又跑到 L4 认知页(bright 0.95)前面。
606
+ // 层权重在这里再乘一次,为的是「扩散进来的弱页不会因为某个邻居强就压过知识页」。
607
+ const weight = (layer) => Math.max(0.05, 1 - decayOf(layer));
608
+ return out
609
+ .sort((a, b) => (weight(b.layer) * b.bright - weight(a.layer) * a.bright)
610
+ || (b.bright - a.bright) || (a.id - b.id))
611
+ .slice(0, limit);
384
612
  }
385
613
 
386
614
  /** L1 铁律:按项目取出。检索任何东西都必须先带上它(「不可逾越」的代码保证)。 */
@@ -0,0 +1,135 @@
1
+ // src/brain/evolve.js — 知识自我进化:从图谱里看出「知识在往哪走」。
2
+ //
3
+ // 为何需要它(2026-11-02 用户定目标「可自我成长的知识库」):
4
+ // 图谱现在能存能查,但不会告诉人「哪里在变好、哪里在堆积、哪里该合并」。
5
+ // 光靠人定期回头看,就是靠记忆维护记忆 —— 必然漏。
6
+ //
7
+ // 四条判据,都只**报告**不判断(判断是内容决策,程序不替人做):
8
+ // 1. 被推翻的:哪些页已 superseded、被谁取代、有没有留下空链
9
+ // 2. 主题堆积:同一主题词下页数过多,说明该合并成规律页
10
+ // 3. 只进不出:有新页被建立但没人链它,等于没接进网络
11
+ // 4. stale:长期没改动、且没有任何入边的页 —— 考察是否该删
12
+ //
13
+ // 输出纪律:按类型折叠计数,同类超阈值只报条数 + 样例(见 read-side-output-must-not-scale)。
14
+
15
+ /** 收集进化信号。只读,不改任何东西。 */
16
+ export function evolveSignals(handle, { staleDays = 90, now = Date.now() } = {}) {
17
+ const { db } = handle;
18
+ const all = db.prepare("SELECT id, slug, project, layer, status, superseded_by, mtime FROM nodes WHERE is_ledger = 0").all();
19
+ const byId = new Map(all.map((r) => [r.id, r]));
20
+ const inbound = new Map();
21
+ for (const r of db.prepare('SELECT dst, count(*) c FROM edges WHERE dst > 0 GROUP BY dst').all()) inbound.set(r.dst, r.c);
22
+ const outbound = new Map();
23
+ for (const r of db.prepare('SELECT src, count(*) c FROM edges WHERE src > 0 GROUP BY src').all()) outbound.set(r.src, r.c);
24
+
25
+ // 1. 被推翻的 vs 单纯弃用
26
+ //
27
+ // 两者都是 status=superseded,但含义不同(2026-11-02 修正):
28
+ // · 被取代 —— 有 superseded-by,指向新页(知识更新了)
29
+ // · 单纯弃用 —— 没有 superseded-by(如「需求已否决,本页存档设计」)
30
+ // 原来把「没写 superseded-by」一律报成断链,于是用户每跑一次 evolve 都被提醒
31
+ // 一件并不存在问题的事(实测:corp_agent 的 run-command-controlled-exec
32
+ // 正文写着「需求已否决」,那是存档不是断链)。
33
+ // 真断链只有一种:**写了 by、但指向的页不存在**。
34
+ const superseded = all.filter((r) => r.status === 'superseded');
35
+ const retired = superseded.filter((r) => !r.superseded_by); // 单纯弃用,不报警
36
+ const replaced = superseded.filter((r) => r.superseded_by); // 真被取代
37
+ const danglingSupersede = replaced.filter((r) =>
38
+ !all.some((x) => x.slug === r.superseded_by && x.project === r.project));
39
+
40
+ // 2. 主题堆积:按 tags 里的**业务标签**归类。
41
+ //
42
+ // 坑(2026-11-02 实测):tags 的第一项是**页面类型**(concept/source/session-log/entity),
43
+ // 拿它归类必然把 58 页 concept 报成「一组堆积」—— 那是类型不是主题,全是噪音。
44
+ // 类型词是固定的几个,直接按白名单跳过;主题看第二项起。
45
+ // 另:会话流水页不参与主题堆积判断。
46
+ // 它们的 tags 里带 `todo-archive`/`历史`,按主题归类会把 13 个归档快照
47
+ // 报成「该合并成规律页」—— 但归档快照本来就该各自独立(一天一文件是目录契约)。
48
+ // 判据看 dir:sessions/ 下的都是流水,不是知识页。
49
+ const TYPE_TAGS = new Set(['concept', 'source', 'synthesis', 'entity', 'session-log']);
50
+ const tagBuckets = new Map();
51
+ for (const row of db.prepare("SELECT tags, slug FROM nodes WHERE is_ledger = 0 AND dir != 'sessions' AND tags != ''").all()) {
52
+ const parts = String(row.tags).replace(/^\[|\]$/g, '').split(',').map((s) => s.trim()).filter(Boolean);
53
+ const topic = parts.find((x) => !TYPE_TAGS.has(x.toLowerCase()));
54
+ if (!topic) continue;
55
+ if (!tagBuckets.has(topic)) tagBuckets.set(topic, []);
56
+ tagBuckets.get(topic).push(row.slug);
57
+ }
58
+ const piled = [...tagBuckets.entries()]
59
+ .filter(([, v]) => v.length >= 8) // 一个主题下 8 页以上算堆积
60
+ .sort((a, b) => b[1].length - a[1].length);
61
+
62
+ // 3. 只进不出:有入边但没有出边 —— 说明它是终点,不连回别处
63
+ const sinkOnly = all.filter((r) => (inbound.get(r.id) || 0) > 0 && !(outbound.get(r.id) || 0));
64
+
65
+ // 4. stale:长期没改 + 没人链
66
+ const staleMs = staleDays * 86400_000;
67
+ const stale = all.filter((r) => (now - (r.mtime || 0) > staleMs) && !(inbound.get(r.id) || 0));
68
+
69
+ return {
70
+ total: all.length,
71
+ superseded, replaced, retired, danglingSupersede,
72
+ piled, sinkOnly, stale,
73
+ byId, inbound, outbound,
74
+ };
75
+ }
76
+
77
+ /** 渲染成给人看的报告。按类型分组,同类超阈值只报样例。 */
78
+ export function renderEvolve(sig, { sample = 5, staleDays = 90 } = {}) {
79
+ const out = [];
80
+ const trunc = (arr, fmt) => {
81
+ const shown = arr.slice(0, sample).map(fmt);
82
+ if (arr.length > sample) shown.push(` …另有 ${arr.length - sample} 条`);
83
+ return shown;
84
+ };
85
+
86
+ out.push(`📈 图谱进化体检 — ${sig.total} 页`);
87
+ out.push('');
88
+
89
+ // 好消息放前面:推翻说明知识在更新
90
+ if (sig.replaced.length) {
91
+ out.push(`✓ 已推翻 ${sig.replaced.length} 页(知识在更新,不是坏事)`);
92
+ out.push(...trunc(sig.replaced, (r) => ` ${r.slug} → ${r.superseded_by}`));
93
+ } else {
94
+ out.push('· 还没有被推翻的页 —— 要么知识很稳,要么没人回头修正');
95
+ }
96
+ if (sig.retired.length) {
97
+ out.push(`· 另有 ${sig.retired.length} 页是单纯弃用(需求否决/方案作废),不是断链`);
98
+ out.push(...trunc(sig.retired, (r) => ` ${r.slug}`));
99
+ }
100
+ out.push('');
101
+
102
+ if (sig.danglingSupersede.length) {
103
+ out.push(`⚠ ${sig.danglingSupersede.length} 页写了取代者但目标页不存在(历史断了)`);
104
+ out.push(...trunc(sig.danglingSupersede, (r) => ` ${r.slug}`));
105
+ out.push(' 修: abs supersede <旧页> --by <新页>');
106
+ out.push('');
107
+ }
108
+
109
+ if (sig.piled.length) {
110
+ out.push(`⚠ 主题堆积 ${sig.piled.length} 组(同一主题下页数多,考虑合并成规律页)`);
111
+ out.push(...trunc(sig.piled, ([tag, list]) => ` ${tag}: ${list.length} 页`));
112
+ out.push('');
113
+ }
114
+
115
+ if (sig.sinkOnly.length) {
116
+ out.push(`· 只被链、不链别人 ${sig.sinkOnly.length} 页(是终点,不回连——若它其实是枢纽,该补出链)`);
117
+ out.push(...trunc(sig.sinkOnly, (r) => ` ${r.slug} (入 ${sig.inbound.get(r.id)})`));
118
+ out.push('');
119
+ }
120
+
121
+ if (sig.stale.length) {
122
+ out.push(`· ${staleDays} 天没动且无人链 ${sig.stale.length} 页(可能该删或该接进网络)`);
123
+ out.push(...trunc(sig.stale, (r) => {
124
+ const days = Math.round((Date.now() - r.mtime) / 86400_000);
125
+ return ` ${r.slug} (${days} 天前)`;
126
+ }));
127
+ out.push('');
128
+ }
129
+
130
+ const issues = sig.danglingSupersede.length + sig.piled.length + sig.stale.length;
131
+ out.push(issues === 0
132
+ ? '✓ 没有需要立刻处理的进化问题'
133
+ : `共 ${issues} 处待处理。这只是体检,改不改由你判断 —— 程序不替人做内容决策。`);
134
+ return out.join('\n');
135
+ }
@@ -78,7 +78,21 @@ export function resolveLayer(body, dirName) {
78
78
  /** 模板占位符 —— 它们出现在正文里是在**讨论语法**,不是真链接。
79
79
  * 实测(2026-11-01):.brain 里 `[[作者]]` `[[页面名]]` `[[slug]]` 被当成真双链,
80
80
  * 污染断点词表并被报成悬挂链接。排除它们是判据,不是特例。 */
81
- const PLACEHOLDER = new Set(['作者', '页面名', 'slug', '页面', '页名', '名称', 'x', 'y', 'foo', 'bar']);
81
+ const PLACEHOLDER = new Set([
82
+ '作者', '页面名', 'slug', '页面', '页名', '名称', 'x', 'y', 'foo', 'bar',
83
+ // 讨论「双链 vs wiki」时提到的词(2026-11-02 实测:log-2026-09-17 里
84
+ // 「抽词前剥掉 [[wiki]]」是指令,不是链接到某个 wiki 页)
85
+ 'wiki',
86
+ ]);
87
+
88
+ /** 占位符形状:`[[TOOLS:...]]`、`[[A...B]]`、`[[前缀:xxx]]` 这类。
89
+ * 它们是「描述语法时写的方括号」,不是真链接。
90
+ * 判据(2026-11-02):含省略号,或含冒号且冒号前是纯大写字母(像常量/枚举写法)。 */
91
+ function isPlaceholderShape(t) {
92
+ if (/[..]{2,}|\.\.\.|…/.test(t)) return true; // 省略号
93
+ if (/^[A-Z_]+[::]/.test(t)) return true; // TOOLS:xxx 这类
94
+ return false;
95
+ }
82
96
 
83
97
  /** 提取页面里的双链词(L6 断点层的数据来源)。
84
98
  * `[[x]]` / `[[x|别名]]` / `[[x#小节]]` 都取 x。
@@ -92,6 +106,7 @@ export function anchorsOf(body) {
92
106
  const t = m[1].trim();
93
107
  if (!t) continue;
94
108
  if (PLACEHOLDER.has(t)) continue;
109
+ if (isPlaceholderShape(t)) continue;
95
110
  // 纯符号/纯数字的不是页名
96
111
  if (!/[\p{L}\p{N}]/u.test(t)) continue;
97
112
  out.add(t);