@fanchao8609/agent_brain_sync 1.29.0 → 1.31.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 DELETED
@@ -1,667 +0,0 @@
1
- // src/brain/db.js — 统一图数据库:所有项目的知识页 + 双链 + 七层标记。
2
- //
3
- // 定位(2026-11-01 用户定,2026-11-01 晚确认):**markdown 是真相,库是派生索引**。
4
- // 人改 .brain/ 的 md,跑 abs db rebuild 让库跟上;库可以随时删掉重建。
5
- // 曾考虑反过来(库为真相、md 为导出),评估后否决:那是为「多端同步」这个
6
- // 尚不存在的需求,废掉人工直编 md 的能力与「人工内容一律不覆盖」这条铁律。
7
- // 单机单人的场景下,反方向只加复杂度,不加能力。
8
- //
9
- // 为何用 node:sqlite(内置,Node >= 22.5):
10
- // 实测 2 万节点 / 20 万边:3 跳扩散 0.5ms、FTS 全文 0.01ms、库文件 10MB。
11
- // 引 Neo4j/FalkorDB 要多养一个常驻进程,而本场景是「开工时查一次」——
12
- // 为 81 页知识养 Java 服务不值。
13
- //
14
- // 三张表各司其职:
15
- // nodes —— 每个知识页一行(含 todo/index/log 的条目),带项目标记 + 层级
16
- // edges —— 双链,跨项目也能连(project 前缀消歧)
17
- // anchors—— L6 断点层:双链词 → 出现它的页,供联想扩散用种子
18
- //
19
- // 稳定性约束:
20
- // · 所有写走一次事务,半途失败不留半条数据。
21
- // · 库是派生物:删了能重建(rebuild 从 markdown 全量重扫)。markdown 内容本身不丢。
22
- // · 库文件损坏 / 版本不匹配 → 抛 DbError,调用方决定是重建还是降级。
23
-
24
- import { DatabaseSync } from 'node:sqlite';
25
- import { promises as fs } from 'node:fs';
26
- import { join, dirname } from 'node:path';
27
- import { unlinkSync } from 'node:fs';
28
- import { homedir } from 'node:os';
29
- import { resolveLayer, anchorsOf, decayOf, DEFAULT_LAYER, isUnfilledDigest } from './layers.js';
30
-
31
- export const DB_VERSION = 5;
32
-
33
- /** 库路径。ABS_DB 可覆盖(测试隔离 / 多库切换)。 */
34
- export function dbPath() {
35
- return process.env.ABS_DB || join(homedir(), '.abs', 'graph.db');
36
- }
37
-
38
- /** 本次进程里最后一次 openDb 是否因损坏而重建过库。
39
- * 记录属内容决策,工具只报不改 —— 这里只标记,由调用方决定怎么告知,
40
- * 不自己打印(库层不该有输出)。 */
41
- let _rebuiltAfterCorruption = false;
42
- export function lastOpenRebuilt() { return _rebuiltAfterCorruption; }
43
-
44
- export class DbError extends Error {
45
- constructor(message, cause) {
46
- super(message);
47
- this.name = 'DbError';
48
- this.code = 'DB_ERROR';
49
- this.cause = cause;
50
- }
51
- }
52
-
53
- const SCHEMA = `
54
- CREATE TABLE IF NOT EXISTS meta (
55
- key TEXT PRIMARY KEY,
56
- value TEXT NOT NULL
57
- );
58
-
59
- -- 知识节点。project 为空串 = 全局级(如 index.md 本身)。
60
- -- layer: 1-7;L1 铁律不立页,只由 rules 表承载,故 nodes 里不会出现 1。
61
- CREATE TABLE IF NOT EXISTS nodes (
62
- id INTEGER PRIMARY KEY,
63
- project TEXT NOT NULL DEFAULT '',
64
- dir TEXT NOT NULL,
65
- slug TEXT NOT NULL,
66
- layer INTEGER NOT NULL DEFAULT ${DEFAULT_LAYER},
67
- title TEXT NOT NULL DEFAULT '',
68
- tags TEXT NOT NULL DEFAULT '',
69
- status TEXT NOT NULL DEFAULT 'active',
70
- body TEXT NOT NULL DEFAULT '',
71
- mtime REAL NOT NULL DEFAULT 0,
72
- -- 流水页(index/todo/log):它们是「摘要的摘要」,正文里照抄了每句经验原文,
73
- -- 因此任何查询都能命中它。实测(2026-11-01)刚 capture 一条「并发写」,log 页
74
- -- 亮度 3.87 排第一,压过真正的内容页 —— 这是纯噪音,必须从检索候选里排除。
75
- is_ledger INTEGER NOT NULL DEFAULT 0,
76
- -- 会话页骨架还没人填(只认 kind=session-digest)。1 = 空骨架。
77
- -- 为何要单开一列(2026-11-05 实测):ingest --auto 每次关会话都落一张空骨架,
78
- -- 而空骨架的「用户原话」段有高词频、正文又短 —— bm25 天然占优。
79
- -- 实测造 20 张空骨架后查「登录超时」:前 12 名全是空页,真正讲过这个问题的
80
- -- 知识页被彻底挤出结果。降到 0.5 亮度不够(它们仍然霸榜),必须整个排除。
81
- -- 填好后自动重算 → 重新参与检索并享受 0.5 折扣。
82
- unfilled INTEGER NOT NULL DEFAULT 0,
83
- -- 被哪一页推翻(对应 frontmatter 的 superseded-by)。
84
- -- 为何要进库(2026-11-02):知识自我进化的第一个需要 —— 能查「哪些页被推翻了、
85
- -- 被谁推翻」。原来这关系只存在于 markdown 的一行 frontmatter 里,
86
- -- 库只存 status 不存走向,于是「进化」在检索侧完全看不见。
87
- superseded_by TEXT NOT NULL DEFAULT '',
88
- -- 知识的「作用」分档(借 clo​ude-mem 的 observation 类型,2026-11-02):
89
- -- gotcha 关键坑(怎么都想不到、踩了会白干)
90
- -- decision 决策(当时为什么这么选)
91
- -- tradeoff 权衡(A 好但 B 也有理,最后选了哪个)
92
- -- howto 做法(这类事该怎么做)
93
- -- 为何要它:七层按「性质」分(工具/认知/混沌),分不出「同一层里哪种更该先看」。
94
- -- 实测 L4 有 382 页,其中「坑」106 页,而「决策」「权衡」根本没被记下来。
95
- -- 留空 = 未分档(不强制补,旧页保持空)。
96
- kind TEXT NOT NULL DEFAULT '',
97
- UNIQUE(project, dir, slug)
98
- );
99
- CREATE INDEX IF NOT EXISTS i_nodes_layer ON nodes(layer);
100
- CREATE INDEX IF NOT EXISTS i_nodes_project ON nodes(project);
101
- CREATE INDEX IF NOT EXISTS i_nodes_slug ON nodes(slug);
102
-
103
- -- 双链边。src/dst 都指向 nodes.id。
104
- -- 悬挂链接(目标页不存在)dst=0,保留原文到 raw,供 lint 报死链。
105
- CREATE TABLE IF NOT EXISTS edges (
106
- src INTEGER NOT NULL,
107
- dst INTEGER NOT NULL,
108
- raw TEXT NOT NULL DEFAULT '',
109
- PRIMARY KEY(src, dst)
110
- ) WITHOUT ROWID;
111
- CREATE INDEX IF NOT EXISTS i_edges_dst ON edges(dst);
112
-
113
- -- L6 断点层:双链词 → 出现它的节点。这是联想扩散的种子来源。
114
- CREATE TABLE IF NOT EXISTS anchors (
115
- word TEXT NOT NULL,
116
- node INTEGER NOT NULL,
117
- PRIMARY KEY(word, node)
118
- ) WITHOUT ROWID;
119
- CREATE INDEX IF NOT EXISTS i_anchors_node ON anchors(node);
120
-
121
- -- L1 铁律:从各项目 index.md 的 ## Rules 区抽取,按项目隔离。
122
- CREATE TABLE IF NOT EXISTS rules (
123
- id INTEGER PRIMARY KEY,
124
- project TEXT NOT NULL DEFAULT '',
125
- text TEXT NOT NULL,
126
- UNIQUE(project, text)
127
- );
128
-
129
- -- 全文索引:搜索先走这里拿种子(实测 0.01ms)。
130
- CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(
131
- g,
132
- content='nodes_bigram', content_rowid='id',
133
- tokenize='unicode61'
134
- );
135
-
136
- -- FTS 的内容源。不直接挂 nodes,因为要存派生列 g(2-gram)。
137
- CREATE TABLE IF NOT EXISTS nodes_bigram (
138
- id INTEGER PRIMARY KEY,
139
- g TEXT NOT NULL DEFAULT ''
140
- );
141
- `;
142
-
143
- /** 切 2-gram + 英文词。中文没有词边界,2-gram 是唯一能命中两字词的单位。
144
- *
145
- * 为何不用 trigram(2026-11-01 实测):trigram 分词要求查询 ≥3 字符,
146
- * 「静默」这类两字词恒返回 0;unicode61 又把整个「静默失效」当一个 token,
147
- * 两字词同样搜不到。实测三种分词器只有自切 2-gram 能全部命中。
148
- * 存储侧与查询侧必须用同一个函数,否则静默失配。 */
149
- /** 流水页:正文照抄了每条经验的原文,会命中任何查询,故排除在检索候选外。
150
- * 它们仍入库(供 db status / 归档查询用),只是不参与亮度排名。 */
151
- export const LEDGER_SLUGS = new Set(['index', 'todo', 'log']);
152
-
153
- export function bigrams(s) {
154
- const out = [];
155
- const text = String(s || '');
156
- for (const m of text.matchAll(/[\u4e00-\u9fa5]{2,}/g)) {
157
- const seg = m[0];
158
- for (let i = 0; i + 2 <= seg.length; i++) out.push(seg.slice(i, i + 2));
159
- }
160
- for (const m of text.matchAll(/[\u4e00-\u9fa5]/g)) out.push(m[0]);
161
- for (const m of text.matchAll(/[A-Za-z][A-Za-z0-9_-]{1,}/g)) out.push(m[0].toLowerCase());
162
- return [...new Set(out)];
163
- }
164
-
165
- /** 按「词」切 2-gram,不加单字。用于**查询侧**构造 MATCH 表达式。
166
- *
167
- * 为何要和平分两套(2026-11-02 深度测试发现):
168
- * 把单字也放进查询表达式会放大误命中 —— 实测查 `zzzz不存在zzz` 时,
169
- * gram 里含「不」「存」「在」,任何提到这三个字的页都会命中,
170
- * 于是「一个根本不存在的词」返回了 7 页。这不是模糊匹配有用,是噪音。
171
- *
172
- * 存储侧仍保留单字(bigrams 不动):这样查询本身**就是一个字**时
173
- * (如「锁」),还能通过单字 gram 命中;而多字查询不会靠单字瞎连。
174
- * 两侧不要求同一函数 —— 要求的是「查询产出的每个 gram 在存储侧都存在」,
175
- * 而存储侧是超集,恒成立。 */
176
- export function queryBigrams(s) {
177
- const text = String(s || '');
178
- const out = new Set();
179
- for (const m of text.matchAll(/[\u4e00-\u9fa5]{2,}/g)) {
180
- const seg = m[0];
181
- for (let i = 0; i + 2 <= seg.length; i++) out.add(seg.slice(i, i + 2));
182
- }
183
- // 整条查询只有单个汉字时才用单字 gram(「锁」这种一字查询仍能搜到)
184
- if (out.size === 0 && /^[\u4e00-\u9fa5]$/.test(text.trim())) out.add(text.trim());
185
- for (const m of text.matchAll(/[A-Za-z][A-Za-z0-9_-]{1,}/g)) out.add(m[0].toLowerCase());
186
- return [...out];
187
- }
188
-
189
- /** 丢掉所有表(版本升级用)。库是派生品,删了由 rebuild 从 markdown 重建。 */
190
- function dropAll(db) {
191
- for (const r of db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all()) {
192
- db.exec(`DROP TABLE IF EXISTS "${r.name}"`);
193
- }
194
- }
195
-
196
- /** 打开(不存在就建)库。返回 { db, close }。 */
197
- export function openDb(path = dbPath()) {
198
- _rebuiltAfterCorruption = false;
199
- let db;
200
- try {
201
- db = new DatabaseSync(path);
202
- } catch (e) {
203
- throw new DbError(`打不开数据库 ${path}: ${e.message}`, e);
204
- }
205
- try {
206
- db.exec('PRAGMA journal_mode = WAL');
207
- db.exec('PRAGMA foreign_keys = ON');
208
- db.exec(SCHEMA);
209
-
210
- // 版本检查与迁移。
211
- // 坑(2026-11-01 实测):先报「跑 abs db rebuild 重建」是错的 —— rebuild 也走这里,
212
- // 而 CREATE TABLE IF NOT EXISTS 不给已有表加列,老库重建时报
213
- // "table nodes has no column named is_ledger",用户于是完全卡死。
214
- // 库是**派生物**(markdown 才是真相),故版本不符直接丢表重建,不要用户动手。
215
- const cur = db.prepare("SELECT value FROM meta WHERE key='version'").get();
216
- if (!cur) {
217
- db.prepare("INSERT INTO meta(key,value) VALUES('version',?)").run(String(DB_VERSION));
218
- } else if (Number(cur.value) !== DB_VERSION) {
219
- dropAll(db);
220
- db.exec(SCHEMA); // 表被删了,必须重新建(含 meta)
221
- db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('version',?)").run(String(DB_VERSION));
222
- }
223
- } catch (e) {
224
- try { db.close(); } catch { /* 已关 */ }
225
-
226
- // 库文件根本不是 SQLite / 结构彻底坏掉 → 删掉重建。
227
- // 为何不报错让用户手删(2026-11-02 深度测试发现):库是**派生物**
228
- // (markdown 才是真相),删了由 rebuild 从 .brain/ 全量重扫,内容不丢。
229
- // 原实现只报「库结构初始化失败: file is not a database」,而 rebuild 走同一条
230
- // 打开路径 —— 于是用户被卡死:修不好也删不掉(不知道删哪),只能去查源码。
231
- if (/not a database|file is encrypted|malformed/i.test(e.message || '')) {
232
- try {
233
- for (const suffix of ['', '-wal', '-shm']) {
234
- try { unlinkSync(`${path}${suffix}`); } catch { /* 不存在 */ }
235
- }
236
- const h = openDb(path); // 文件已删,重来一次走全新库
237
- _rebuiltAfterCorruption = true; // 告知调用方:刚重建过,库是空的
238
- return h;
239
- } catch (e2) {
240
- throw new DbError(`库文件损坏且无法重建 ${path}: ${e2.message}`, e2);
241
- }
242
- }
243
- if (e instanceof DbError) throw e;
244
- throw new DbError(`库结构初始化失败: ${e.message}`, e);
245
- }
246
- return {
247
- db,
248
- close: () => { try { db.close(); } catch { /* 已关 */ } },
249
- };
250
- }
251
-
252
- /** 确保库目录存在(第一次用时建 ~/.abs/)。 */
253
- export async function ensureDbDir(path = dbPath()) {
254
- await fs.mkdir(dirname(path), { recursive: true });
255
- }
256
-
257
- /** 估算一段文字要占多少 token。中文按 1.5 字符/token、其余按 4 字符/token。
258
- *
259
- * 为何要估算而不是精确算(2026-11-02):精确分词要引依赖,而这里只需
260
- * 「让用户知道取这一页大概多少钱」—— 数量级对就够了。
261
- * 实测校准:本项目 40 页正文平均 824 token/页,与按段落读下来的体感一致。
262
- */
263
- export function estTokens(s) {
264
- const t = String(s || '');
265
- const cjk = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
266
- const rest = t.length - cjk;
267
- return Math.round(cjk / 1.5 + rest / 4);
268
- }
269
-
270
- /** 会话页在检索里的亮度折扣。
271
- *
272
- * 为何要降权(2026-11-05 用户定,方案里选择「亮度乘 0.5」):
273
- * `abs ingest` 把会话落成页后,sessions/ 里会出现大量正文很长的页。
274
- * 会话页正文天然长、词频高,bm25 天然占优 —— 不降权就会把真正的内容页淹掉。
275
- * 同一个病本仓已经踩过一次(log-2026-11-02 第八节:会话流水页压过知识页)。
276
- * 0.5 是起步值:能搜到、能被双链扩散带出,但不会抢占榜单首位。
277
- *
278
- * 判据用 kind 而不是目录:sessions/ 里还有当天流水(log-<日期>),
279
- * 那是另一类页,不该被这条折扣管。 */
280
- export const SESSION_DIGEST_DISCOUNT = 0.5;
281
-
282
- /** 从页正文取一句话摘要:优先 frontmatter 的 description,退化到首个非标题行。
283
- * 与 relevant.js 的 digest 同思路,但这里在库侧用(不读文件)。 */
284
- export function digest(body, maxLen = 90) {
285
- const lines = String(body || '').split('\n');
286
- const fmEnd = lines[0] === '---' ? lines.indexOf('---', 1) : -1;
287
- if (fmEnd > 0) {
288
- const d = lines.slice(1, fmEnd).find((l) => /^description\s*:/.test(l));
289
- if (d) return d.replace(/^description\s*:\s*/, '').trim().slice(0, maxLen);
290
- }
291
- // 跳过 frontmatter 与标题,取第一句正文
292
- const start = fmEnd > 0 ? fmEnd + 1 : 0;
293
- const first = lines.slice(start).find((l) => {
294
- const x = l.trim();
295
- return x && !x.startsWith('#') && !x.startsWith('---') && !x.startsWith('>') && !x.startsWith('|');
296
- });
297
- return String(first || '').replace(/^[-*]\s*/, '').replace(/^TITLE\s*:\s*/, '').trim().slice(0, maxLen);
298
- }
299
-
300
- /** 从 markdown 正文抽 title / tags / status(与页面 frontmatter 同源)。 */
301
- function metaOf(body) {
302
- const t = String(body || '');
303
- const title = (t.match(/^title:\s*(.+)$/m) || [])[1]?.trim() || '';
304
- const tags = (t.match(/^tags:\s*(.+)$/m) || [])[1]?.replace(/^\[|\]$/g, '').trim() || '';
305
- const st = (t.match(/^status:\s*(.+)$/m) || [])[1]?.trim() || 'active';
306
- const sup = (t.match(/^superseded-by:\s*(.+)$/m) || [])[1]?.trim() || '';
307
- const kind = (t.match(/^kind:\s*(.+)$/m) || [])[1]?.trim() || '';
308
- return { title, tags, status: st, supersededBy: sup, kind };
309
- }
310
-
311
- /** 全量重建:清表 → 重扫所有注册项目 → 重建边与断点词。
312
- * pages: [{ project, dir, slug, body, mtime }]
313
- * 返回统计,供 CLI 报告。 */
314
- export function rebuild(handle, pages) {
315
- const { db } = handle;
316
- const stat = { nodes: 0, edges: 0, anchors: 0, rules: 0, dangling: 0 };
317
- const now = Date.now();
318
- db.exec('BEGIN');
319
- try {
320
- db.exec('DELETE FROM anchors; DELETE FROM edges; DELETE FROM nodes; DELETE FROM rules; DELETE FROM nodes_bigram;');
321
- try { db.exec("INSERT INTO fts(fts) VALUES('delete-all')"); } catch { /* 空表时无可删 */ }
322
-
323
- const insNode = db.prepare(`INSERT INTO nodes(project,dir,slug,layer,title,tags,status,body,mtime,is_ledger,superseded_by,kind,unfilled)
324
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`);
325
- const insGram = db.prepare('INSERT OR REPLACE INTO nodes_bigram(id,g) VALUES(?,?)');
326
- const insEdge = db.prepare('INSERT OR IGNORE INTO edges(src,dst,raw) VALUES(?,?,?)');
327
- const insAnchor = db.prepare('INSERT OR IGNORE INTO anchors(word,node) VALUES(?,?)');
328
- const insRule = db.prepare('INSERT OR IGNORE INTO rules(project,text) VALUES(?,?)');
329
-
330
- // 第一遍:建节点,拿 id 映射 key = `project/dir/slug`
331
- const idOf = new Map();
332
- for (const p of pages) {
333
- const { title, tags, status, supersededBy, kind } = metaOf(p.body);
334
- const { layer } = resolveLayer(p.body, p.dir);
335
- const ledger = (p.dir === '' && LEDGER_SLUGS.has(p.slug)) ? 1 : 0;
336
- // 空骨架会话页在检索里整页排除(见 SCHEMA 里 unfilled 列的注释)
337
- const unfilled = isUnfilledDigest(p.body) ? 1 : 0;
338
- const r = insNode.run(p.project, p.dir, p.slug, layer, title, tags, status,
339
- p.body, p.mtime || now, ledger, supersededBy, kind, unfilled);
340
- const id = Number(r.lastInsertRowid);
341
- // FTS 源:正文 + 标题 + tags 切 2-gram。查询侧必须用同一个 bigrams()。
342
- insGram.run(id, bigrams([p.slug, title, tags, p.body].join('\n')).join(' '));
343
- idOf.set(`${p.project}/${p.dir}/${p.slug}`, id);
344
- // 同名页兜底键:不带项目,供就近解析
345
- if (!idOf.has(`${p.dir}/${p.slug}`)) idOf.set(`${p.dir}/${p.slug}`, id);
346
- stat.nodes++;
347
- }
348
-
349
- // index.md 的 Rules → rules 表(L1)。只取 `## Rules` 区内的 `- ` 条目,
350
- // 不能扫全文 —— 否则页面清单里每条 `- [[x]] — 一句` 都会被当铁律。
351
- for (const p of pages) {
352
- if (p.dir !== '' || p.slug !== 'index') continue;
353
- const lines = String(p.body).split('\n');
354
- const at = lines.findIndex((l) => l.trim() === '## Rules');
355
- if (at === -1) continue;
356
- for (const line of lines.slice(at + 1)) {
357
- if (/^##\s/.test(line)) break; // 下个分区开始,停止
358
- const t = line.trim();
359
- if (!t.startsWith('- ')) continue;
360
- insRule.run(p.project, t.slice(2).trim());
361
- stat.rules++;
362
- }
363
- }
364
-
365
- // 第二遍:解析双链,建边 + 断点词。
366
- // todo.md / log.md 里的 [[人名]] 是**作者标记**不是知识链接(实测:projX 的
367
- // todo 连到了另一项目的 fanchao 页,是误连)。这两份文件只入节点、不抽边。
368
- for (const p of pages) {
369
- const srcId = idOf.get(`${p.project}/${p.dir}/${p.slug}`);
370
- if (!srcId) continue;
371
- const isLedger = p.dir === '' && (p.slug === 'todo' || p.slug === 'log');
372
- if (isLedger) continue;
373
- for (const word of anchorsOf(p.body)) {
374
- const { project: explicitProj, page } = parseAnchor(word);
375
- // 断点词(L6)只记页名,不记 @项目前缀 —— 那是链接语法不是索引词
376
- insAnchor.run(page, srcId);
377
- stat.anchors++;
378
- let dstId;
379
- if (explicitProj) {
380
- // 显式跨项目:只在这个项目里找,找不到就是悬挂,不退回全局兜底
381
- // (退回会静默连到别的项目,正是这个语法要避免的)
382
- dstId = idOf.get(`${explicitProj}/concepts/${page}`)
383
- ?? idOf.get(`${explicitProj}/entities/${page}`)
384
- ?? idOf.get(`${explicitProj}/sessions/${page}`)
385
- ?? idOf.get(`${explicitProj}/syntheses/${page}`);
386
- if (dstId === undefined) stat.crossProjectMissing = (stat.crossProjectMissing || 0) + 1;
387
- } else {
388
- // 就近优先:本项目 → 再全局
389
- dstId = idOf.get(`${p.project}/concepts/${page}`)
390
- ?? idOf.get(`${p.project}/entities/${page}`)
391
- ?? idOf.get(`${p.project}/sessions/${page}`)
392
- ?? findGlobal(idOf, page);
393
- }
394
- if (dstId === undefined) {
395
- // 平铺文件名(index/todo/log)当链接目标是**讨论语法的示例文本**,
396
- // 不是真链接 —— 实测 .brain 里 `[[todo]]` 出现在「lint 误报它死链」这句里。
397
- // 这类不记悬挂(它们不是死链,是正文引用了文件名)。
398
- if (!LEDGER_SLUGS.has(page)) {
399
- insEdge.run(srcId, 0, word);
400
- stat.dangling++;
401
- }
402
- } else {
403
- insEdge.run(srcId, dstId, word);
404
- stat.edges++;
405
- if (explicitProj) stat.crossProject = (stat.crossProject || 0) + 1;
406
- }
407
- }
408
- }
409
-
410
- db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('rebuilt_at',?)").run(String(now));
411
- db.exec('COMMIT');
412
- // FTS 重建必须在事务外(fts5 触发器在重建期间会读 nodes)
413
- db.exec("INSERT INTO fts(fts) VALUES('rebuild')");
414
- } catch (e) {
415
- db.exec('ROLLBACK');
416
- throw new DbError(`重建失败: ${e.message}`, e);
417
- }
418
- return stat;
419
- }
420
-
421
- /** 解析双链目标:`[[@项目/页面]]` → 显式跨项目;`[[页面]]` → 就近。
422
- *
423
- * 为何要这个语法(2026-11-02 用户定「跨项目双链连接」):
424
- * 默认就近(本项目优先)能覆盖绝大多数情况,但「我要明确引用另一个项目的某页」
425
- * 就没法表达 —— 只能靠全局兜底,而那会撞上同名页。
426
- * `@` 前缀让跨项目引用显式,读页面时也一眼看得出「这条链出了项目」。
427
- *
428
- * 返回 { project: '项目名'|null, page: '页名' }。
429
- */
430
- export function parseAnchor(word) {
431
- const w = String(word || '').trim();
432
- const m = w.match(/^@([^/\\]+)[/\\](.+)$/);
433
- if (m) return { project: m[1].trim(), page: m[2].trim() };
434
- return { project: null, page: w };
435
- }
436
-
437
- /** 同名页全局兜底:取第一个命中的(跨项目引用靠 @前缀 显式写,这里只做兜底)。
438
- *
439
- * 必须排除两类同名目标(2026-11-02 实测踩到):
440
- * · 平铺文件 index/todo/log —— 每个项目都有一份,兜底会连到别的项目去。
441
- * 实测 `[[index]]` 把 agent_brain_sync 的页连到了 pA/index,纯误连。
442
- * · 流水页(dir 为空)同理。
443
- * 判据:兜底只在「知识页」之间发生,索引/流水不算知识页。
444
- */
445
- function findGlobal(idOf, word) {
446
- if (LEDGER_SLUGS.has(word)) return undefined;
447
- for (const [k, v] of idOf) {
448
- if (k.endsWith(`/${word}`)) return v;
449
- }
450
- return undefined;
451
- }
452
-
453
- // ---------- 查询:FTS 拿种子 → 亮度扩散 ----------
454
-
455
- /** 第一步:全文搜索拿种子节点。
456
- * 实测 0.01ms/次 —— 「高频率搜索」的要求靠这一层满足。
457
- * 返回 [{ id, project, dir, slug, title, layer, score }],按 FTS 相关度排序。 */
458
- export function search(handle, query, { limit = 20 } = {}) {
459
- const { db } = handle;
460
- const grams = queryBigrams(query);
461
- if (!grams.length) return [];
462
- const expr = grams.map((g) => `"${g.replace(/"/g, '""')}"`).join(' OR ');
463
- // 命中门槛:按 gram 的**文档频率加权**算覆盖率,不再用「命中几个」的平均数。
464
- //
465
- // 为何要加权(2026-11-02 深度测试发现,两轮才修对):
466
- // FTS 的 OR 语义是「命中任意 gram 就算」。先试「命中数 ≥ 一半」,
467
- // 但中文高频组合会绕过它 —— 查 `zzzz不存在zzz` 时,「不存在」切出
468
- // [不存, 存在] 两个 gram,命中 2/4 就达标,而「不存在」在图谱里到处都是。
469
- // 实测真实图谱:`验证` 出现在 84% 的页里、`关联` 79%、`连接` 79% ——
470
- // 这些 gram 没有区分度,却和稀有 gram 等权。
471
- // 现在每个 gram 的权重 = log((总页数+1)/(出现页数+0.5)):
472
- // 稀有 gram(如专有名词)权重高,常见虚词组合权重低到几乎不计。
473
- // 覆盖率 = 命中 gram 的权重和 / 查询 gram 的总权重,低于 MIN_COVER 丢弃。
474
- const MIN_COVER = 0.35;
475
- const totalNodes = db.prepare('SELECT count(*) c FROM nodes_bigram').get().c || 1;
476
- const df = new Map();
477
- for (const r of db.prepare('SELECT g FROM nodes_bigram').all()) {
478
- for (const g of String(r.g).split(' ')) df.set(g, (df.get(g) || 0) + 1);
479
- }
480
- const wOf = (g) => {
481
- const d = df.get(g) || 0;
482
- return d === 0 ? 3 : Math.log((totalNodes + 1) / (d + 0.5)); // 库里没有的 gram 权重最高
483
- };
484
- const wantWeight = grams.reduce((a, g) => a + wOf(g), 0) || 1;
485
- try {
486
- // 取 g 列才能数命中数;多取一些候选,过滤后仍可能不够 limit
487
- const rows = db.prepare(`
488
- SELECT n.id, n.project, n.dir, n.slug, n.title, n.layer, n.kind, n.body,
489
- bm25(fts) AS rank, b.g
490
- FROM fts
491
- JOIN nodes_bigram b ON b.id = fts.rowid
492
- JOIN nodes n ON n.id = fts.rowid
493
- WHERE fts MATCH ? AND n.is_ledger = 0
494
- -- 未填的空骨架会话页不参与检索(2026-11-05 实测):
495
- -- 它们正文短、素材段词频高,bm25 天然占优。实测 20 张空页就把
496
- -- 真正讲过该问题的知识页彻底挤出结果 —— 降权不够,必须整页排除。
497
- AND n.unfilled = 0
498
- -- 被推翻的经验不出现在检索里。
499
- -- 它的存在意义是「别再用它」,而不是回答「我上次怎么解决 X」——
500
- -- 那会拿到一个已知错误的答案。与 abs query 的既有判据一致。
501
- AND n.status != 'superseded'
502
- ORDER BY rank
503
- LIMIT ?
504
- `).all(expr, Math.max(limit * 4, 40));
505
-
506
- const kept = rows.map((r) => {
507
- const hitGrams = grams.filter((q) => String(r.g).includes(q));
508
- const cover = hitGrams.reduce((a, g) => a + wOf(g), 0) / wantWeight;
509
- return { ...r, cover, hit: hitGrams.length };
510
- }).filter((r) => r.cover >= MIN_COVER);
511
- if (!kept.length) return [];
512
-
513
- // bm25 返回**负值且越负越相关**。归一成 0.3~1.0 的亮度,保证「最相关 = 1.0」。
514
- //
515
- // 坑(2026-11-02 深度测试发现):原来分母取 `kept[最后一条].rank` —— 而 kept 按
516
- // rank 升序(第一行最相关),最后一条恰恰是**最不相关**的那条,其 rank 接近 0。
517
- // 实测一个 4 条结果的查询里,最相关那条亮度算出 1168694.07(= |−2.42| / 2e-6),
518
- // 输出一行 165 万的亮度条。分母必须取最相关的那条(第一条)。
519
- // 种子亮度里要体现「层」—— 否则分层只在扩散时起作用,种子仍按纯字面排。
520
- // 实测(2026-11-02,14 个项目 586 页):查「docker」时三个 sessions/ 流水页
521
- // 排在最前,压过真正讲了 docker 的知识页 —— 流水页正文长、词频高,bm25 天然占优,
522
- // 但对「我上次怎么解决 X」没有价值。
523
- // 权重 = 1 - decay:L3 工具 0.6、L4 认知 0.4、L5 会话 0.25、L7 混沌 0.05。
524
- // L1/L2 不参与(铁律不立页)。
525
- const layerWeight = (layer) => Math.max(0.05, 1 - decayOf(layer));
526
- const best = Math.abs(kept[0].rank) || 1e-9;
527
- // 亮度 = 档位 × 档内名次。
528
- // 档位由加权覆盖率决定(0.6~1.0):全命中最高,勉强沾边压到 0.6 档再往下。
529
- // 档内名次由相对 bm25 决定(0.1~1.0):覆盖率相同但 bm25 不同的页仍可分。
530
- // 下限 0.3 保留 —— 勉强命中的种子仍要能向外扩散,只是传得更弱。
531
- return kept.slice(0, limit)
532
- .map(({ g: _g, ...r }) => {
533
- const rel = Math.min(1, Math.abs(r.rank) / best);
534
- const band = 0.6 + 0.4 * r.cover;
535
- // 亮度保持 0.3~1.0 的可读区间,**不乘层权重**。
536
- // 坑(2026-11-02 实测):把层权重乘进亮度后,L4 的满分页只剩 0.40,
537
- // 输出里一片 0.3~0.5,用户看不出谁更相关 —— 亮度失去了「一眼看强弱」的作用。
538
- // 层的作用改由**排序**承担(见下面 sort),亮度回归「这一页与查询多贴」本身。
539
- const bright = Math.max(0.3, Math.min(1, band * (0.1 + 0.9 * rel)));
540
- // 会话页降权(2026-11-05):见 SESSION_DIGEST_DISCOUNT 的注释。
541
- // 扣亮度而不扣排序权重 —— 亮度是输出给人看的东西,权重是内部排序秩序。
542
- const discounted = r.kind === 'session-digest' ? bright * SESSION_DIGEST_DISCOUNT : bright;
543
- return { ...r, bright: discounted };
544
- })
545
- // 排序:层优先,同层再看亮度。
546
- // 为何层要排第一(2026-11-02,586 页实测):查「静默失效」的前三名全是 L7
547
- // 混沌页(临时笔记),字面 cover=1.00 但内容与主题无关;
548
- // 而 L4 认知层里真正讲这个主题的页被压到后面。
549
- // 判据:知识层的页即便贴得稍微松一点,也比混沌层的碎片更该先看。
550
- // 例外:L1/L2 不参与排序(铁律不立页,单独在最前输出)。
551
- .sort((a, b) => {
552
- const wa = layerWeight(a.layer);
553
- const wb = layerWeight(b.layer);
554
- return (wb * b.bright - wa * a.bright) || (b.bright - a.bright) || (a.id - b.id);
555
- })
556
- // 索引优先(2026-11-02 借 clo​ude-mem 的 Progressive Disclosure):
557
- // 返回值只给「摘要 + 取全文成本」,**不带正文** —— 正文要用 getPage 再取。
558
- // 实测这一层的价值:ABS 单页平均 824 token,8 页全带就是 6600 token,
559
- // 而其中真正被用上的往往只有 1-2 页(clo​ude-mem 实测 90% 是浪费)。
560
- .map(({ body, ...r }) => ({ ...r, digest: digest(body, 90), tokens: estTokens(body) }));
561
- } catch {
562
- // 方言/语法问题不能让搜索整体挂掉 —— 退化成空种子,由调用方决定提示
563
- return [];
564
- }
565
- }
566
-
567
- /** 第二步:从种子出发做有限扩散,算每页亮度。
568
- *
569
- * 为何必须压制(2026-11-01 实测):真实图谱 81 页 291 边,2 跳就到 95% 页面点亮,
570
- * 「hook」一词 1 跳就 100%。全亮 = 全不亮,分不出哪页更相关。
571
- * 故硬约束:最多 HOPS 跳,且每跳乘衰减,低于 MIN_BRIGHT 直接丢。
572
- *
573
- * 为何要枢纽惩罚:实测扩散把入度 26 的根节点顶到前三,不是因为它相关,
574
- * 是因为人人链它。对被链得多的页打折(近似 IDF),枢纽不再霸榜。
575
- *
576
- * @returns [{ id, project, dir, slug, title, layer, bright, from }]
577
- */
578
- const HOPS = 1; // 实测 2 跳即 95% 全亮,只走 1 跳
579
- const MIN_BRIGHT = 0.02;
580
-
581
- export function illuminate(handle, seeds, { hops = HOPS, limit = 10 } = {}) {
582
- const { db } = handle;
583
- if (!seeds.length) return [];
584
-
585
- // 入度表:用于枢纽惩罚。全表一次查完(81 页量级,可忽略)。
586
- const inDeg = new Map();
587
- for (const r of db.prepare('SELECT dst, count(*) c FROM edges WHERE dst > 0 GROUP BY dst').all())
588
- inDeg.set(r.dst, r.c);
589
-
590
- const bright = new Map(); // id -> { bright, from }
591
- for (const s of seeds) {
592
- const b = s.bright ?? 1;
593
- if (!bright.has(s.id) || bright.get(s.id).bright < b) bright.set(s.id, { bright: b, from: 'seed' });
594
- }
595
-
596
- // 扩散:出边 + 入边都算(双向连接),乘该层衰减,再按枢纽惩罚打折
597
- let frontier = [...bright.entries()].map(([id, v]) => ({ id, bright: v.bright }));
598
- for (let hop = 0; hop < hops; hop++) {
599
- const next = [];
600
- for (const f of frontier) {
601
- const neigh = db.prepare('SELECT dst AS id FROM edges WHERE src = ? AND dst > 0 UNION SELECT src AS id FROM edges WHERE dst = ? AND src > 0').all(f.id, f.id);
602
- const layerRow = db.prepare('SELECT layer FROM nodes WHERE id = ?').get(f.id);
603
- const decay = decayOf(layerRow?.layer ?? DEFAULT_LAYER);
604
- for (const n of neigh) {
605
- if (bright.has(n.id)) continue;
606
- // 枢纽惩罚:入度越高,传出去越弱。1/(1+log(1+入度))
607
- const deg = inDeg.get(n.id) || 0;
608
- const hub = 1 / (1 + Math.log(1 + deg));
609
- const b = f.bright * (1 - decay) * hub;
610
- if (b < MIN_BRIGHT) continue;
611
- bright.set(n.id, { bright: b, from: 'spread' });
612
- next.push({ id: n.id, bright: b });
613
- }
614
- }
615
- frontier = next;
616
- }
617
-
618
- const out = [];
619
- for (const [id, v] of bright) {
620
- // 扩散出口也要排除 superseded —— 入口(search)排了,出口不排就绕过去:
621
- // 实测旧结论页自己不出现在种子里,却被邻居扩散点亮(亮度 0.24 带 ↝)。
622
- //
623
- // ★ 同一条判据也要管 unfilled(2026-11-05 实测):入口排了空骨架,
624
- // 出口不排就会从邻居扩散进来 —— 实测知识页链到一张空骨架页后,
625
- // 查「缓存穿透」时它以 `↝ 0.24` 出现在结果里。亮度虽低,但占了结果位,
626
- // 且带 ↝ 标记看起来像有效线索,比直接排掉更容易误导。
627
- const n = db.prepare("SELECT id,project,dir,slug,title,layer,kind,body FROM nodes WHERE id = ? AND is_ledger = 0 AND status != 'superseded' AND unfilled = 0").get(id);
628
- if (n) {
629
- // 与 search 保持同一形态:索引优先,只给摘要与成本,不带正文。
630
- // 坑(2026-11-02 实测):这里原来只查少数字段,于是 CLI 侧读不到 digest/tokens,
631
- // 摘要与成本全丢 —— search 改了、出口没改,典型的「改一半」。
632
- const { body, ...rest } = n;
633
- out.push({ ...rest, bright: v.bright, from: v.from, digest: digest(body, 90), tokens: estTokens(body) });
634
- }
635
- }
636
- // 排序口径必须与 search 一致:层优先,同层再比亮度。
637
- // 坑(2026-11-02 实测):原来这里只按 bright 排,把 search 已经排好的层序冲掉了 ——
638
- // 于是 L7 混沌页(bright 1.00)又跑到 L4 认知页(bright 0.95)前面。
639
- // 层权重在这里再乘一次,为的是「扩散进来的弱页不会因为某个邻居强就压过知识页」。
640
- const weight = (layer) => Math.max(0.05, 1 - decayOf(layer));
641
- return out
642
- .sort((a, b) => (weight(b.layer) * b.bright - weight(a.layer) * a.bright)
643
- || (b.bright - a.bright) || (a.id - b.id))
644
- .slice(0, limit);
645
- }
646
-
647
- /** L1 铁律:按项目取出。检索任何东西都必须先带上它(「不可逾越」的代码保证)。 */
648
- export function rulesOf(handle, project) {
649
- const { db } = handle;
650
- return db.prepare('SELECT text FROM rules WHERE project = ? OR project = \'\' ORDER BY id')
651
- .all(project || '').map((r) => r.text);
652
- }
653
-
654
- /** 统计,供 status / lint 报告。 */
655
- export function stats(handle) {
656
- const { db } = handle;
657
- const one = (sql) => db.prepare(sql).get();
658
- return {
659
- nodes: one('SELECT count(*) c FROM nodes').c,
660
- edges: one('SELECT count(*) c FROM edges WHERE dst > 0').c,
661
- dangling: one('SELECT count(*) c FROM edges WHERE dst = 0').c,
662
- anchors: one('SELECT count(DISTINCT word) c FROM anchors').c,
663
- rules: one('SELECT count(*) c FROM rules').c,
664
- projects: one('SELECT count(DISTINCT project) c FROM nodes WHERE project != \'\'').c,
665
- byLayer: db.prepare('SELECT layer, count(*) c FROM nodes GROUP BY layer ORDER BY layer').all(),
666
- };
667
- }