@fanchao8609/agent_brain_sync 1.21.0 → 1.22.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/README.md CHANGED
@@ -17,14 +17,17 @@ npm install -g @fanchao8609/agent_brain_sync
17
17
  abs install
18
18
  ```
19
19
 
20
- `abs install` 会交互式让你勾选智能体;也可以直接指定:
20
+ **默认只装 pi**(其余宿主 opt-in)——装到不用的宿主上会往那边配置文件塞 hook/MCP 条目。
21
21
 
22
22
  ```bash
23
- abs install --agent pi # 只装 pi
24
- abs install # 全部: claude-code / codex / opencode / pi / omp
23
+ abs install # 默认: 只装 pi
24
+ abs install --all # 全装: claude-code / codex / opencode / pi / omp
25
+ abs install --agent claude-code # 只装指定宿主
25
26
  abs install --agent pi --no-mcp # 只装 hook + skill,不要 MCP
26
27
  ```
27
28
 
29
+ 交互模式下回车 = 只装 pi;输入 `all` 才全装。
30
+
28
31
  装了什么:
29
32
 
30
33
  | 宿主 | hook | MCP | skill |
@@ -122,6 +125,7 @@ abs db rebuild # 扫所有登记项目 → 建图数
122
125
  abs db search <词> # 走库检索:FTS 拿种子 + 亮度扩散,L1 铁律恒在最前
123
126
  abs db status # 库体检:七层分布 / 悬挂链接 / 已入库项目
124
127
  abs db add # 把当前项目登记进 ~/.abs/projects.json
128
+ abs db evolve # 知识进化体检:被推翻/断链/主题堆积/僵化页
125
129
  abs summon [--mark|--status] # 会话总结托底:列出未总结 / 有更新的会话(不写盘)
126
130
  abs resolve <页名或id> # 反查页面路径(页改名后 id 不变)
127
131
  abs supersede <页名> [--by <新页>] # 标记经验已失效(不删文件,保留历史;query 默认不再返回)
package/bin/abs.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // abs <cmd> [args]
4
4
  // 命令: init / board / status / load / task / install / uninstall / help
5
5
  import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede, cmdReview } from '../src/store.js';
6
- import { cmdDbRebuild, cmdDbSearch, cmdDbStatus, addProject } from '../src/brain/db-cmd.js';
6
+ import { cmdDbRebuild, cmdDbSearch, cmdDbStatus, cmdDbEvolve, addProject } from '../src/brain/db-cmd.js';
7
7
  import { cmdCapture } from '../src/brain/capture.js';
8
8
  import { cmdSummon } from '../src/brain/summon-cmd.js';
9
9
  import { setUser, getUser, userConfigPath } from '../src/userconfig.js';
@@ -249,7 +249,7 @@ const usage = `abs — 跨会话记忆工具(.brain/ 图谱)
249
249
  summon [--mark|--status] 会话总结托底: 列出未总结/有更新的会话 (不写盘)
250
250
  concept <slug> --title … 建概念页骨架
251
251
  query <词…> [--all] 检索知识页 (多词 OR)
252
- db rebuild|search|status 图数据库: 建库 / 亮度搜索 / 体检
252
+ db rebuild|search|status|evolve 图数据库: 建库 / 亮度搜索 / 体检 / 进化体检
253
253
  resolve <页名…> 反查页面路径
254
254
  supersede <页名> [--by 页] 标经验已失效
255
255
  review [--accept 页…|--reject 页…] 待确认页队列 (draft 升 active/否决)
@@ -556,7 +556,7 @@ async function main() {
556
556
  case 'uninstall': {
557
557
  // --help 已在 switch 前统一拦截(subUsage)。
558
558
  if (cmd === 'install') {
559
- await runInstall({ agent: opts.agent, mcp: opts.mcp !== false, skill: opts.skill !== false, yes: opts.yes });
559
+ await runInstall({ agent: opts.agent, all: !!opts.all, mcp: opts.mcp !== false, skill: opts.skill !== false, yes: opts.yes });
560
560
  } else {
561
561
  await runUninstall({ agent: opts.agent, yes: opts.yes });
562
562
  }
@@ -581,12 +581,13 @@ async function main() {
581
581
  if (sub === 'rebuild') { console.log(await cmdDbRebuild({ dir: opts.dir })); break; }
582
582
  if (sub === 'search') { console.log(await cmdDbSearch({ query: rest.join(' '), limit: Number(opts.limit) || 8 })); break; }
583
583
  if (sub === 'status') { console.log(await cmdDbStatus()); break; }
584
+ if (sub === 'evolve') { console.log(await cmdDbEvolve({ staleDays: Number(opts['stale-days']) || 90 })); break; }
584
585
  if (sub === 'add') {
585
586
  const r = await addProject(opts.dir || process.cwd());
586
587
  console.log(`✓ 已登记项目: ${r}\n 建库: abs db rebuild`);
587
588
  break;
588
589
  }
589
- console.log('用法: abs db rebuild | search <词> | status | add [--dir <项目根>]');
590
+ console.log('用法: abs db rebuild | search <词> | status | evolve | add [--dir <项目根>]');
590
591
  break;
591
592
  }
592
593
  case 'query': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,6 +19,7 @@ import { cmdLog } from '../note.js';
19
19
  import { clip, slugOf } from '../text.js';
20
20
  import { LAYERS, layerOf, DEFAULT_LAYER } from './layers.js';
21
21
  import { autoIndex } from './db-cmd.js';
22
+ import { summonTail } from './summon.js';
22
23
 
23
24
  /** 层 → 落点目录。L1/L6 不立页,由下面的分支单独处理。 */
24
25
  const LAYER_DIR = {
@@ -153,18 +154,22 @@ export async function cmdCapture({ dir, text, layer, tags, title }) {
153
154
  const slug = file.replace(/\.md$/, '');
154
155
  const section = dirName === 'concepts' ? 'Concepts' : dirName === 'sessions' ? 'Sessions' : 'Sources';
155
156
  await registerInIndex(root, section, slug, head);
156
- await cmdLog({ dir: root, title: clean, kind: 'note' });
157
+ await cmdLog({ dir: root, title: clean, kind: 'note', summon: false });
157
158
 
158
159
  // 落盘即入库 —— 否则用户以为记下了,检索却空手而归(静默失效)。
159
160
  // autoIndex 永不抛错也不阻塞:库坏了只是检索旧一点,不能让记笔记失败。
160
161
  const idx = await autoIndex();
161
162
 
163
+ // 每次总结都顺手查一遍会话历史,避免漏下没总结的会话(2026-11-02 用户要求)。
164
+ // 只给线索不写盘 —— 落哪一条仍是人的判断。
165
+ const tail = await summonTail(root);
166
+
162
167
  return [
163
168
  `✓ 已落入 L${L} ${meta.name}层 → ${dirName}/${file}`,
164
169
  ` ${clip(clean, 60)} ${atTag(who)}`,
165
170
  ` 该层衰减 ${meta.decay}(${meta.note})`,
166
171
  idx.ok ? ' 已同步入库,abs db search 可查到' : ` (未入库:${idx.reason})`,
167
- ].join('\n');
172
+ ].join('\n') + tail;
168
173
  }
169
174
 
170
175
  export { LAYERS };
@@ -6,12 +6,72 @@
6
6
  import { promises as fs } from 'node:fs';
7
7
  import { join, resolve, dirname } from 'node:path';
8
8
  import { findBrainRoot, brainPath } from '../index.js';
9
- import { openDb, rebuild, search, illuminate, rulesOf, stats, ensureDbDir, dbPath } from './db.js';
9
+ import { openDb, rebuild, search, illuminate, rulesOf, stats, ensureDbDir, dbPath, lastOpenRebuilt } from './db.js';
10
10
  import { LAYERS } from './layers.js';
11
11
 
12
12
  const PAGE_DIRS = ['concepts', 'entities', 'sources', 'syntheses', 'sessions'];
13
13
  const FLAT_FILES = ['index.md', 'todo.md', 'log.md'];
14
14
 
15
+ /** 项目根路径 → 项目名(入库时的 project 字段)。
16
+ *
17
+ * 取路径末段。为何不引入 UUID(2026-11-02 评估):项目名是给人看的标识,
18
+ * 出现在每条检索结果里;UUID 会让输出不可读。末段同名的风险用 `db status` 暴露,
19
+ * 不做静默处理。
20
+ */
21
+ function projectNameOf(root) {
22
+ return String(root).split('/').filter(Boolean).pop() || 'unknown';
23
+ }
24
+
25
+ /** 给一批项目根算出**互不重名**的项目名。
26
+ *
27
+ * 为何需要(2026-11-02 实测):项目名默认取路径末段,而机器上同时存在
28
+ * ~/.agents 与 ~/Documents/软件/.agents,两者末段都是 `.agents` →
29
+ * 入库时撞 UNIQUE(project, dir, slug) 约束,整个 rebuild 失败。
30
+ *
31
+ * 消歧规则:重名的那些改用「父目录-末段」,仍重名就继续往上加,直到唯一。
32
+ * 只给重名的项目加前缀 —— 没重名的保持短名(检索结果里读起来更清楚)。
33
+ */
34
+ export function uniqueProjectNames(roots) {
35
+ const seg = (p) => String(p).split('/').filter(Boolean);
36
+ const base = new Map(roots.map((r) => [r, seg(r).pop() || 'unknown']));
37
+ const groups = new Map();
38
+ for (const [r, n] of base) {
39
+ if (!groups.has(n)) groups.set(n, []);
40
+ groups.get(n).push(r);
41
+ }
42
+ const out = new Map();
43
+ for (const [name, list] of groups) {
44
+ if (list.length === 1) { out.set(list[0], name); continue; }
45
+ // 重名 → 逐层加父目录前缀,直到组内唯一
46
+ for (let depth = 2; depth <= 4; depth++) {
47
+ const cand = new Map();
48
+ for (const r of list) {
49
+ const s = seg(r);
50
+ cand.set(r, s.slice(Math.max(0, s.length - depth)).join('-'));
51
+ }
52
+ const vals = [...cand.values()];
53
+ if (new Set(vals).size === vals.length) {
54
+ for (const [r, v] of cand) out.set(r, v);
55
+ break;
56
+ }
57
+ if (depth === 4) for (const [r, v] of cand) out.set(r, v); // 兜底:认了
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** 当前所在项目名。查询时用它决定「读哪几条铁律」。
64
+ *
65
+ * 为何要单独一个函数(2026-11-02):原来只在 cmdDbSearch 里内联算,
66
+ * 与 rebuild 用的算法是两份代码 —— 任一边改了项目名规则,铁律就会读错项目的。
67
+ * 判据:项目名的算法只能有一处。 */
68
+ async function currentProjectName(dir) {
69
+ if (process.env.ABS_PROJECT) return process.env.ABS_PROJECT;
70
+ const from = dir || process.cwd();
71
+ const root = await findBrainRoot(from);
72
+ return root ? projectNameOf(root) : '';
73
+ }
74
+
15
75
  /** 扫一个项目的 .brain/ → 页面数组(含 index/todo/log 三份平铺文件)。 */
16
76
  async function scanProject(root, project) {
17
77
  const out = [];
@@ -80,14 +140,19 @@ export async function cmdDbRebuild({ dir } = {}) {
80
140
  return '✗ 没有可入的项目。先在项目根跑: abs db add 或 abs db rebuild';
81
141
  }
82
142
 
143
+ // 先收集所有有效根,再统一算项目名 —— 名字要去重,必须看到全局才知道谁重名
144
+ const found = [];
145
+ for (const root of roots) {
146
+ const r = await findBrainRoot(root);
147
+ if (r) found.push(r); // 登记了但目录没了 → 跳过,不报错
148
+ }
149
+ const names = uniqueProjectNames([...new Set(found)]);
83
150
  const pages = [];
84
151
  const used = [];
85
- for (const root of roots) {
86
- const found = await findBrainRoot(root);
87
- if (!found) continue; // 登记了但目录没了 → 跳过,不报错
88
- const project = found.split('/').filter(Boolean).pop() || 'unknown';
89
- pages.push(...await scanProject(found, project));
90
- used.push(`${project} (${found})`);
152
+ for (const f of [...new Set(found)]) {
153
+ const project = names.get(f);
154
+ pages.push(...await scanProject(f, project));
155
+ used.push(`${project} (${f})`);
91
156
  }
92
157
  if (!pages.length) return '✗ 登记的项目都没有 .brain/ 图谱,无可入库内容。';
93
158
 
@@ -122,9 +187,7 @@ export async function cmdDbSearch({ query, limit = 8 } = {}) {
122
187
  const h = openDb(path);
123
188
  try {
124
189
  // L1 铁律永远先出 —— 「不可逾越」的代码保证,不靠读到文本才遵守
125
- const project = process.env.ABS_PROJECT
126
- || (await findBrainRoot(process.cwd()) || '').split('/').filter(Boolean).pop()
127
- || '';
190
+ const project = await currentProjectName();
128
191
  const rules = rulesOf(h, project);
129
192
 
130
193
  const seeds = search(h, q, { limit: 12 });
@@ -136,17 +199,27 @@ export async function cmdDbSearch({ query, limit = 8 } = {}) {
136
199
  for (const r of rules) out.push(`- ${r}`);
137
200
  }
138
201
  if (!lit.length) {
139
- out.push('', '(无命中。库可能是旧的:abs db rebuild 重扫)');
202
+ out.push('', lastOpenRebuilt()
203
+ ? '(库文件曾损坏被清空,现在没有内容:跑 abs db rebuild 重建)'
204
+ : '(无命中。库可能是旧的:abs db rebuild 重扫)');
140
205
  return out.join('\n');
141
206
  }
142
207
  out.push('', '--- 按亮度排序 ---');
143
208
  for (const p of lit) {
144
- const bar = '█'.repeat(Math.max(1, Math.round(Math.min(p.bright, 3) / 3 * 12)));
209
+ // 亮度现在是 0.3~1.0(不再是早期可能到 165 万的异常值),
210
+ // 条长按 1.0 满格算:0.3 → 4 格,1.0 → 12 格。
211
+ const bar = '█'.repeat(Math.max(1, Math.round(Math.min(p.bright, 1) * 12)));
145
212
  const where = [p.project, p.dir, p.slug].filter(Boolean).join('/');
146
213
  const tag = p.from === 'spread' ? ' ↝' : '';
147
- out.push(`${bar} ${p.bright.toFixed(2)} L${p.layer} ${where}${tag}`);
214
+ // 跨项目的结果要显式标出来 —— 用户得知道「这条是别的项目的经验」,
215
+ // 否则会以为是自己项目的结论(判据、上下文都可能不同)。
216
+ const other = project && p.project && p.project !== project ? ' [跨项目]' : '';
217
+ out.push(`${bar} ${p.bright.toFixed(2)} L${p.layer} ${where}${tag}${other}`);
148
218
  }
149
219
  out.push('', '(L1 铁律恒在最前;↝ 表示由邻居扩散点亮,非直接命中)');
220
+ if (project) {
221
+ out.push(`(铁律只显示本项目 "${project}" 的 —— 别的项目的铁律不外借)`);
222
+ }
150
223
  return out.join('\n');
151
224
  } finally {
152
225
  h.close();
@@ -161,9 +234,11 @@ export async function cmdDbStatus() {
161
234
 
162
235
  const h = openDb(path);
163
236
  try {
237
+ const rebuilt = lastOpenRebuilt();
164
238
  const s = stats(h);
165
239
  const out = [
166
240
  `📀 库: ${path}`,
241
+ ...(rebuilt ? ['⚠ 库文件曾损坏,已自动清空重建 —— 跑 abs db rebuild 恢复内容'] : []),
167
242
  `节点 ${s.nodes} | 边 ${s.edges} | 悬挂链接 ${s.dangling} | 断点词 ${s.anchors} | 铁律 ${s.rules}`,
168
243
  '',
169
244
  '--- 七层分布 ---',
@@ -231,14 +306,31 @@ async function cmdDbRebuildQuiet() {
231
306
  const here = await findBrainRoot(process.cwd());
232
307
  const roots = [...new Set([...registered, ...(here ? [here] : [])])];
233
308
  if (!roots.length) return;
234
- const pages = [];
309
+ const found = [];
235
310
  for (const root of roots) {
236
- const found = await findBrainRoot(root);
237
- if (!found) continue;
238
- const project = found.split('/').filter(Boolean).pop() || 'unknown';
239
- pages.push(...await scanProject(found, project));
311
+ const r = await findBrainRoot(root);
312
+ if (r) found.push(r);
240
313
  }
314
+ const uniq = [...new Set(found)];
315
+ const names = uniqueProjectNames(uniq);
316
+ const pages = [];
317
+ for (const f of uniq) pages.push(...await scanProject(f, names.get(f)));
241
318
  if (!pages.length) return;
242
319
  const h = openDb(dbPath());
243
320
  try { rebuild(h, pages); } finally { h.close(); }
244
321
  }
322
+
323
+ /** `abs db evolve` — 知识自我进化的体检:哪些被推翻、哪些堆积、哪些该合并。 */
324
+ export async function cmdDbEvolve({ staleDays = 90 } = {}) {
325
+ const path = dbPath();
326
+ const exists = await fs.stat(path).then(() => true).catch(() => false);
327
+ if (!exists) return `库不存在: ${path}\n 先跑: abs db rebuild`;
328
+
329
+ const h = openDb(path);
330
+ try {
331
+ const { evolveSignals, renderEvolve } = await import('./evolve.js');
332
+ return renderEvolve(evolveSignals(h, { staleDays }), { staleDays });
333
+ } finally {
334
+ h.close();
335
+ }
336
+ }
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 = 3;
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,11 @@ 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 '',
69
81
  UNIQUE(project, dir, slug)
70
82
  );
71
83
  CREATE INDEX IF NOT EXISTS i_nodes_layer ON nodes(layer);
@@ -129,14 +141,35 @@ export function bigrams(s) {
129
141
  const seg = m[0];
130
142
  for (let i = 0; i + 2 <= seg.length; i++) out.push(seg.slice(i, i + 2));
131
143
  }
132
- // 单个汉字:query 是「锁」「锁」这类一字词时,2-gram 切不出来,
133
- // 上面循环对 1 字串一次都不执行 → 恒返回空 → 搜不到(实测「锁」0 命中)。
134
- // 补成单字 gram,存储侧同样产出它(见下)。
135
144
  for (const m of text.matchAll(/[\u4e00-\u9fa5]/g)) out.push(m[0]);
136
145
  for (const m of text.matchAll(/[A-Za-z][A-Za-z0-9_-]{1,}/g)) out.push(m[0].toLowerCase());
137
146
  return [...new Set(out)];
138
147
  }
139
148
 
149
+ /** 按「词」切 2-gram,不加单字。用于**查询侧**构造 MATCH 表达式。
150
+ *
151
+ * 为何要和平分两套(2026-11-02 深度测试发现):
152
+ * 把单字也放进查询表达式会放大误命中 —— 实测查 `zzzz不存在zzz` 时,
153
+ * gram 里含「不」「存」「在」,任何提到这三个字的页都会命中,
154
+ * 于是「一个根本不存在的词」返回了 7 页。这不是模糊匹配有用,是噪音。
155
+ *
156
+ * 存储侧仍保留单字(bigrams 不动):这样查询本身**就是一个字**时
157
+ * (如「锁」),还能通过单字 gram 命中;而多字查询不会靠单字瞎连。
158
+ * 两侧不要求同一函数 —— 要求的是「查询产出的每个 gram 在存储侧都存在」,
159
+ * 而存储侧是超集,恒成立。 */
160
+ export function queryBigrams(s) {
161
+ const text = String(s || '');
162
+ const out = new Set();
163
+ for (const m of text.matchAll(/[\u4e00-\u9fa5]{2,}/g)) {
164
+ const seg = m[0];
165
+ for (let i = 0; i + 2 <= seg.length; i++) out.add(seg.slice(i, i + 2));
166
+ }
167
+ // 整条查询只有单个汉字时才用单字 gram(「锁」这种一字查询仍能搜到)
168
+ if (out.size === 0 && /^[\u4e00-\u9fa5]$/.test(text.trim())) out.add(text.trim());
169
+ for (const m of text.matchAll(/[A-Za-z][A-Za-z0-9_-]{1,}/g)) out.add(m[0].toLowerCase());
170
+ return [...out];
171
+ }
172
+
140
173
  /** 丢掉所有表(版本升级用)。库是派生品,删了由 rebuild 从 markdown 重建。 */
141
174
  function dropAll(db) {
142
175
  for (const r of db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all()) {
@@ -146,6 +179,7 @@ function dropAll(db) {
146
179
 
147
180
  /** 打开(不存在就建)库。返回 { db, close }。 */
148
181
  export function openDb(path = dbPath()) {
182
+ _rebuiltAfterCorruption = false;
149
183
  let db;
150
184
  try {
151
185
  db = new DatabaseSync(path);
@@ -172,6 +206,24 @@ export function openDb(path = dbPath()) {
172
206
  }
173
207
  } catch (e) {
174
208
  try { db.close(); } catch { /* 已关 */ }
209
+
210
+ // 库文件根本不是 SQLite / 结构彻底坏掉 → 删掉重建。
211
+ // 为何不报错让用户手删(2026-11-02 深度测试发现):库是**派生物**
212
+ // (markdown 才是真相),删了由 rebuild 从 .brain/ 全量重扫,内容不丢。
213
+ // 原实现只报「库结构初始化失败: file is not a database」,而 rebuild 走同一条
214
+ // 打开路径 —— 于是用户被卡死:修不好也删不掉(不知道删哪),只能去查源码。
215
+ if (/not a database|file is encrypted|malformed/i.test(e.message || '')) {
216
+ try {
217
+ for (const suffix of ['', '-wal', '-shm']) {
218
+ try { unlinkSync(`${path}${suffix}`); } catch { /* 不存在 */ }
219
+ }
220
+ const h = openDb(path); // 文件已删,重来一次走全新库
221
+ _rebuiltAfterCorruption = true; // 告知调用方:刚重建过,库是空的
222
+ return h;
223
+ } catch (e2) {
224
+ throw new DbError(`库文件损坏且无法重建 ${path}: ${e2.message}`, e2);
225
+ }
226
+ }
175
227
  if (e instanceof DbError) throw e;
176
228
  throw new DbError(`库结构初始化失败: ${e.message}`, e);
177
229
  }
@@ -192,7 +244,8 @@ function metaOf(body) {
192
244
  const title = (t.match(/^title:\s*(.+)$/m) || [])[1]?.trim() || '';
193
245
  const tags = (t.match(/^tags:\s*(.+)$/m) || [])[1]?.replace(/^\[|\]$/g, '').trim() || '';
194
246
  const st = (t.match(/^status:\s*(.+)$/m) || [])[1]?.trim() || 'active';
195
- return { title, tags, status: st };
247
+ const sup = (t.match(/^superseded-by:\s*(.+)$/m) || [])[1]?.trim() || '';
248
+ return { title, tags, status: st, supersededBy: sup };
196
249
  }
197
250
 
198
251
  /** 全量重建:清表 → 重扫所有注册项目 → 重建边与断点词。
@@ -207,8 +260,8 @@ export function rebuild(handle, pages) {
207
260
  db.exec('DELETE FROM anchors; DELETE FROM edges; DELETE FROM nodes; DELETE FROM rules; DELETE FROM nodes_bigram;');
208
261
  try { db.exec("INSERT INTO fts(fts) VALUES('delete-all')"); } catch { /* 空表时无可删 */ }
209
262
 
210
- const insNode = db.prepare(`INSERT INTO nodes(project,dir,slug,layer,title,tags,status,body,mtime,is_ledger)
211
- VALUES(?,?,?,?,?,?,?,?,?,?)`);
263
+ const insNode = db.prepare(`INSERT INTO nodes(project,dir,slug,layer,title,tags,status,body,mtime,is_ledger,superseded_by)
264
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)`);
212
265
  const insGram = db.prepare('INSERT OR REPLACE INTO nodes_bigram(id,g) VALUES(?,?)');
213
266
  const insEdge = db.prepare('INSERT OR IGNORE INTO edges(src,dst,raw) VALUES(?,?,?)');
214
267
  const insAnchor = db.prepare('INSERT OR IGNORE INTO anchors(word,node) VALUES(?,?)');
@@ -217,11 +270,11 @@ export function rebuild(handle, pages) {
217
270
  // 第一遍:建节点,拿 id 映射 key = `project/dir/slug`
218
271
  const idOf = new Map();
219
272
  for (const p of pages) {
220
- const { title, tags, status } = metaOf(p.body);
273
+ const { title, tags, status, supersededBy } = metaOf(p.body);
221
274
  const { layer } = resolveLayer(p.body, p.dir);
222
275
  const ledger = (p.dir === '' && LEDGER_SLUGS.has(p.slug)) ? 1 : 0;
223
276
  const r = insNode.run(p.project, p.dir, p.slug, layer, title, tags, status,
224
- p.body, p.mtime || now, ledger);
277
+ p.body, p.mtime || now, ledger, supersededBy);
225
278
  const id = Number(r.lastInsertRowid);
226
279
  // FTS 源:正文 + 标题 + tags 切 2-gram。查询侧必须用同一个 bigrams()。
227
280
  insGram.run(id, bigrams([p.slug, title, tags, p.body].join('\n')).join(' '));
@@ -256,19 +309,38 @@ export function rebuild(handle, pages) {
256
309
  const isLedger = p.dir === '' && (p.slug === 'todo' || p.slug === 'log');
257
310
  if (isLedger) continue;
258
311
  for (const word of anchorsOf(p.body)) {
259
- insAnchor.run(word, srcId);
312
+ const { project: explicitProj, page } = parseAnchor(word);
313
+ // 断点词(L6)只记页名,不记 @项目前缀 —— 那是链接语法不是索引词
314
+ insAnchor.run(page, srcId);
260
315
  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);
316
+ let dstId;
317
+ if (explicitProj) {
318
+ // 显式跨项目:只在这个项目里找,找不到就是悬挂,不退回全局兜底
319
+ // (退回会静默连到别的项目,正是这个语法要避免的)
320
+ dstId = idOf.get(`${explicitProj}/concepts/${page}`)
321
+ ?? idOf.get(`${explicitProj}/entities/${page}`)
322
+ ?? idOf.get(`${explicitProj}/sessions/${page}`)
323
+ ?? idOf.get(`${explicitProj}/syntheses/${page}`);
324
+ if (dstId === undefined) stat.crossProjectMissing = (stat.crossProjectMissing || 0) + 1;
325
+ } else {
326
+ // 就近优先:本项目 → 再全局
327
+ dstId = idOf.get(`${p.project}/concepts/${page}`)
328
+ ?? idOf.get(`${p.project}/entities/${page}`)
329
+ ?? idOf.get(`${p.project}/sessions/${page}`)
330
+ ?? findGlobal(idOf, page);
331
+ }
266
332
  if (dstId === undefined) {
267
- insEdge.run(srcId, 0, word);
268
- stat.dangling++;
333
+ // 平铺文件名(index/todo/log)当链接目标是**讨论语法的示例文本**,
334
+ // 不是真链接 —— 实测 .brain 里 `[[todo]]` 出现在「lint 误报它死链」这句里。
335
+ // 这类不记悬挂(它们不是死链,是正文引用了文件名)。
336
+ if (!LEDGER_SLUGS.has(page)) {
337
+ insEdge.run(srcId, 0, word);
338
+ stat.dangling++;
339
+ }
269
340
  } else {
270
341
  insEdge.run(srcId, dstId, word);
271
342
  stat.edges++;
343
+ if (explicitProj) stat.crossProject = (stat.crossProject || 0) + 1;
272
344
  }
273
345
  }
274
346
  }
@@ -284,8 +356,32 @@ export function rebuild(handle, pages) {
284
356
  return stat;
285
357
  }
286
358
 
287
- /** 同名页全局兜底:取第一个命中的(跨项目引用靠 @前缀 显式写,这里只做兜底)。 */
359
+ /** 解析双链目标:`[[@项目/页面]]` → 显式跨项目;`[[页面]]` → 就近。
360
+ *
361
+ * 为何要这个语法(2026-11-02 用户定「跨项目双链连接」):
362
+ * 默认就近(本项目优先)能覆盖绝大多数情况,但「我要明确引用另一个项目的某页」
363
+ * 就没法表达 —— 只能靠全局兜底,而那会撞上同名页。
364
+ * `@` 前缀让跨项目引用显式,读页面时也一眼看得出「这条链出了项目」。
365
+ *
366
+ * 返回 { project: '项目名'|null, page: '页名' }。
367
+ */
368
+ export function parseAnchor(word) {
369
+ const w = String(word || '').trim();
370
+ const m = w.match(/^@([^/\\]+)[/\\](.+)$/);
371
+ if (m) return { project: m[1].trim(), page: m[2].trim() };
372
+ return { project: null, page: w };
373
+ }
374
+
375
+ /** 同名页全局兜底:取第一个命中的(跨项目引用靠 @前缀 显式写,这里只做兜底)。
376
+ *
377
+ * 必须排除两类同名目标(2026-11-02 实测踩到):
378
+ * · 平铺文件 index/todo/log —— 每个项目都有一份,兜底会连到别的项目去。
379
+ * 实测 `[[index]]` 把 agent_brain_sync 的页连到了 pA/index,纯误连。
380
+ * · 流水页(dir 为空)同理。
381
+ * 判据:兜底只在「知识页」之间发生,索引/流水不算知识页。
382
+ */
288
383
  function findGlobal(idOf, word) {
384
+ if (LEDGER_SLUGS.has(word)) return undefined;
289
385
  for (const [k, v] of idOf) {
290
386
  if (k.endsWith(`/${word}`)) return v;
291
387
  }
@@ -299,25 +395,96 @@ function findGlobal(idOf, word) {
299
395
  * 返回 [{ id, project, dir, slug, title, layer, score }],按 FTS 相关度排序。 */
300
396
  export function search(handle, query, { limit = 20 } = {}) {
301
397
  const { db } = handle;
302
- const grams = bigrams(query);
303
- // 查询切成 2-gram 后用 OR 连 —— 越多 gram 命中越相关(bm25 自己会排序)。
398
+ const grams = queryBigrams(query);
304
399
  if (!grams.length) return [];
305
400
  const expr = grams.map((g) => `"${g.replace(/"/g, '""')}"`).join(' OR ');
401
+ // 命中门槛:按 gram 的**文档频率加权**算覆盖率,不再用「命中几个」的平均数。
402
+ //
403
+ // 为何要加权(2026-11-02 深度测试发现,两轮才修对):
404
+ // FTS 的 OR 语义是「命中任意 gram 就算」。先试「命中数 ≥ 一半」,
405
+ // 但中文高频组合会绕过它 —— 查 `zzzz不存在zzz` 时,「不存在」切出
406
+ // [不存, 存在] 两个 gram,命中 2/4 就达标,而「不存在」在图谱里到处都是。
407
+ // 实测真实图谱:`验证` 出现在 84% 的页里、`关联` 79%、`连接` 79% ——
408
+ // 这些 gram 没有区分度,却和稀有 gram 等权。
409
+ // 现在每个 gram 的权重 = log((总页数+1)/(出现页数+0.5)):
410
+ // 稀有 gram(如专有名词)权重高,常见虚词组合权重低到几乎不计。
411
+ // 覆盖率 = 命中 gram 的权重和 / 查询 gram 的总权重,低于 MIN_COVER 丢弃。
412
+ const MIN_COVER = 0.35;
413
+ const totalNodes = db.prepare('SELECT count(*) c FROM nodes_bigram').get().c || 1;
414
+ const df = new Map();
415
+ for (const r of db.prepare('SELECT g FROM nodes_bigram').all()) {
416
+ for (const g of String(r.g).split(' ')) df.set(g, (df.get(g) || 0) + 1);
417
+ }
418
+ const wOf = (g) => {
419
+ const d = df.get(g) || 0;
420
+ return d === 0 ? 3 : Math.log((totalNodes + 1) / (d + 0.5)); // 库里没有的 gram 权重最高
421
+ };
422
+ const wantWeight = grams.reduce((a, g) => a + wOf(g), 0) || 1;
306
423
  try {
424
+ // 取 g 列才能数命中数;多取一些候选,过滤后仍可能不够 limit
307
425
  const rows = db.prepare(`
308
426
  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
427
+ bm25(fts) AS rank, b.g
428
+ FROM fts
429
+ JOIN nodes_bigram b ON b.id = fts.rowid
430
+ JOIN nodes n ON n.id = fts.rowid
311
431
  WHERE fts MATCH ? AND n.is_ledger = 0
432
+ -- 被推翻的经验不出现在检索里。
433
+ -- 它的存在意义是「别再用它」,而不是回答「我上次怎么解决 X」——
434
+ -- 那会拿到一个已知错误的答案。与 abs query 的既有判据一致。
435
+ AND n.status != 'superseded'
312
436
  ORDER BY rank
313
437
  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) }));
438
+ `).all(expr, Math.max(limit * 4, 40));
439
+
440
+ const kept = rows.map((r) => {
441
+ const hitGrams = grams.filter((q) => String(r.g).includes(q));
442
+ const cover = hitGrams.reduce((a, g) => a + wOf(g), 0) / wantWeight;
443
+ return { ...r, cover, hit: hitGrams.length };
444
+ }).filter((r) => r.cover >= MIN_COVER);
445
+ if (!kept.length) return [];
446
+
447
+ // bm25 返回**负值且越负越相关**。归一成 0.3~1.0 的亮度,保证「最相关 = 1.0」。
448
+ //
449
+ // 坑(2026-11-02 深度测试发现):原来分母取 `kept[最后一条].rank` —— 而 kept 按
450
+ // rank 升序(第一行最相关),最后一条恰恰是**最不相关**的那条,其 rank 接近 0。
451
+ // 实测一个 4 条结果的查询里,最相关那条亮度算出 1168694.07(= |−2.42| / 2e-6),
452
+ // 输出一行 165 万的亮度条。分母必须取最相关的那条(第一条)。
453
+ // 种子亮度里要体现「层」—— 否则分层只在扩散时起作用,种子仍按纯字面排。
454
+ // 实测(2026-11-02,14 个项目 586 页):查「docker」时三个 sessions/ 流水页
455
+ // 排在最前,压过真正讲了 docker 的知识页 —— 流水页正文长、词频高,bm25 天然占优,
456
+ // 但对「我上次怎么解决 X」没有价值。
457
+ // 权重 = 1 - decay:L3 工具 0.6、L4 认知 0.4、L5 会话 0.25、L7 混沌 0.05。
458
+ // L1/L2 不参与(铁律不立页)。
459
+ const layerWeight = (layer) => Math.max(0.05, 1 - decayOf(layer));
460
+ const best = Math.abs(kept[0].rank) || 1e-9;
461
+ // 亮度 = 档位 × 档内名次。
462
+ // 档位由加权覆盖率决定(0.6~1.0):全命中最高,勉强沾边压到 0.6 档再往下。
463
+ // 档内名次由相对 bm25 决定(0.1~1.0):覆盖率相同但 bm25 不同的页仍可分。
464
+ // 下限 0.3 保留 —— 勉强命中的种子仍要能向外扩散,只是传得更弱。
465
+ return kept.slice(0, limit).map(({ g: _g, ...r }) => {
466
+ const rel = Math.min(1, Math.abs(r.rank) / best);
467
+ const band = 0.6 + 0.4 * r.cover;
468
+ // 亮度保持 0.3~1.0 的可读区间,**不乘层权重**。
469
+ // 坑(2026-11-02 实测):把层权重乘进亮度后,L4 的满分页只剩 0.40,
470
+ // 输出里一片 0.3~0.5,用户看不出谁更相关 —— 亮度失去了「一眼看强弱」的作用。
471
+ // 层的作用改由**排序**承担(见下面 sort),亮度回归「这一页与查询多贴」本身。
472
+ const bright = Math.max(0.3, Math.min(1, band * (0.1 + 0.9 * rel)));
473
+ return { ...r, bright };
474
+ }).sort((a, b) => {
475
+ // 排序:层优先,同层再看亮度。
476
+ // 为何层要排第一(2026-11-02,586 页实测):查「静默失效」的前三名全是 L7
477
+ // 混沌页(临时笔记),字面 cover=1.00 但内容与主题无关;
478
+ // 而 L4 认知层里真正讲这个主题的页被压到后面。
479
+ // 判据:知识层的页即便贴得稍微松一点,也比混沌层的碎片更该先看。
480
+ //
481
+ // 例外:L1/L2 不参与排序(铁律不立页,单独在最前输出)。
482
+ const wa = layerWeight(a.layer);
483
+ const wb = layerWeight(b.layer);
484
+ const sa = wa * a.bright;
485
+ const sb = wb * b.bright;
486
+ return (sb - sa) || (b.bright - a.bright) || (a.id - b.id);
487
+ });
321
488
  } catch {
322
489
  // 方言/语法问题不能让搜索整体挂掉 —— 退化成空种子,由调用方决定提示
323
490
  return [];
@@ -377,10 +544,20 @@ export function illuminate(handle, seeds, { hops = HOPS, limit = 10 } = {}) {
377
544
 
378
545
  const out = [];
379
546
  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);
547
+ // 扩散出口也要排除 superseded —— 入口(search)排了,出口不排就绕过去:
548
+ // 实测旧结论页自己不出现在种子里,却被邻居扩散点亮(亮度 0.24 带 ↝)。
549
+ const n = db.prepare("SELECT id,project,dir,slug,title,layer FROM nodes WHERE id = ? AND is_ledger = 0 AND status != 'superseded'").get(id);
381
550
  if (n) out.push({ ...n, bright: v.bright, from: v.from });
382
551
  }
383
- return out.sort((a, b) => b.bright - a.bright || a.id - b.id).slice(0, limit);
552
+ // 排序口径必须与 search 一致:层优先,同层再比亮度。
553
+ // 坑(2026-11-02 实测):原来这里只按 bright 排,把 search 已经排好的层序冲掉了 ——
554
+ // 于是 L7 混沌页(bright 1.00)又跑到 L4 认知页(bright 0.95)前面。
555
+ // 层权重在这里再乘一次,为的是「扩散进来的弱页不会因为某个邻居强就压过知识页」。
556
+ const weight = (layer) => Math.max(0.05, 1 - decayOf(layer));
557
+ return out
558
+ .sort((a, b) => (weight(b.layer) * b.bright - weight(a.layer) * a.bright)
559
+ || (b.bright - a.bright) || (a.id - b.id))
560
+ .slice(0, limit);
384
561
  }
385
562
 
386
563
  /** L1 铁律:按项目取出。检索任何东西都必须先带上它(「不可逾越」的代码保证)。 */
@@ -0,0 +1,123 @@
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. 被推翻的
26
+ const superseded = all.filter((r) => r.status === 'superseded');
27
+ const danglingSupersede = superseded.filter((r) => {
28
+ if (!r.superseded_by) return true; // 标了推翻但没指向谁
29
+ return !all.some((x) => x.slug === r.superseded_by && x.project === r.project);
30
+ });
31
+
32
+ // 2. 主题堆积:按 tags 里的**业务标签**归类。
33
+ //
34
+ // 坑(2026-11-02 实测):tags 的第一项是**页面类型**(concept/source/session-log/entity),
35
+ // 拿它归类必然把 58 页 concept 报成「一组堆积」—— 那是类型不是主题,全是噪音。
36
+ // 类型词是固定的几个,直接按白名单跳过;主题看第二项起。
37
+ // 另:会话流水页不参与主题堆积判断。
38
+ // 它们的 tags 里带 `todo-archive`/`历史`,按主题归类会把 13 个归档快照
39
+ // 报成「该合并成规律页」—— 但归档快照本来就该各自独立(一天一文件是目录契约)。
40
+ // 判据看 dir:sessions/ 下的都是流水,不是知识页。
41
+ const TYPE_TAGS = new Set(['concept', 'source', 'synthesis', 'entity', 'session-log']);
42
+ const tagBuckets = new Map();
43
+ for (const row of db.prepare("SELECT tags, slug FROM nodes WHERE is_ledger = 0 AND dir != 'sessions' AND tags != ''").all()) {
44
+ const parts = String(row.tags).replace(/^\[|\]$/g, '').split(',').map((s) => s.trim()).filter(Boolean);
45
+ const topic = parts.find((x) => !TYPE_TAGS.has(x.toLowerCase()));
46
+ if (!topic) continue;
47
+ if (!tagBuckets.has(topic)) tagBuckets.set(topic, []);
48
+ tagBuckets.get(topic).push(row.slug);
49
+ }
50
+ const piled = [...tagBuckets.entries()]
51
+ .filter(([, v]) => v.length >= 8) // 一个主题下 8 页以上算堆积
52
+ .sort((a, b) => b[1].length - a[1].length);
53
+
54
+ // 3. 只进不出:有入边但没有出边 —— 说明它是终点,不连回别处
55
+ const sinkOnly = all.filter((r) => (inbound.get(r.id) || 0) > 0 && !(outbound.get(r.id) || 0));
56
+
57
+ // 4. stale:长期没改 + 没人链
58
+ const staleMs = staleDays * 86400_000;
59
+ const stale = all.filter((r) => (now - (r.mtime || 0) > staleMs) && !(inbound.get(r.id) || 0));
60
+
61
+ return {
62
+ total: all.length,
63
+ superseded, danglingSupersede,
64
+ piled, sinkOnly, stale,
65
+ byId, inbound, outbound,
66
+ };
67
+ }
68
+
69
+ /** 渲染成给人看的报告。按类型分组,同类超阈值只报样例。 */
70
+ export function renderEvolve(sig, { sample = 5, staleDays = 90 } = {}) {
71
+ const out = [];
72
+ const trunc = (arr, fmt) => {
73
+ const shown = arr.slice(0, sample).map(fmt);
74
+ if (arr.length > sample) shown.push(` …另有 ${arr.length - sample} 条`);
75
+ return shown;
76
+ };
77
+
78
+ out.push(`📈 图谱进化体检 — ${sig.total} 页`);
79
+ out.push('');
80
+
81
+ // 好消息放前面:推翻说明知识在更新
82
+ if (sig.superseded.length) {
83
+ out.push(`✓ 已推翻 ${sig.superseded.length} 页(知识在更新,不是坏事)`);
84
+ out.push(...trunc(sig.superseded, (r) => ` ${r.slug} → ${r.superseded_by || '(未指向)'}`));
85
+ } else {
86
+ out.push('· 还没有被推翻的页 —— 要么知识很稳,要么没人回头修正');
87
+ }
88
+ out.push('');
89
+
90
+ if (sig.danglingSupersede.length) {
91
+ out.push(`⚠ ${sig.danglingSupersede.length} 页标了推翻但没指向新页(历史断了,看不出被谁取代)`);
92
+ out.push(...trunc(sig.danglingSupersede, (r) => ` ${r.slug}`));
93
+ out.push(' 修: abs supersede <旧页> --by <新页>');
94
+ out.push('');
95
+ }
96
+
97
+ if (sig.piled.length) {
98
+ out.push(`⚠ 主题堆积 ${sig.piled.length} 组(同一主题下页数多,考虑合并成规律页)`);
99
+ out.push(...trunc(sig.piled, ([tag, list]) => ` ${tag}: ${list.length} 页`));
100
+ out.push('');
101
+ }
102
+
103
+ if (sig.sinkOnly.length) {
104
+ out.push(`· 只被链、不链别人 ${sig.sinkOnly.length} 页(是终点,不回连——若它其实是枢纽,该补出链)`);
105
+ out.push(...trunc(sig.sinkOnly, (r) => ` ${r.slug} (入 ${sig.inbound.get(r.id)})`));
106
+ out.push('');
107
+ }
108
+
109
+ if (sig.stale.length) {
110
+ out.push(`· ${staleDays} 天没动且无人链 ${sig.stale.length} 页(可能该删或该接进网络)`);
111
+ out.push(...trunc(sig.stale, (r) => {
112
+ const days = Math.round((Date.now() - r.mtime) / 86400_000);
113
+ return ` ${r.slug} (${days} 天前)`;
114
+ }));
115
+ out.push('');
116
+ }
117
+
118
+ const issues = sig.danglingSupersede.length + sig.piled.length + sig.stale.length;
119
+ out.push(issues === 0
120
+ ? '✓ 没有需要立刻处理的进化问题'
121
+ : `共 ${issues} 处待处理。这只是体检,改不改由你判断 —— 程序不替人做内容决策。`);
122
+ return out.join('\n');
123
+ }
@@ -166,10 +166,12 @@ export async function scanProject(projectRoot, { ledger, now = Date.now() } = {}
166
166
  try { names = (await fs.readdir(dir)).filter((f) => f.endsWith('.jsonl')); } catch {
167
167
  return { error: `找不到会话目录: ${dir}`, fresh: [], changed: [], skipped: [], active: [] };
168
168
  }
169
+ const ownBook = !ledger; // 账本是自己读的(不是调用方给的)
169
170
  const book = ledger || await readLedger();
170
171
  // 已有快照的日子整日跳过 —— 人已经总结过,托底不该再吵。
171
172
  const haveSnapshot = await daysWithSnapshot(join(projectRoot, '.brain', 'sessions'));
172
173
  const fresh = [], changed = [], skipped = [], active = [];
174
+ let touched = false; // 是否补记过 stat(需要回写账本,否则下次又得读全文)
173
175
 
174
176
  for (const name of names.sort()) {
175
177
  const full = join(dir, name);
@@ -177,25 +179,60 @@ export async function scanProject(projectRoot, { ledger, now = Date.now() } = {}
177
179
  try { st = await fs.stat(full); } catch { continue; }
178
180
  if (now - st.mtimeMs < ACTIVE_MS) { active.push({ name, reason: '会话仍在写' }); continue; }
179
181
 
180
- const md5 = await md5Of(full);
181
- if (!md5) continue;
182
+ const rec = book.files[name];
183
+
184
+ // ── 便宜的预筛:文件大小 + mtime 都没变 → 内容必然没变 → 跳过,不读全文。
185
+ // 为何要它(2026-11-01 实测):全读算 md5 在 63 个文件 / 55MB 上是 74ms,
186
+ // 而只读 stat 是 0ms(快 74 倍)。这条路径会被 abs log 每次收尾调用,
187
+ // 且文件数只增不减 —— 不预筛就是「读取侧随规模增长」。
188
+ // 漏判风险为零:改内容必动 mtime,内容变了而 size 与 mtime 都不变不可能发生。
189
+ if (rec && rec.size === st.size && rec.mtime === st.mtimeMs && rec.md5) {
190
+ skipped.push({ name, reason: 'size+mtime 未变' });
191
+ continue;
192
+ }
193
+
194
+ // 已有快照的日子,先看账本里记的 day —— 不必读文件就能判。
195
+ // 实测(2026-11-01):63 个文件里有 41 个属于「已有快照」的日子,
196
+ // 原来要先 digestSession 读全文才知道日期,等于白读 41 次。账本里有 day 时先拦。
197
+ if (rec && rec.day && haveSnapshot.has(rec.day)) {
198
+ skipped.push({ name, reason: `${rec.day} 已有快照` });
199
+ continue;
200
+ }
201
+
182
202
  const d = await digestSession(full);
183
203
  if (!d || !d.users.length) { skipped.push({ name, reason: '无真实用户输入' }); continue; }
184
204
  if (d.day && haveSnapshot.has(d.day)) { skipped.push({ name, reason: `${d.day} 已有快照` }); continue; }
185
205
 
186
- const rec = book.files[name];
187
- if (rec && rec.md5 === md5) { skipped.push({ name, reason: 'md5 一致' }); continue; }
206
+ // 到这里才需要 md5(要么文件变了,要么账本里没有)。
207
+ const md5 = await md5Of(full);
208
+ if (!md5) continue;
209
+ if (rec && rec.md5 === md5) {
210
+ // size/mtime 变了但内容没变(如 touch、备份还原)→ 补记新的 stat,下次走预筛
211
+ book.files[name] = { ...rec, size: st.size, mtime: st.mtimeMs };
212
+ touched = true;
213
+ skipped.push({ name, reason: 'md5 一致(仅 stat 变化)' });
214
+ continue;
215
+ }
188
216
 
189
- const item = { name, md5, day: d.day, digest: d, sizeKB: Math.round(st.size / 1024) };
217
+ const item = { name, md5, day: d.day, digest: d, sizeKB: Math.round(st.size / 1024), size: st.size, mtime: st.mtimeMs };
190
218
  if (rec) changed.push({ ...item, prevMd5: rec.md5 }); else fresh.push(item);
191
219
  }
220
+ // 补记的 stat 要落盘 —— 只改内存等于每次重读全文,预筛白做。
221
+ // 只在自己读的账本上回写:调用方传了 ledger 时,回写时机由它决定
222
+ // (cmdSummon 要等 --mark 才写,免得扫描顺手改了用户的账本)。
223
+ if (touched && ownBook) await writeLedger(book);
224
+
192
225
  return { fresh, changed, skipped, active, dir };
193
226
  }
194
227
 
195
228
  /** 记一笔:这些会话已提取(不管人是否落盘,账本只记「提过了」)。 */
229
+ /** 记一笔:这些会话已提取。同时存 size+mtime —— 下次扫描靠它做便宜预筛,避免全读。 */
196
230
  export function markSummarized(ledger, items, when = new Date().toISOString()) {
197
231
  for (const it of items) {
198
- ledger.files[it.name] = { md5: it.md5, day: it.day, summarizedAt: when };
232
+ ledger.files[it.name] = {
233
+ md5: it.md5, day: it.day, summarizedAt: when,
234
+ size: it.size, mtime: it.mtime,
235
+ };
199
236
  }
200
237
  return ledger;
201
238
  }
@@ -234,3 +271,33 @@ export function renderScan(result, { full = false, limit = 15 } = {}) {
234
271
  }
235
272
  return out.join('\n');
236
273
  }
274
+
275
+ /** 收尾时顺手列出未总结/有更新的会话(给人看的尾巴)。
276
+ *
277
+ * 为何放在这里而不是各调用方(2026-11-02):abs log 与 abs capture 都要用,
278
+ * 两处各写一份必然漂移。失败一律吞掉 —— 记流水/记经验不能因为扫会话而失败。
279
+ *
280
+ * @param {string} root 项目根
281
+ * @param {object} opts { limit } 最多列几场
282
+ * @returns {Promise<string>} 无待总结时返回空串(不占输出)
283
+ */
284
+ export async function summonTail(root, { limit = 8 } = {}) {
285
+ try {
286
+ const r = await scanProject(root);
287
+ const pending = (r.fresh?.length || 0) + (r.changed?.length || 0);
288
+ if (!pending) return '';
289
+ const lines = ['', `--- 本项目有 ${pending} 场会话待总结 ---`];
290
+ for (const it of [...r.fresh, ...r.changed].slice(0, limit)) {
291
+ const tag = it.prevMd5 ? '(有更新)' : '';
292
+ lines.push(` ${it.day} ${it.sizeKB}KB${tag}`);
293
+ for (const u of (it.digest?.users || []).slice(0, 2)) {
294
+ lines.push(` · ${u.replace(/\s+/g, ' ').trim().slice(0, 70)}`);
295
+ }
296
+ }
297
+ if (pending > limit) lines.push(` …另有 ${pending - limit} 场(abs summon 看全量)`);
298
+ lines.push(' 落哪条由你判断: abs capture --layer N "…" | abs summon --mark 记账不再提');
299
+ return '\n' + lines.join('\n');
300
+ } catch {
301
+ return '';
302
+ }
303
+ }
package/src/install.js CHANGED
@@ -919,8 +919,11 @@ export function installSummary() {
919
919
  return HOSTS.map((h) => ` ${h.key.padEnd(12)} ${h.label}`).join('\n');
920
920
  }
921
921
 
922
- export async function runInstall({ agent, mcp = true, skill = true, yes = false } = {}) {
923
- const targets = agent ? [agent] : await pickAgents(yes);
922
+ export async function runInstall({ agent, all = false, mcp = true, skill = true, yes = false } = {}) {
923
+ // all=true 是显式全选的官方入口。
924
+ // 为何要它(2026-11-02):默认改成只装 pi 后,「装到所有宿主」这个能力
925
+ // 必须有明确表达方式 —— 否则测试只能靠 --yes 的旧语义,行为一变就全挂。
926
+ const targets = agent ? [agent] : (all ? Object.keys(INSTALLERS) : await pickAgents(yes));
924
927
  const failed = [];
925
928
  for (const key of targets) {
926
929
  const inst = INSTALLERS[key];
@@ -976,17 +979,28 @@ export async function runUninstall({ agent } = {}) {
976
979
  }
977
980
 
978
981
  // 交互式多选(无 TTY 时回退为全部)
982
+ /** 默认装的宿主。
983
+ *
984
+ * 2026-11-02 用户定:默认只装 pi,其余宿主 opt-in。
985
+ * 理由:装到不用的宿主上会往那边的配置文件里塞 hook/MCP 条目 ——
986
+ * 用户没在用 Claude Code / Codex / OpenCode 时,那是纯污染。
987
+ * 要装别的:abs install --agent claude-code(可逐个装)。
988
+ */
989
+ export const DEFAULT_AGENT = 'pi';
990
+
979
991
  async function pickAgents(yes = false) {
980
- // --yes / 非 TTY: 直接全选,绝不弹交互。
992
+ // --yes / 非 TTY: 装默认宿主(pi),绝不弹交互。
981
993
  // 坑: 曾经只看 isTTY,--yes 被收下却从不使用 —— 在 TTY 里跑
982
994
  // abs install --yes(自动化/脚本)仍会弹提示并挂起等输入。
983
- if (yes || !process.stdin.isTTY) return Object.keys(INSTALLERS);
995
+ if (yes || !process.stdin.isTTY) return [DEFAULT_AGENT];
984
996
  const readline = await import('node:readline/promises');
985
997
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
986
- console.log('选择要安装的智能体 (逗号分隔, 回车=全部):');
998
+ console.log(`选择要安装的智能体 (逗号分隔, 回车=只装 ${DEFAULT_AGENT}):`);
987
999
  console.log(installSummary());
988
1000
  const ans = (await rl.question('> ')).trim();
989
1001
  rl.close();
990
- if (!ans) return Object.keys(INSTALLERS);
1002
+ if (!ans) return [DEFAULT_AGENT];
1003
+ // all / * 作为显式全选的逃生门,避免把「装全部」的能力彻底拿掉
1004
+ if (ans === 'all' || ans === '*') return Object.keys(INSTALLERS);
991
1005
  return ans.split(',').map((s) => s.trim()).filter(Boolean);
992
1006
  }
package/src/note.js CHANGED
@@ -11,6 +11,7 @@ import { today, localStamp, LOG_KINDS } from './todo.js';
11
11
  import { editFile, SKIP } from './lock.js';
12
12
  import { impactOf } from './codegraph.js';
13
13
  import { clip, slugOf } from './text.js';
14
+ import { summonTail } from './brain/summon.js';
14
15
 
15
16
 
16
17
  /** 惰性调 autoIndex —— 断循环 import:note.js ← capture.js ← db-cmd.js ← note.js。
@@ -102,7 +103,7 @@ export async function cmdNote({ dir, text, tags, when, impact, type }) {
102
103
  // index Sources 区登记(锁内幂等:别页已登记则跳过,防并发重复) + log 一行
103
104
  const slug = file.replace(/\.md$/, '');
104
105
  await registerInIndex(root, 'Sources', slug, heading);
105
- await cmdLog({ dir: root, title: clean, kind: 'note' });
106
+ await cmdLog({ dir: root, title: clean, kind: 'note', summon: false });
106
107
  // 落盘即入库(库已存在时)。失败不阻断 —— 见 autoIndex 的边界说明。
107
108
  await autoIndexLazy();
108
109
  return `✓ 经验暂存 → sources/${file}\n ${clean} ${atTag(who)}`;
@@ -260,7 +261,7 @@ export async function registerInIndex(root, section, slug, desc) {
260
261
  });
261
262
  }
262
263
  // ---------- log: 追加工作成果沉淀摘要(用户/AI 主动 abs log "..." 记, 不收工具动作流水) ----------
263
- export async function cmdLog({ dir, title, kind = 'dev' }) {
264
+ export async function cmdLog({ dir, title, kind = 'dev', summon = true }) {
264
265
  const root = await requireBrain(dir || process.cwd());
265
266
  const who = await requireUser(); // 写操作守卫
266
267
  // ★ kind 必须是枚举值(2026-10-05 加):此前无校验,传什么写什么 ——
@@ -294,6 +295,16 @@ export async function cmdLog({ dir, title, kind = 'dev' }) {
294
295
  return { text: lines.join('\n') };
295
296
  });
296
297
  // 重复时不报「✓ 已写入」—— 那是在说假话(用户会以为多了一条)。
297
- if (res === SKIP) return `• 同一分钟已有同一内容,已跳过 → ${p}\n ${line}`;
298
- return `✓ log → ${p}\n ${line}`;
299
- }
298
+ const head = res === SKIP
299
+ ? `• 同一分钟已有同一内容,已跳过 → ${p}\n ${line}`
300
+ : `✓ log → ${p}\n ${line}`;
301
+
302
+ // 落盘顺手深读本项目下的会话:没总结过的、有更新的,一并提出来。
303
+ // 为何挂在 log 上(2026-11-01 用户定):`abs log` 是收尾动作本身,
304
+ // 人在这里才愿意看「还有哪些会话没总结」;平时他不会主动跑 summon。
305
+ // 只给线索不写盘 —— 落哪一条仍是人的判断(见 summon.js 的边界说明)。
306
+ // summon=false 供 note/concept 内部调用时关闭:那些也是写 log,但不是收尾场景。
307
+ const tail = summon ? await summonTail(root) : '';
308
+ return head + tail;
309
+ }
310
+