@fanchao8609/agent_brain_sync 1.20.1 → 1.21.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 +6 -0
- package/bin/abs.js +39 -0
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +21 -2
- package/src/brain/capture.js +170 -0
- package/src/brain/db-cmd.js +244 -0
- package/src/brain/db.js +406 -0
- package/src/brain/gate.js +19 -0
- package/src/brain/init.js +135 -0
- package/src/brain/layers.js +112 -0
- package/src/brain/load.js +273 -0
- package/src/brain/maintain.js +199 -0
- package/src/brain/rules.js +98 -0
- package/src/brain/shape.js +102 -0
- package/src/brain/summon-cmd.js +62 -0
- package/src/brain/summon.js +236 -0
- package/src/note.js +15 -0
- package/src/store.js +36 -1104
- package/src/text.js +21 -1
- package/src/todo/task-cmd.js +274 -0
package/src/brain/db.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
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 { homedir } from 'node:os';
|
|
28
|
+
import { resolveLayer, anchorsOf, decayOf, DEFAULT_LAYER } from './layers.js';
|
|
29
|
+
|
|
30
|
+
export const DB_VERSION = 2;
|
|
31
|
+
|
|
32
|
+
/** 库路径。ABS_DB 可覆盖(测试隔离 / 多库切换)。 */
|
|
33
|
+
export function dbPath() {
|
|
34
|
+
return process.env.ABS_DB || join(homedir(), '.abs', 'graph.db');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class DbError extends Error {
|
|
38
|
+
constructor(message, cause) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'DbError';
|
|
41
|
+
this.code = 'DB_ERROR';
|
|
42
|
+
this.cause = cause;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SCHEMA = `
|
|
47
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
48
|
+
key TEXT PRIMARY KEY,
|
|
49
|
+
value TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
-- 知识节点。project 为空串 = 全局级(如 index.md 本身)。
|
|
53
|
+
-- layer: 1-7;L1 铁律不立页,只由 rules 表承载,故 nodes 里不会出现 1。
|
|
54
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
55
|
+
id INTEGER PRIMARY KEY,
|
|
56
|
+
project TEXT NOT NULL DEFAULT '',
|
|
57
|
+
dir TEXT NOT NULL,
|
|
58
|
+
slug TEXT NOT NULL,
|
|
59
|
+
layer INTEGER NOT NULL DEFAULT ${DEFAULT_LAYER},
|
|
60
|
+
title TEXT NOT NULL DEFAULT '',
|
|
61
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
62
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
63
|
+
body TEXT NOT NULL DEFAULT '',
|
|
64
|
+
mtime REAL NOT NULL DEFAULT 0,
|
|
65
|
+
-- 流水页(index/todo/log):它们是「摘要的摘要」,正文里照抄了每句经验原文,
|
|
66
|
+
-- 因此任何查询都能命中它。实测(2026-11-01)刚 capture 一条「并发写」,log 页
|
|
67
|
+
-- 亮度 3.87 排第一,压过真正的内容页 —— 这是纯噪音,必须从检索候选里排除。
|
|
68
|
+
is_ledger INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
UNIQUE(project, dir, slug)
|
|
70
|
+
);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS i_nodes_layer ON nodes(layer);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS i_nodes_project ON nodes(project);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS i_nodes_slug ON nodes(slug);
|
|
74
|
+
|
|
75
|
+
-- 双链边。src/dst 都指向 nodes.id。
|
|
76
|
+
-- 悬挂链接(目标页不存在)dst=0,保留原文到 raw,供 lint 报死链。
|
|
77
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
78
|
+
src INTEGER NOT NULL,
|
|
79
|
+
dst INTEGER NOT NULL,
|
|
80
|
+
raw TEXT NOT NULL DEFAULT '',
|
|
81
|
+
PRIMARY KEY(src, dst)
|
|
82
|
+
) WITHOUT ROWID;
|
|
83
|
+
CREATE INDEX IF NOT EXISTS i_edges_dst ON edges(dst);
|
|
84
|
+
|
|
85
|
+
-- L6 断点层:双链词 → 出现它的节点。这是联想扩散的种子来源。
|
|
86
|
+
CREATE TABLE IF NOT EXISTS anchors (
|
|
87
|
+
word TEXT NOT NULL,
|
|
88
|
+
node INTEGER NOT NULL,
|
|
89
|
+
PRIMARY KEY(word, node)
|
|
90
|
+
) WITHOUT ROWID;
|
|
91
|
+
CREATE INDEX IF NOT EXISTS i_anchors_node ON anchors(node);
|
|
92
|
+
|
|
93
|
+
-- L1 铁律:从各项目 index.md 的 ## Rules 区抽取,按项目隔离。
|
|
94
|
+
CREATE TABLE IF NOT EXISTS rules (
|
|
95
|
+
id INTEGER PRIMARY KEY,
|
|
96
|
+
project TEXT NOT NULL DEFAULT '',
|
|
97
|
+
text TEXT NOT NULL,
|
|
98
|
+
UNIQUE(project, text)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
-- 全文索引:搜索先走这里拿种子(实测 0.01ms)。
|
|
102
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(
|
|
103
|
+
g,
|
|
104
|
+
content='nodes_bigram', content_rowid='id',
|
|
105
|
+
tokenize='unicode61'
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
-- FTS 的内容源。不直接挂 nodes,因为要存派生列 g(2-gram)。
|
|
109
|
+
CREATE TABLE IF NOT EXISTS nodes_bigram (
|
|
110
|
+
id INTEGER PRIMARY KEY,
|
|
111
|
+
g TEXT NOT NULL DEFAULT ''
|
|
112
|
+
);
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
/** 切 2-gram + 英文词。中文没有词边界,2-gram 是唯一能命中两字词的单位。
|
|
116
|
+
*
|
|
117
|
+
* 为何不用 trigram(2026-11-01 实测):trigram 分词要求查询 ≥3 字符,
|
|
118
|
+
* 「静默」这类两字词恒返回 0;unicode61 又把整个「静默失效」当一个 token,
|
|
119
|
+
* 两字词同样搜不到。实测三种分词器只有自切 2-gram 能全部命中。
|
|
120
|
+
* 存储侧与查询侧必须用同一个函数,否则静默失配。 */
|
|
121
|
+
/** 流水页:正文照抄了每条经验的原文,会命中任何查询,故排除在检索候选外。
|
|
122
|
+
* 它们仍入库(供 db status / 归档查询用),只是不参与亮度排名。 */
|
|
123
|
+
export const LEDGER_SLUGS = new Set(['index', 'todo', 'log']);
|
|
124
|
+
|
|
125
|
+
export function bigrams(s) {
|
|
126
|
+
const out = [];
|
|
127
|
+
const text = String(s || '');
|
|
128
|
+
for (const m of text.matchAll(/[\u4e00-\u9fa5]{2,}/g)) {
|
|
129
|
+
const seg = m[0];
|
|
130
|
+
for (let i = 0; i + 2 <= seg.length; i++) out.push(seg.slice(i, i + 2));
|
|
131
|
+
}
|
|
132
|
+
// 单个汉字:query 是「锁」「锁」这类一字词时,2-gram 切不出来,
|
|
133
|
+
// 上面循环对 1 字串一次都不执行 → 恒返回空 → 搜不到(实测「锁」0 命中)。
|
|
134
|
+
// 补成单字 gram,存储侧同样产出它(见下)。
|
|
135
|
+
for (const m of text.matchAll(/[\u4e00-\u9fa5]/g)) out.push(m[0]);
|
|
136
|
+
for (const m of text.matchAll(/[A-Za-z][A-Za-z0-9_-]{1,}/g)) out.push(m[0].toLowerCase());
|
|
137
|
+
return [...new Set(out)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 丢掉所有表(版本升级用)。库是派生品,删了由 rebuild 从 markdown 重建。 */
|
|
141
|
+
function dropAll(db) {
|
|
142
|
+
for (const r of db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all()) {
|
|
143
|
+
db.exec(`DROP TABLE IF EXISTS "${r.name}"`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 打开(不存在就建)库。返回 { db, close }。 */
|
|
148
|
+
export function openDb(path = dbPath()) {
|
|
149
|
+
let db;
|
|
150
|
+
try {
|
|
151
|
+
db = new DatabaseSync(path);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
throw new DbError(`打不开数据库 ${path}: ${e.message}`, e);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
157
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
158
|
+
db.exec(SCHEMA);
|
|
159
|
+
|
|
160
|
+
// 版本检查与迁移。
|
|
161
|
+
// 坑(2026-11-01 实测):先报「跑 abs db rebuild 重建」是错的 —— rebuild 也走这里,
|
|
162
|
+
// 而 CREATE TABLE IF NOT EXISTS 不给已有表加列,老库重建时报
|
|
163
|
+
// "table nodes has no column named is_ledger",用户于是完全卡死。
|
|
164
|
+
// 库是**派生物**(markdown 才是真相),故版本不符直接丢表重建,不要用户动手。
|
|
165
|
+
const cur = db.prepare("SELECT value FROM meta WHERE key='version'").get();
|
|
166
|
+
if (!cur) {
|
|
167
|
+
db.prepare("INSERT INTO meta(key,value) VALUES('version',?)").run(String(DB_VERSION));
|
|
168
|
+
} else if (Number(cur.value) !== DB_VERSION) {
|
|
169
|
+
dropAll(db);
|
|
170
|
+
db.exec(SCHEMA); // 表被删了,必须重新建(含 meta)
|
|
171
|
+
db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('version',?)").run(String(DB_VERSION));
|
|
172
|
+
}
|
|
173
|
+
} catch (e) {
|
|
174
|
+
try { db.close(); } catch { /* 已关 */ }
|
|
175
|
+
if (e instanceof DbError) throw e;
|
|
176
|
+
throw new DbError(`库结构初始化失败: ${e.message}`, e);
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
db,
|
|
180
|
+
close: () => { try { db.close(); } catch { /* 已关 */ } },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 确保库目录存在(第一次用时建 ~/.abs/)。 */
|
|
185
|
+
export async function ensureDbDir(path = dbPath()) {
|
|
186
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 从 markdown 正文抽 title / tags / status(与页面 frontmatter 同源)。 */
|
|
190
|
+
function metaOf(body) {
|
|
191
|
+
const t = String(body || '');
|
|
192
|
+
const title = (t.match(/^title:\s*(.+)$/m) || [])[1]?.trim() || '';
|
|
193
|
+
const tags = (t.match(/^tags:\s*(.+)$/m) || [])[1]?.replace(/^\[|\]$/g, '').trim() || '';
|
|
194
|
+
const st = (t.match(/^status:\s*(.+)$/m) || [])[1]?.trim() || 'active';
|
|
195
|
+
return { title, tags, status: st };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 全量重建:清表 → 重扫所有注册项目 → 重建边与断点词。
|
|
199
|
+
* pages: [{ project, dir, slug, body, mtime }]
|
|
200
|
+
* 返回统计,供 CLI 报告。 */
|
|
201
|
+
export function rebuild(handle, pages) {
|
|
202
|
+
const { db } = handle;
|
|
203
|
+
const stat = { nodes: 0, edges: 0, anchors: 0, rules: 0, dangling: 0 };
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
db.exec('BEGIN');
|
|
206
|
+
try {
|
|
207
|
+
db.exec('DELETE FROM anchors; DELETE FROM edges; DELETE FROM nodes; DELETE FROM rules; DELETE FROM nodes_bigram;');
|
|
208
|
+
try { db.exec("INSERT INTO fts(fts) VALUES('delete-all')"); } catch { /* 空表时无可删 */ }
|
|
209
|
+
|
|
210
|
+
const insNode = db.prepare(`INSERT INTO nodes(project,dir,slug,layer,title,tags,status,body,mtime,is_ledger)
|
|
211
|
+
VALUES(?,?,?,?,?,?,?,?,?,?)`);
|
|
212
|
+
const insGram = db.prepare('INSERT OR REPLACE INTO nodes_bigram(id,g) VALUES(?,?)');
|
|
213
|
+
const insEdge = db.prepare('INSERT OR IGNORE INTO edges(src,dst,raw) VALUES(?,?,?)');
|
|
214
|
+
const insAnchor = db.prepare('INSERT OR IGNORE INTO anchors(word,node) VALUES(?,?)');
|
|
215
|
+
const insRule = db.prepare('INSERT OR IGNORE INTO rules(project,text) VALUES(?,?)');
|
|
216
|
+
|
|
217
|
+
// 第一遍:建节点,拿 id 映射 key = `project/dir/slug`
|
|
218
|
+
const idOf = new Map();
|
|
219
|
+
for (const p of pages) {
|
|
220
|
+
const { title, tags, status } = metaOf(p.body);
|
|
221
|
+
const { layer } = resolveLayer(p.body, p.dir);
|
|
222
|
+
const ledger = (p.dir === '' && LEDGER_SLUGS.has(p.slug)) ? 1 : 0;
|
|
223
|
+
const r = insNode.run(p.project, p.dir, p.slug, layer, title, tags, status,
|
|
224
|
+
p.body, p.mtime || now, ledger);
|
|
225
|
+
const id = Number(r.lastInsertRowid);
|
|
226
|
+
// FTS 源:正文 + 标题 + tags 切 2-gram。查询侧必须用同一个 bigrams()。
|
|
227
|
+
insGram.run(id, bigrams([p.slug, title, tags, p.body].join('\n')).join(' '));
|
|
228
|
+
idOf.set(`${p.project}/${p.dir}/${p.slug}`, id);
|
|
229
|
+
// 同名页兜底键:不带项目,供就近解析
|
|
230
|
+
if (!idOf.has(`${p.dir}/${p.slug}`)) idOf.set(`${p.dir}/${p.slug}`, id);
|
|
231
|
+
stat.nodes++;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// index.md 的 Rules → rules 表(L1)。只取 `## Rules` 区内的 `- ` 条目,
|
|
235
|
+
// 不能扫全文 —— 否则页面清单里每条 `- [[x]] — 一句` 都会被当铁律。
|
|
236
|
+
for (const p of pages) {
|
|
237
|
+
if (p.dir !== '' || p.slug !== 'index') continue;
|
|
238
|
+
const lines = String(p.body).split('\n');
|
|
239
|
+
const at = lines.findIndex((l) => l.trim() === '## Rules');
|
|
240
|
+
if (at === -1) continue;
|
|
241
|
+
for (const line of lines.slice(at + 1)) {
|
|
242
|
+
if (/^##\s/.test(line)) break; // 下个分区开始,停止
|
|
243
|
+
const t = line.trim();
|
|
244
|
+
if (!t.startsWith('- ')) continue;
|
|
245
|
+
insRule.run(p.project, t.slice(2).trim());
|
|
246
|
+
stat.rules++;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 第二遍:解析双链,建边 + 断点词。
|
|
251
|
+
// todo.md / log.md 里的 [[人名]] 是**作者标记**不是知识链接(实测:projX 的
|
|
252
|
+
// todo 连到了另一项目的 fanchao 页,是误连)。这两份文件只入节点、不抽边。
|
|
253
|
+
for (const p of pages) {
|
|
254
|
+
const srcId = idOf.get(`${p.project}/${p.dir}/${p.slug}`);
|
|
255
|
+
if (!srcId) continue;
|
|
256
|
+
const isLedger = p.dir === '' && (p.slug === 'todo' || p.slug === 'log');
|
|
257
|
+
if (isLedger) continue;
|
|
258
|
+
for (const word of anchorsOf(p.body)) {
|
|
259
|
+
insAnchor.run(word, srcId);
|
|
260
|
+
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);
|
|
266
|
+
if (dstId === undefined) {
|
|
267
|
+
insEdge.run(srcId, 0, word);
|
|
268
|
+
stat.dangling++;
|
|
269
|
+
} else {
|
|
270
|
+
insEdge.run(srcId, dstId, word);
|
|
271
|
+
stat.edges++;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('rebuilt_at',?)").run(String(now));
|
|
277
|
+
db.exec('COMMIT');
|
|
278
|
+
// FTS 重建必须在事务外(fts5 触发器在重建期间会读 nodes)
|
|
279
|
+
db.exec("INSERT INTO fts(fts) VALUES('rebuild')");
|
|
280
|
+
} catch (e) {
|
|
281
|
+
db.exec('ROLLBACK');
|
|
282
|
+
throw new DbError(`重建失败: ${e.message}`, e);
|
|
283
|
+
}
|
|
284
|
+
return stat;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 同名页全局兜底:取第一个命中的(跨项目引用靠 @前缀 显式写,这里只做兜底)。 */
|
|
288
|
+
function findGlobal(idOf, word) {
|
|
289
|
+
for (const [k, v] of idOf) {
|
|
290
|
+
if (k.endsWith(`/${word}`)) return v;
|
|
291
|
+
}
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------- 查询:FTS 拿种子 → 亮度扩散 ----------
|
|
296
|
+
|
|
297
|
+
/** 第一步:全文搜索拿种子节点。
|
|
298
|
+
* 实测 0.01ms/次 —— 「高频率搜索」的要求靠这一层满足。
|
|
299
|
+
* 返回 [{ id, project, dir, slug, title, layer, score }],按 FTS 相关度排序。 */
|
|
300
|
+
export function search(handle, query, { limit = 20 } = {}) {
|
|
301
|
+
const { db } = handle;
|
|
302
|
+
const grams = bigrams(query);
|
|
303
|
+
// 查询切成 2-gram 后用 OR 连 —— 越多 gram 命中越相关(bm25 自己会排序)。
|
|
304
|
+
if (!grams.length) return [];
|
|
305
|
+
const expr = grams.map((g) => `"${g.replace(/"/g, '""')}"`).join(' OR ');
|
|
306
|
+
try {
|
|
307
|
+
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
|
|
311
|
+
WHERE fts MATCH ? AND n.is_ledger = 0
|
|
312
|
+
ORDER BY rank
|
|
313
|
+
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) }));
|
|
321
|
+
} catch {
|
|
322
|
+
// 方言/语法问题不能让搜索整体挂掉 —— 退化成空种子,由调用方决定提示
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 第二步:从种子出发做有限扩散,算每页亮度。
|
|
328
|
+
*
|
|
329
|
+
* 为何必须压制(2026-11-01 实测):真实图谱 81 页 291 边,2 跳就到 95% 页面点亮,
|
|
330
|
+
* 「hook」一词 1 跳就 100%。全亮 = 全不亮,分不出哪页更相关。
|
|
331
|
+
* 故硬约束:最多 HOPS 跳,且每跳乘衰减,低于 MIN_BRIGHT 直接丢。
|
|
332
|
+
*
|
|
333
|
+
* 为何要枢纽惩罚:实测扩散把入度 26 的根节点顶到前三,不是因为它相关,
|
|
334
|
+
* 是因为人人链它。对被链得多的页打折(近似 IDF),枢纽不再霸榜。
|
|
335
|
+
*
|
|
336
|
+
* @returns [{ id, project, dir, slug, title, layer, bright, from }]
|
|
337
|
+
*/
|
|
338
|
+
const HOPS = 1; // 实测 2 跳即 95% 全亮,只走 1 跳
|
|
339
|
+
const MIN_BRIGHT = 0.02;
|
|
340
|
+
|
|
341
|
+
export function illuminate(handle, seeds, { hops = HOPS, limit = 10 } = {}) {
|
|
342
|
+
const { db } = handle;
|
|
343
|
+
if (!seeds.length) return [];
|
|
344
|
+
|
|
345
|
+
// 入度表:用于枢纽惩罚。全表一次查完(81 页量级,可忽略)。
|
|
346
|
+
const inDeg = new Map();
|
|
347
|
+
for (const r of db.prepare('SELECT dst, count(*) c FROM edges WHERE dst > 0 GROUP BY dst').all())
|
|
348
|
+
inDeg.set(r.dst, r.c);
|
|
349
|
+
|
|
350
|
+
const bright = new Map(); // id -> { bright, from }
|
|
351
|
+
for (const s of seeds) {
|
|
352
|
+
const b = s.bright ?? 1;
|
|
353
|
+
if (!bright.has(s.id) || bright.get(s.id).bright < b) bright.set(s.id, { bright: b, from: 'seed' });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 扩散:出边 + 入边都算(双向连接),乘该层衰减,再按枢纽惩罚打折
|
|
357
|
+
let frontier = [...bright.entries()].map(([id, v]) => ({ id, bright: v.bright }));
|
|
358
|
+
for (let hop = 0; hop < hops; hop++) {
|
|
359
|
+
const next = [];
|
|
360
|
+
for (const f of frontier) {
|
|
361
|
+
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);
|
|
362
|
+
const layerRow = db.prepare('SELECT layer FROM nodes WHERE id = ?').get(f.id);
|
|
363
|
+
const decay = decayOf(layerRow?.layer ?? DEFAULT_LAYER);
|
|
364
|
+
for (const n of neigh) {
|
|
365
|
+
if (bright.has(n.id)) continue;
|
|
366
|
+
// 枢纽惩罚:入度越高,传出去越弱。1/(1+log(1+入度))
|
|
367
|
+
const deg = inDeg.get(n.id) || 0;
|
|
368
|
+
const hub = 1 / (1 + Math.log(1 + deg));
|
|
369
|
+
const b = f.bright * (1 - decay) * hub;
|
|
370
|
+
if (b < MIN_BRIGHT) continue;
|
|
371
|
+
bright.set(n.id, { bright: b, from: 'spread' });
|
|
372
|
+
next.push({ id: n.id, bright: b });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
frontier = next;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const out = [];
|
|
379
|
+
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 });
|
|
382
|
+
}
|
|
383
|
+
return out.sort((a, b) => b.bright - a.bright || a.id - b.id).slice(0, limit);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** L1 铁律:按项目取出。检索任何东西都必须先带上它(「不可逾越」的代码保证)。 */
|
|
387
|
+
export function rulesOf(handle, project) {
|
|
388
|
+
const { db } = handle;
|
|
389
|
+
return db.prepare('SELECT text FROM rules WHERE project = ? OR project = \'\' ORDER BY id')
|
|
390
|
+
.all(project || '').map((r) => r.text);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** 统计,供 status / lint 报告。 */
|
|
394
|
+
export function stats(handle) {
|
|
395
|
+
const { db } = handle;
|
|
396
|
+
const one = (sql) => db.prepare(sql).get();
|
|
397
|
+
return {
|
|
398
|
+
nodes: one('SELECT count(*) c FROM nodes').c,
|
|
399
|
+
edges: one('SELECT count(*) c FROM edges WHERE dst > 0').c,
|
|
400
|
+
dangling: one('SELECT count(*) c FROM edges WHERE dst = 0').c,
|
|
401
|
+
anchors: one('SELECT count(DISTINCT word) c FROM anchors').c,
|
|
402
|
+
rules: one('SELECT count(*) c FROM rules').c,
|
|
403
|
+
projects: one('SELECT count(DISTINCT project) c FROM nodes WHERE project != \'\'').c,
|
|
404
|
+
byLayer: db.prepare('SELECT layer, count(*) c FROM nodes GROUP BY layer ORDER BY layer').all(),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/brain/gate.js — 写入侧格式闸门注册。
|
|
2
|
+
// 从 store.js 拆出(2026-11-01):闸门是「哪个文件用哪套标准」的全局注册,
|
|
3
|
+
// 与建骨架无关。靠 import 副作用生效 —— 模块顶层即注册。
|
|
4
|
+
import { setFormatGate } from '../lock.js';
|
|
5
|
+
import { enforceBrainFormat, ensureStateMark } from '../todo.js';
|
|
6
|
+
import { BRAIN_SHAPE, legacyRenames } from './shape.js';
|
|
7
|
+
|
|
8
|
+
/** 写入侧格式闸门:lock.js 的 editFile 是所有写入的唯一收口,
|
|
9
|
+
* 这里把「哪个文件名用哪套标准」告诉它 —— 三个目标文件之外的写入一律放行。
|
|
10
|
+
* 非目标文件返回 null 表示不管。 */
|
|
11
|
+
setFormatGate((file, text, current) => {
|
|
12
|
+
const name = file.split('/').pop();
|
|
13
|
+
const spec = BRAIN_SHAPE[name];
|
|
14
|
+
if (!spec) return null;
|
|
15
|
+
const r = enforceBrainFormat(text, { ...spec, renames: legacyRenames() }, current);
|
|
16
|
+
return name === 'todo.md'
|
|
17
|
+
? r.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
|
|
18
|
+
: r.text;
|
|
19
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// src/brain/init.js — 建 .brain/ 骨架、结构体检、三个受管文件的模板。
|
|
2
|
+
// 从 store.js 拆出(2026-11-01):职责单一(只管骨架的生与补),与 load/task/rules 无关。
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { getUser } from '../userconfig.js';
|
|
6
|
+
import { editFile, SKIP } from '../lock.js';
|
|
7
|
+
import { todoTemplate, rebuildStructure } from '../todo.js';
|
|
8
|
+
|
|
9
|
+
const BRAIN_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
|
|
10
|
+
const BRAIN_FILES = ['index.md', 'log.md', 'todo.md'];
|
|
11
|
+
|
|
12
|
+
export async function cmdInit({ dir }) {
|
|
13
|
+
const root = resolveProjectDir(dir);
|
|
14
|
+
const brain = join(root, '.brain');
|
|
15
|
+
try {
|
|
16
|
+
await fs.access(brain);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (e && e.code !== 'ENOENT') throw e;
|
|
19
|
+
return createSkeleton(root, brain);
|
|
20
|
+
}
|
|
21
|
+
// 已存在 → 校验结构完整性;损坏则明确报错并指引 repair(不静默半初始化)
|
|
22
|
+
const report = await reportBrain({ dir: root });
|
|
23
|
+
if (report.ok) throw new Error(`已存在: ${brain} (abort)`);
|
|
24
|
+
throw new Error(
|
|
25
|
+
`已存在但结构不完整:\n ${report.problems.join('\n ')}\n` +
|
|
26
|
+
` → 轻则补齐: abs init --repair 重则重建: 删掉 ${brain} 后重跑 abs init`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function createSkeleton(root, brain) {
|
|
31
|
+
for (const d of BRAIN_DIRS) await fs.mkdir(join(brain, d), { recursive: true });
|
|
32
|
+
// 走 editFile 而不是 fs.writeFile:三个文件是受管表,写入必须过同一道闸门
|
|
33
|
+
// (2026-10-18:原来直写盘,是个绕过口)。模板本身合法,闸门只做归一。
|
|
34
|
+
for (const [f, tpl] of [['index.md', indexTemplate], ['log.md', logTemplate], ['todo.md', todoTemplate]]) {
|
|
35
|
+
await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
|
|
36
|
+
}
|
|
37
|
+
// 建完就提示设姓名 —— 否则用户一路到第一次 todo add 才撞墙,
|
|
38
|
+
// 中间 init/load 都不提,根本不知道该设。
|
|
39
|
+
const who = await getUser();
|
|
40
|
+
const hint = who ? '' : `\n ⚠ 尚未设置使用者姓名,写操作(todo add/log/note)会先报错。\n 请先设置: abs config set user <你的名字>`;
|
|
41
|
+
return `✓ 已建图谱 ${brain}\n (模板见 SKILL.md「.brain/ 怎么组织」)${hint}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 结构体检:6 目录 + 3 文件逐一核对。ok=false 时 problems 列出缺失项。 */
|
|
45
|
+
export async function reportBrain({ dir }) {
|
|
46
|
+
const root = resolveProjectDir(dir);
|
|
47
|
+
const brain = join(root, '.brain');
|
|
48
|
+
const problems = [];
|
|
49
|
+
for (const d of BRAIN_DIRS) {
|
|
50
|
+
try {
|
|
51
|
+
const st = await fs.stat(join(brain, d));
|
|
52
|
+
if (!st.isDirectory()) problems.push(`缺目录 ${d}/ (被文件占位)`);
|
|
53
|
+
} catch {
|
|
54
|
+
problems.push(`缺目录 ${d}/`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const f of BRAIN_FILES) {
|
|
58
|
+
try {
|
|
59
|
+
await fs.access(join(brain, f));
|
|
60
|
+
} catch {
|
|
61
|
+
problems.push(`缺文件 ${f}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { ok: problems.length === 0, problems, brain };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** init --repair: 只补缺失骨架,绝不覆盖已有文件。 */
|
|
68
|
+
export async function cmdRepair({ dir }) {
|
|
69
|
+
const root = resolveProjectDir(dir);
|
|
70
|
+
const brain = join(root, '.brain');
|
|
71
|
+
try {
|
|
72
|
+
await fs.access(brain);
|
|
73
|
+
} catch {
|
|
74
|
+
return createSkeleton(root, brain); // 整个图谱都没有 = 全新建
|
|
75
|
+
}
|
|
76
|
+
const fixed = [];
|
|
77
|
+
for (const d of BRAIN_DIRS) {
|
|
78
|
+
try {
|
|
79
|
+
const st = await fs.stat(join(brain, d));
|
|
80
|
+
if (!st.isDirectory()) {
|
|
81
|
+
await fs.rm(join(brain, d), { force: true });
|
|
82
|
+
await fs.mkdir(join(brain, d), { recursive: true });
|
|
83
|
+
fixed.push(`${d}/`);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
await fs.mkdir(join(brain, d), { recursive: true });
|
|
87
|
+
fixed.push(`${d}/`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const [f, tpl] of [['index.md', indexTemplate], ['log.md', logTemplate], ['todo.md', todoTemplate]]) {
|
|
91
|
+
try {
|
|
92
|
+
await fs.access(join(brain, f));
|
|
93
|
+
} catch {
|
|
94
|
+
// 同 createSkeleton:补齐也走 editFile 的口子(已存在则 cur 非 null,不覆盖)
|
|
95
|
+
await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
|
|
96
|
+
fixed.push(f);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!fixed.length) return `✓ 结构完整,无需修复: ${brain}`;
|
|
100
|
+
return `✓ 已补齐 ${fixed.join(', ')} → ${brain}\n (已有文件一律不覆盖)`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveProjectDir(dir) {
|
|
104
|
+
// 必须显式回退 cwd: resolve(undefined) 会抛 ERR_INVALID_ARG_TYPE,
|
|
105
|
+
// 并不像看上去那样「自动回退」。曾误删此分支为 resolve(dir),把 abs init/load/board
|
|
106
|
+
// 不带 --dir 全部变成裸栈崩溃。
|
|
107
|
+
return resolve(dir || process.cwd());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function indexTemplate() {
|
|
111
|
+
// 同 todoTemplate:由 rebuildStructure 生成,模板 = 重排结果,不会来回抖。
|
|
112
|
+
return rebuildStructure(
|
|
113
|
+
['# 🗂 Graph Index', '',
|
|
114
|
+
'本文件唯一入口。每新建/大改一页,同步在此分类下加一行 [[页面名]] — 一句话。', '',
|
|
115
|
+
'## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'].join('\n'),
|
|
116
|
+
{
|
|
117
|
+
h1: '# 🗂 Graph Index',
|
|
118
|
+
// 无 `## Roadmap`:它是 AI 自己写的方向总结,会被 load 反复读到并带偏后续会话
|
|
119
|
+
// (“看似是总结指引,其实是 AI 的总结指引” —— 2026-09-13 用户决策删除)。
|
|
120
|
+
order: ['## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
|
|
121
|
+
},
|
|
122
|
+
).text;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function logTemplate() {
|
|
126
|
+
// ★ 占位符必须是注释形式,不能是真条目形状(2026-10-05 实测):
|
|
127
|
+
// 原来写的是 `## [YYYY-MM-DD] ingest | 沉淀 <slug>` —— 看起来像条目,
|
|
128
|
+
// 但它不是真日期、也不是真 kind,条目形状闸门上线后**模板自己就非法**,
|
|
129
|
+
// 新条目插到它上面后它掉到第 4 行,直接把写入卡死。
|
|
130
|
+
// 用 HTML 注释保留「怎么写」的提示,同时不参与任何解析。
|
|
131
|
+
return [
|
|
132
|
+
'# 🗒 Activity Log', '',
|
|
133
|
+
'<!-- 每条一行:## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文(kind: note|dev|concept|ingest) -->', ''
|
|
134
|
+
].join('\n');
|
|
135
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/brain/layers.js — 七层知识分层:唯一的层级判据来源。
|
|
2
|
+
//
|
|
3
|
+
// 为何要这一层(2026-11-01 用户定):知识性质不同,检索权重就该不同。
|
|
4
|
+
// 铁律和随口一句话混在同一张图里、检索时一视同仁,是当前的真实缺陷。
|
|
5
|
+
// 分层的唯一技术作用 = 每层的衰减/权重不同,不是分类好看。
|
|
6
|
+
//
|
|
7
|
+
// 七层(用户原话):
|
|
8
|
+
// L1 铁律 —— 任何智能体不可逾越。物理位置 = index.md 的 `## Rules` 区,不是页。
|
|
9
|
+
// L2 规则 —— 执行规则。
|
|
10
|
+
// L3 工具 —— 工具使用经验。
|
|
11
|
+
// L4 认知 —— 踩坑、实用经验、总结、重点事件。(默认层)
|
|
12
|
+
// L5 会话 —— 会话内容、todo 记录、任务总结。物理位置 = sessions/ + todo.md。
|
|
13
|
+
// L6 断点 —— 双链词,即页面里 `[[链接]]` 的链接名。不是页,是词的索引。
|
|
14
|
+
// L7 混沌 —— 未分辨信息、大量琐碎会话。物理位置 = sources/。
|
|
15
|
+
//
|
|
16
|
+
// 设计约束(「稳定可靠」的三条硬指标):
|
|
17
|
+
// 1. 层归属唯一:每页有且只有一个 layer;缺省补 4;非法值报错,不静默当成 4。
|
|
18
|
+
// 2. 层不会漂:写入走 frontmatter,读取时校验,漂了能被 lint 查出来。
|
|
19
|
+
// 3. 降级不崩:缺字段/坏库/缺项目 → 按目录推层,继续能跑。
|
|
20
|
+
|
|
21
|
+
export const LAYERS = [
|
|
22
|
+
{ id: 1, key: 'law', name: '铁律', decay: 0, note: '永不衰减,每次检索必出' },
|
|
23
|
+
{ id: 2, key: 'rule', name: '规则', decay: 0.2, note: '执行规则' },
|
|
24
|
+
{ id: 3, key: 'tool', name: '工具', decay: 0.4, note: '工具使用经验' },
|
|
25
|
+
{ id: 4, key: 'cognition', name: '认知', decay: 0.6, note: '踩坑、经验、重点事件(默认层)' },
|
|
26
|
+
{ id: 5, key: 'session', name: '会话', decay: 0.75, note: '会话内容、todo、任务总结' },
|
|
27
|
+
{ id: 6, key: 'anchor', name: '断点', decay: 0.85, note: '双链词索引,不占页面' },
|
|
28
|
+
{ id: 7, key: 'chaos', name: '混沌', decay: 0.95, note: '未分辨的琐碎信息' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const LAYER_IDS = LAYERS.map((l) => l.id);
|
|
32
|
+
export const DEFAULT_LAYER = 4;
|
|
33
|
+
|
|
34
|
+
const BY_ID = new Map(LAYERS.map((l) => [l.id, l]));
|
|
35
|
+
|
|
36
|
+
/** 目录 → 层 的降级映射。只在无 layer 字段时用(用户定的自动迁移规则)。 */
|
|
37
|
+
export const DIR_LAYER = {
|
|
38
|
+
sessions: 5,
|
|
39
|
+
sources: 7,
|
|
40
|
+
concepts: 4,
|
|
41
|
+
entities: 4,
|
|
42
|
+
syntheses: 4,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** 层元信息。非法 id 返回 null(调用方据此报错,不静默兜底)。 */
|
|
46
|
+
export function layerOf(id) {
|
|
47
|
+
return BY_ID.get(Number(id)) || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 该层的衰减系数。L1 = 0 表示不衰减。 */
|
|
51
|
+
export function decayOf(id) {
|
|
52
|
+
const l = layerOf(id);
|
|
53
|
+
return l ? l.decay : BY_ID.get(DEFAULT_LAYER).decay;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 解析页面 fullmatch 里的 layer 字段。
|
|
57
|
+
* 返回 { layer, source, error }:
|
|
58
|
+
* source='frontmatter' 明确写了;'dir' 按目录推的;'default' 两者都没有。
|
|
59
|
+
* error 非空表示字段存在但非法 —— 调用方必须报出来,不能当正常。
|
|
60
|
+
*/
|
|
61
|
+
export function resolveLayer(body, dirName) {
|
|
62
|
+
const m = String(body || '').match(/^layer:\s*(.+)$/m);
|
|
63
|
+
if (m) {
|
|
64
|
+
const raw = m[1].trim();
|
|
65
|
+
const n = Number(raw);
|
|
66
|
+
if (Number.isInteger(n) && BY_ID.has(n)) return { layer: n, source: 'frontmatter', error: null };
|
|
67
|
+
return {
|
|
68
|
+
layer: DIR_LAYER[dirName] ?? DEFAULT_LAYER,
|
|
69
|
+
source: 'dir',
|
|
70
|
+
error: `layer 非法(读到 "${raw}",应为 1-7 的整数)`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const byDir = DIR_LAYER[dirName];
|
|
74
|
+
if (byDir) return { layer: byDir, source: 'dir', error: null };
|
|
75
|
+
return { layer: DEFAULT_LAYER, source: 'default', error: null };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 模板占位符 —— 它们出现在正文里是在**讨论语法**,不是真链接。
|
|
79
|
+
* 实测(2026-11-01):.brain 里 `[[作者]]` `[[页面名]]` `[[slug]]` 被当成真双链,
|
|
80
|
+
* 污染断点词表并被报成悬挂链接。排除它们是判据,不是特例。 */
|
|
81
|
+
const PLACEHOLDER = new Set(['作者', '页面名', 'slug', '页面', '页名', '名称', 'x', 'y', 'foo', 'bar']);
|
|
82
|
+
|
|
83
|
+
/** 提取页面里的双链词(L6 断点层的数据来源)。
|
|
84
|
+
* `[[x]]` / `[[x|别名]]` / `[[x#小节]]` 都取 x。
|
|
85
|
+
* 代码块与行内代码里的 `[[x]]` 是示例,不算。 */
|
|
86
|
+
export function anchorsOf(body) {
|
|
87
|
+
const out = new Set();
|
|
88
|
+
let text = String(body || '');
|
|
89
|
+
// 去掉围栏代码块与行内代码 —— 那里面的双链是文档示例
|
|
90
|
+
text = text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ');
|
|
91
|
+
for (const m of text.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)) {
|
|
92
|
+
const t = m[1].trim();
|
|
93
|
+
if (!t) continue;
|
|
94
|
+
if (PLACEHOLDER.has(t)) continue;
|
|
95
|
+
// 纯符号/纯数字的不是页名
|
|
96
|
+
if (!/[\p{L}\p{N}]/u.test(t)) continue;
|
|
97
|
+
out.add(t);
|
|
98
|
+
}
|
|
99
|
+
return [...out];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 校验一页的层是否与目录相符,供 lint 用。
|
|
103
|
+
* 只报「明确写了 layer 且与目录默认层冲突」的情况 —— 那是人有意为之的,
|
|
104
|
+
* 值得看一眼;纯按目录推的不报(否则每页都是噪音)。 */
|
|
105
|
+
export function layerConflict(body, dirName) {
|
|
106
|
+
const r = resolveLayer(body, dirName);
|
|
107
|
+
if (r.error) return r.error;
|
|
108
|
+
if (r.source !== 'frontmatter') return null;
|
|
109
|
+
const expect = DIR_LAYER[dirName];
|
|
110
|
+
if (!expect || expect === r.layer) return null;
|
|
111
|
+
return `layer: ${r.layer} 与目录 ${dirName}/(默认 ${expect})不符`;
|
|
112
|
+
}
|