@fanchao8609/agent_brain_sync 1.9.5 → 1.9.7

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/lint.js ADDED
@@ -0,0 +1,327 @@
1
+ // src/lint.js — 图谱体检(lint 检查逻辑)。
2
+ // 从 store.js 提取:cmdLint 过大(268 行),拆为 checkPage / checkGraph / checkFiles 三个子函数。
3
+ import { promises as fs } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { requireBrain, brainPath, BRAIN_DIR } from './index.js';
6
+ import { readRules, idOfPage, statusOfPage, supersededByOf, clip } from './store.js';
7
+ import { doneKindOf, doneDateOf } from './todo.js';
8
+
9
+ // ---------- 图谱遍历 ----------
10
+
11
+ export const PAGE_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
12
+
13
+ // concept/entity/synthesis 页的容量上限,超出提示"拆或外链"。
14
+ // 曾为 150L/5120B —— 实测偏紧:跨 4 项目 68 页里仅 2 页超限,且都只超一点
15
+ // (5463B / 5440B);为满足它还把一页从 5319B 压到 4972B(内容受损、收益为零)。
16
+ // 放宽到 8KB:当前最大页 5463B,留约 50% 余量,但不至于失去"该拆了"的信号。
17
+ // 提成常量避免检查条件与提示文本各写一份而漂移。
18
+ const PAGE_MAX_LINES = 150;
19
+
20
+ /** 尾巴关键词:只要段名里带这些「动作词」,就认为作者在给「做完怎么确认」。
21
+ * 为何不限定叫「验证」:实测 26 页段名高度分散(`## ✅ 处置` 17 次 > `## 🛠 解法`),
22
+ * 只认「验证」会误报 7/11(64%)—— 噪音会让规则失去意义。 */
23
+ const TAIL_WORDS = '验证|检查|清单|测试|处置|做法|步骤|顺序|判据|信号|怎么';
24
+
25
+ /** concept 页是否有「尾」(可执行的东西)。
26
+ * 两种真实形态都算:
27
+ * 1. 有带动作词的段标题,且**段内有真内容** —— 排除 `abs concept` 骨架的
28
+ * `## 验证` + `<!-- 占位 -->`(只看标题会把未填的骨架误判为有尾)。
29
+ * 2. 列表项形式,如 `3. 验证命令:\`cmd\``(hook-sh-not-bash / todo-rewrite-not-map 的写法)。
30
+ * 逐行扫描而非复杂正则:需要「段边界」与「占位识别」,正则会难读且难改。 */
31
+ export function hasTail(body) {
32
+ const lines = String(body || '').split('\n');
33
+ const headRe = new RegExp(`^#{2,6}[^\\n]*(${TAIL_WORDS})`);
34
+ for (let i = 0; i < lines.length; i++) {
35
+ if (!headRe.test(lines[i])) continue;
36
+ const lvl = lines[i].match(/^#+/)[0].length;
37
+ for (let j = i + 1; j < lines.length; j++) {
38
+ const t = lines[j].trim();
39
+ const h = lines[j].match(/^(#+)\s/);
40
+ if (h && h[1].length <= lvl) break; // 本段结束,换下一段找
41
+ if (!t) continue;
42
+ if (t.startsWith('<!--') || t === '-->') continue; // 占位注释不算内容
43
+ return true;
44
+ }
45
+ }
46
+ return new RegExp(`^\\s*(?:\\d+\\.|[-*])\\s*\\**[^\\n]{0,20}(${TAIL_WORDS})`, 'm').test(String(body || ''));
47
+ }
48
+ const PAGE_MAX_BYTES = 8 * 1024;
49
+
50
+ // source 页超龄未提炼的天数阈值(SOURCE-UNDISTILLED)。
51
+ // 7 天 = 跨过至少一个完整工作周还没人提炼,基本等于被遗忘。
52
+ const SOURCE_STALE_DAYS = 7;
53
+ // draft 超龄阈值:新落的经验(abs note)默认 draft,指的是"还没核实过"。
54
+ // 长期停在 draft = 既没被核实也没被推翻,属"写了没人看"的堆积 —— 不自动改,只报。
55
+ const DRAFT_STALE_DAYS = 14;
56
+
57
+ // sources/ 目录文件数上限,超出提示提炼归档。与 cmdLoad 共用(load 做提醒,lint 做报错)。
58
+ export const SOURCES_MAX = 10;
59
+
60
+ // Rules 区条目上限(cmdLint 专用,cmdRule 有自己的 RULES_MAX)。
61
+ const LINT_RULES_MAX = 30;
62
+
63
+ export async function listPages(vault) {
64
+ let indexText = '';
65
+ try {
66
+ indexText = await fs.readFile(join(vault, 'index.md'), 'utf8');
67
+ } catch { /* no index yet */ }
68
+ const pages = [];
69
+ for (const d of PAGE_DIRS) {
70
+ const dp = join(vault, d);
71
+ let files;
72
+ try {
73
+ files = await fs.readdir(dp);
74
+ } catch {
75
+ continue;
76
+ }
77
+ for (const f of files) {
78
+ if (!f.endsWith('.md') || f.startsWith('_')) continue;
79
+ const full = join(dp, f);
80
+ const body = await fs.readFile(full, 'utf8').catch(() => '');
81
+ const fm = body.match(/^---\n([\s\S]*?)\n---/);
82
+ const links = [...new Set([...body.matchAll(/\[\[([^\]]+)\]\]/g)].map((m) => m[1].split('|')[0]))];
83
+ pages.push({
84
+ dir: d,
85
+ // 带上 .brain/ 前缀:这串会原样出现在 lint 提示里,用户会拿它去找文件。
86
+ // 坑: 曾经只给 vault 相对路径(concepts/x.md),用户到项目根找 concepts/ 找不到。
87
+ rel: `${BRAIN_DIR}/${d}/${f}`,
88
+ slug: f.replace(/\.md$/, ''),
89
+ body,
90
+ frontmatter: fm ? fm[1] : '',
91
+ hasFrontmatter: body.startsWith('---\n'),
92
+ links,
93
+ lines: body.split('\n').length,
94
+ bytes: Buffer.byteLength(body, 'utf8'),
95
+ indexed: indexText.includes(`[[${f.replace(/\.md$/, '')}]]`),
96
+ status: statusOfPage(body, fm ? fm[1] : ''),
97
+ supersededBy: supersededByOf(body),
98
+ });
99
+ }
100
+ }
101
+ return pages;
102
+ }
103
+
104
+ // ---------- 页级检查 ----------
105
+
106
+ /** 单页检查:frontmatter / 链接 / 容量 / 尾巴 / index 登记。 */
107
+ function checkPage(pg, names, linkedNames, inbound) {
108
+ const issues = [];
109
+ if (!pg.hasFrontmatter) issues.push(`NO-FRONTMATTER: ${pg.rel}`);
110
+ // ID-DRIFT: 页面写了 id 但已跟文件名(slug)不一致 = 改过名或改过 id。
111
+ if (pg.hasFrontmatter) {
112
+ const id = idOfPage(pg.body, pg.slug);
113
+ if (id !== pg.slug) {
114
+ issues.push(`ID-DRIFT: ${pg.rel} (frontmatter id=${id} ≠ 文件名 ${pg.slug};` +
115
+ `引用请用 [[${id}]] 或改回文件名)`);
116
+ }
117
+ }
118
+ for (const ln of pg.links) {
119
+ if (/^<.+>$/.test(ln) || /slug|Name|name|Date|页面名$/.test(ln)) {
120
+ issues.push(`TEMPLATE-LINK: ${pg.rel} -> [[${ln}]]`);
121
+ } else if (!names.has(ln)) {
122
+ issues.push(`DEAD-LINK: ${pg.rel} -> [[${ln}]]`);
123
+ }
124
+ }
125
+ const isTerminal = pg.dir === 'sources'
126
+ || /todo归档$/.test(pg.slug)
127
+ || (pg.dir === 'sessions' && /^log-/.test(pg.slug));
128
+ if (!isTerminal && !pg.links.length && !linkedNames.has(pg.slug)) {
129
+ issues.push(`ORPHAN-PAGE: ${pg.rel} (no links out, no links in)`);
130
+ }
131
+ if (['concepts', 'entities', 'syntheses'].includes(pg.dir) && !(inbound.get(pg.slug) || 0)) {
132
+ issues.push(`NO-INBOUND: ${pg.rel} (无人链接到本页;在相关页的 ## 关联连接 挂一条 [[${pg.slug}]])`);
133
+ }
134
+ if (/^#{2,6}[^\n]*知识冲突/m.test(pg.body) && /status: draft/.test(pg.frontmatter)) {
135
+ issues.push(`UNRESOLVED-CONFLICT: ${pg.rel}`);
136
+ }
137
+ if (['concepts', 'entities', 'syntheses'].includes(pg.dir)) {
138
+ if (pg.lines > PAGE_MAX_LINES || pg.bytes > PAGE_MAX_BYTES) {
139
+ issues.push(`OVER-SIZE: ${pg.rel} (${pg.lines}L/${pg.bytes}B > ${PAGE_MAX_LINES}L/${PAGE_MAX_BYTES / 1024}KB; 拆或外链)`);
140
+ }
141
+ }
142
+ if (pg.dir === 'concepts' && !hasTail(pg.body)) {
143
+ issues.push(`NO-TAIL: ${pg.rel}(无「做完怎么确认」;尾巴写清跑什么/看什么/按什么判据,别只有头)`);
144
+ }
145
+ if (pg.dir !== 'sources' && !pg.indexed) {
146
+ issues.push(`INDEX-MISSING: ${pg.rel} not listed as [[${pg.slug}]] in index.md`);
147
+ }
148
+ return issues;
149
+ }
150
+
151
+ // ---------- 跨页图谱检查 ----------
152
+
153
+ /** 跨页检查:sources 堆积/超龄、sessions 命名、superseded 悬挂、draft 超龄。 */
154
+ async function checkGraph(root, pages) {
155
+ const issues = [];
156
+ const nsrc = pages.filter((p) => p.dir === 'sources').length;
157
+ if (nsrc > SOURCES_MAX) issues.push(`SOURCES-PILED-UP: sources/ has ${nsrc} files > ${SOURCES_MAX}; 提炼归档旧 source`);
158
+
159
+ const conceptSlugs = new Set(pages.filter((p) => p.dir === 'concepts').map((p) => p.slug));
160
+ const staleMs = SOURCE_STALE_DAYS * 86400 * 1000;
161
+ for (const pg of pages) {
162
+ if (pg.dir !== 'sources') continue;
163
+ if (pg.links.some((ln) => conceptSlugs.has(ln))) continue;
164
+ let ageMs = 0;
165
+ try { ageMs = Date.now() - (await fs.stat(join(root, pg.rel))).mtimeMs; } catch { continue; }
166
+ if (ageMs > staleMs) {
167
+ issues.push(`SOURCE-UNDISTILLED: ${pg.rel}(${SOURCE_STALE_DAYS} 天未提炼成 concept;提炼后删 source 并清引用)`);
168
+ }
169
+ }
170
+
171
+ // SESSIONS-NAMING: sessions/ 的一天一文件契约
172
+ const sessByDate = new Map();
173
+ for (const pg of pages) {
174
+ if (pg.dir !== 'sessions') continue;
175
+ const m = pg.slug.match(/^(?:log-)?(\d{4}-\d{2}-\d{2})/);
176
+ if (!m) continue;
177
+ const tags = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
178
+ const tagList = tags.replace(/^\[|\]$/g, '').split(',').map((t) => t.trim());
179
+ if (tagList.includes('archive')) continue;
180
+ if (!sessByDate.has(m[1])) sessByDate.set(m[1], []);
181
+ sessByDate.get(m[1]).push(pg.slug);
182
+ }
183
+ for (const [date, slugs] of sessByDate) {
184
+ if (slugs.length > 1) {
185
+ issues.push(`SESSIONS-SPLIT: ${date} 在 sessions/ 有 ${slugs.length} 个文件(${slugs.join('、')});` +
186
+ `一天只应有一个 \`log-${date}.md\`:归档写进其「## 📦 任务归档」段,多主题写成多个 ## 子段`);
187
+ } else if (!slugs[0].startsWith('log-')) {
188
+ issues.push(`SESSIONS-NAMING: ${date} 的文件 \`${slugs[0]}.md\` 不是规范名;` +
189
+ `应为 \`log-${date}.md\`(跑 abs todo archive 会并入;旧归档页可删)`);
190
+ }
191
+ }
192
+
193
+ // SESSIONS-MISPLACED: sessions/ 里放了不属 sessions 的页
194
+ for (const pg of pages) {
195
+ if (pg.dir !== 'sessions') continue;
196
+ const tags = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
197
+ if (!/session-log|todo-archive|archive/.test(tags)) {
198
+ issues.push(`SESSIONS-MISPLACED: ${pg.rel}(tags: ${tags.trim()} 不属 sessions/;` +
199
+ `当天工作写进 \`log-<日期>.md\` 正文,暂存线索用 \`abs note\` 落 sources/)`);
200
+ }
201
+ }
202
+
203
+ // SUPERSEDED-DANGLING
204
+ for (const pg of pages) {
205
+ if (pg.status !== 'superseded') continue;
206
+ if (!pg.supersededBy) continue;
207
+ const by = pg.supersededBy.replace(/^\[\[|\]\]$/g, '').trim();
208
+ const names = new Set(pages.map((p) => p.slug));
209
+ if (!names.has(by)) {
210
+ issues.push(`SUPERSEDED-DANGLING: ${pg.rel} (superseded-by: ${by} —— 该页不存在,删页后未同步)`);
211
+ }
212
+ }
213
+
214
+ // DRAFT-STALE
215
+ for (const pg of pages) {
216
+ if (pg.status !== 'draft') continue;
217
+ if (pg.dir === 'sources') continue;
218
+ let ageMs = 0;
219
+ try { ageMs = Date.now() - (await fs.stat(join(root, pg.rel))).mtimeMs; } catch { continue; }
220
+ if (ageMs > DRAFT_STALE_DAYS * 86400 * 1000) {
221
+ const days = Math.floor(ageMs / 86400000);
222
+ issues.push(`DRAFT-STALE: ${pg.rel}(已 ${days} 天停在 draft;核实后改 status: active,推翻则 abs supersede)`);
223
+ }
224
+ }
225
+ return issues;
226
+ }
227
+
228
+ // ---------- 文件级检查(index / todo / rules) ----------
229
+
230
+ /** index.md 反向检查 + todo Done 区堆积 + Rules 区检查。 */
231
+ async function checkFiles(vault, pages, indexLinks) {
232
+ const issues = [];
233
+ const names = new Set(pages.map((p) => p.slug));
234
+ for (const f of await fs.readdir(vault).catch(() => [])) {
235
+ if (f.endsWith('.md')) names.add(f.replace(/\.md$/, ''));
236
+ }
237
+
238
+ // index.md 反向检查
239
+ for (const ln of indexLinks) {
240
+ if (/slug|Name|name|Date|页面名$/.test(ln)) continue;
241
+ if (!names.has(ln)) issues.push(`INDEX-DEAD-LINK: index.md -> [[${ln}]] (该页不存在, 删页后忘清 index?)`);
242
+ }
243
+
244
+ // Done 区堆积
245
+ const todoTxt = await fs.readFile(join(vault, 'todo.md'), 'utf8').catch(() => '');
246
+ const di = todoTxt.split('\n').findIndex((l) => l.startsWith('## Done'));
247
+ if (di !== -1) {
248
+ const doneLines = todoTxt.split('\n').slice(di + 1).filter((l) => l.trim()).length;
249
+ const DONE_MAX = 60;
250
+ if (doneLines > DONE_MAX) {
251
+ issues.push(`DONE-PILED-UP: Done 区 ${doneLines} 行 > ${DONE_MAX}; 跑 \`abs todo archive\` 迁出旧日期组`);
252
+ }
253
+ const doneBody = todoTxt.split('\n').slice(di + 1);
254
+ const noKind = doneBody.filter((l) => /^\s*- \[x\]/.test(l) && !doneKindOf(l));
255
+ if (noKind.length) {
256
+ const sample = (noKind[0].match(/- \[x\] (\S+)/) || [, '?'])[1];
257
+ issues.push(
258
+ `DONE-NO-KIND: Done 区 ${noKind.length} 条缺结语(如 ${sample})。` +
259
+ `逐条补 \`--as 落地|否决|仅方案\`(新条目:abs todo done <id> --as …)`,
260
+ );
261
+ }
262
+ const noDate = doneBody.filter((l) => /^\s*- \[x\]/.test(l) && !doneDateOf(l));
263
+ if (noDate.length) {
264
+ const sample = (noDate[0].match(/- \[x\] (\S+)/) || [, '?'])[1];
265
+ issues.push(
266
+ `DONE-NO-DATE: Done 区 ${noDate.length} 条缺 \`(完成 YYYY-MM-DD)\`(如 ${sample})。` +
267
+ `手写的 [x] 不会自动盖日期 —— 补上后重跑;新条目一律走 \`abs todo done <id>\`。`,
268
+ );
269
+ }
270
+ }
271
+
272
+ // Rules 区检查
273
+ {
274
+ const idxTxt = await readFileOrNull(join(vault, 'index.md'));
275
+ const { items, found } = readRules(idxTxt);
276
+ if (found && items.length > LINT_RULES_MAX) {
277
+ issues.push(`RULES-PILED-UP: Rules 区 ${items.length} 条 > ${LINT_RULES_MAX};把长条目提炼成概念页,这里只留一句话`);
278
+ }
279
+ const longOnes = items.filter((l) => l.trim().length > 160);
280
+ if (longOnes.length) {
281
+ issues.push(`RULES-TOO-LONG: Rules 区 ${longOnes.length} 条超 160 字符(如 "${clip(longOnes[0].trim(), 40)}");展开写进概念页,这里只留一句话(不带链接)`);
282
+ }
283
+ }
284
+ return issues;
285
+ }
286
+
287
+ async function readFileOrNull(p) {
288
+ try { return await fs.readFile(p, 'utf8'); } catch { return null; }
289
+ }
290
+
291
+ // ---------- 入口 ----------
292
+
293
+ export async function cmdLint({ dir }) {
294
+ let root;
295
+ try {
296
+ root = await requireBrain(dir || process.cwd());
297
+ } catch {
298
+ return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
299
+ }
300
+ const vault = brainPath(root);
301
+ const pages = await listPages(vault);
302
+ const names = new Set(pages.map((p) => p.slug));
303
+ for (const f of await fs.readdir(vault).catch(() => [])) {
304
+ if (f.endsWith('.md')) names.add(f.replace(/\.md$/, ''));
305
+ }
306
+ const linkedNames = new Set(pages.flatMap((p) => p.links));
307
+ const inbound = new Map();
308
+ for (const p of pages) for (const ln of p.links) inbound.set(ln, (inbound.get(ln) || 0) + 1);
309
+ let indexLinks = [];
310
+ try {
311
+ const idx = await fs.readFile(join(vault, 'index.md'), 'utf8');
312
+ indexLinks = [...idx.matchAll(/\[\[([^\]|#]+)/g)].map((m) => m[1].trim());
313
+ } catch { /* 无 index 则不查 */ }
314
+
315
+ const issues = [];
316
+ for (const pg of pages) issues.push(...checkPage(pg, names, linkedNames, inbound));
317
+ issues.push(...await checkGraph(root, pages));
318
+ issues.push(...await checkFiles(vault, pages, indexLinks));
319
+
320
+ const n = issues.length;
321
+ return [
322
+ ...(issues.length ? issues : []),
323
+ '',
324
+ `lint: ${n} problem(s).`,
325
+ n === 0 ? '✓ 图谱健康' : '',
326
+ ].filter(Boolean).join('\n');
327
+ }
package/src/query.js ADDED
@@ -0,0 +1,139 @@
1
+ // src/query.js — 检索知识图谱(多词 OR,扫全 .md 页)
2
+ import { promises as fs } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { requireBrain, brainPath } from './index.js';
5
+ import { rankPage } from './relevant.js';
6
+ import { statusOfPage, idOfPage, supersededByOf, clip } from './store.js';
7
+
8
+ // ---------- query: 检索知识图谱(多词 OR,扫全 .md 页) ----------
9
+ const KNOWN_SLUG_HINT = /模板残留|\[\[slug\]\]/;
10
+
11
+ export async function cmdQuery({ dir, terms, includeSuperseded }) {
12
+ // 每个 term 内部再按空白拆 —— 让 `abs query "发布 流程"` 与 `abs query 发布 流程` 等价。
13
+ // 坑: 曾经引号包起来的 "发布 流程" 被当成一个完整短语 → 全图无命中。
14
+ // 用户看到「无命中」会以为图谱里没这条经验,实际只是词没被拆开(静默失效)。
15
+ const words = [...new Set(
16
+ (terms || []).flatMap((w) => String(w).trim().split(/\s+/)).filter(Boolean)
17
+ )];
18
+ if (!words.length) {
19
+ return '用法: abs query <词1> [词2 …] — 多词检索 .brain/ 全部知识页';
20
+ }
21
+ let root;
22
+ try {
23
+ root = await requireBrain(dir || process.cwd());
24
+ } catch {
25
+ return `未找到 .brain/ 图谱(无记忆可查)。先在项目根运行: abs init`;
26
+ }
27
+ const hits = [];
28
+ const dirs = ['concepts', 'entities', 'sources', 'syntheses', 'sessions'];
29
+ for (const d of dirs) {
30
+ const p = brainPath(root, d);
31
+ let files;
32
+ try {
33
+ files = await fs.readdir(p);
34
+ } catch {
35
+ continue;
36
+ }
37
+ for (const f of files) {
38
+ if (!f.endsWith('.md') || f.startsWith('_')) continue;
39
+ const full = join(p, f);
40
+ const body = await fs.readFile(full, 'utf8').catch(() => '');
41
+ const slug = f.replace(/\.md$/, '');
42
+ const rank = rankPage(body, slug, words);
43
+ if (rank) {
44
+ // 作者从本页 frontmatter 读(权威来源)。不在 index 行里重复 ——
45
+ // index 行是覆盖式更新的,作者会从"创建者"漂成"最后改的人"。
46
+ const au = body.match(/^author:\s*(.+)$/m);
47
+ const st = statusOfPage(body);
48
+ // 被推翻的经验默认不出现在检索结果里 —— 它的存在意义是"别再用它",
49
+ // 而不是回答"我上次怎么解決 X"(那会拿到一个已知错误的答案)。
50
+ // 但它不是静默消失:计数里告知还有几条被隐藏(带 --all 能看)。
51
+ if (st === 'superseded' && !includeSuperseded) {
52
+ hits.push({ hidden: true, slug });
53
+ continue;
54
+ }
55
+ hits.push({
56
+ full, slug, matched: rank.matched, kind: rank.kind, fuzzyScore: rank.score, via: rank.via,
57
+ author: au ? au[1].trim() : '',
58
+ id: idOfPage(body, slug),
59
+ status: st,
60
+ supersededBy: supersededByOf(body),
61
+ snippet: firstHitLine(body, rank.matched.length ? rank.matched : words),
62
+ });
63
+ }
64
+ }
65
+ }
66
+ const hidden = hits.filter((h) => h.hidden).length;
67
+ // 分两区:精确命中优先,模糊只作补充。
68
+ // 坑(2026-09-16 实测): 不加区分时查 file-write-locking 返回 26 页(几乎全图)——
69
+ // 连字符词的 2-gram 太通用,模糊命中把无关页也拉进来。
70
+ // 规则:只要有任何精确命中,模糊命中的就不排在前面(但也不丢弃,单列一段)。
71
+ const exactHits = hits.filter((h) => !h.hidden && h.kind === 'exact')
72
+ .map((h) => ({ ...h, score: h.fuzzyScore }))
73
+ .sort((a, b) => b.score - a.score);
74
+ const fuzzyHits = hits.filter((h) => !h.hidden && h.kind === 'fuzzy')
75
+ .map((h) => ({ ...h, score: h.fuzzyScore }))
76
+ .sort((a, b) => b.score - a.score);
77
+ // 有精确命中 → 模糊全藏起来(但不静默:告知数量 + 怎么看)
78
+ const fuzzySuppressed = exactHits.length > 0 && fuzzyHits.length > 0;
79
+ const shown = exactHits.length ? exactHits : fuzzyHits;
80
+ if (!shown.length) {
81
+ const extra = hidden ? `(另外 ${hidden} 页已标记 superseded,用 abs query ${words.join(' ')} --all 查看)` : '';
82
+ return `query [${words.join(', ')}]: 无命中。${extra}用 abs lint 看图谱健康;首次使用先 abs init。`;
83
+ }
84
+ const lines = shown.map((h) => {
85
+ const by = h.author ? ` @${h.author}` : '';
86
+ // id 只在≠slug 时显示 —— 相同时再印一遗就是纯噪音(绝大多数页)。
87
+ // 目的:让 AI 拿到一个改名也不漂的引用句柄(abs resolve <id> 能反查回来)。
88
+ const id = h.id && h.id !== h.slug ? ` [id: ${h.id}]` : '';
89
+ // draft = 未经核实。不拦使用,但必须让 AI 知道这是它自己没验证过的。
90
+ const st = h.status === 'draft' ? ' [draft 未核实]' : '';
91
+ const full = h.kind === 'exact' && words.length > 1 && h.matched.length === words.length ? ' ★全命中' : '';
92
+ // 模糊命中必须显式标出 —— 否则用户会把"语义相近"当成"真的是这条"。
93
+ const fuzzy = h.kind === 'fuzzy' ? ' [模糊命中: 字面未出现,仅字形相近]' : '';
94
+ const hitTxt = h.matched.length ? h.matched.join(', ') : '(无字面命中)';
95
+ // 命中渠道:tag 最有价值(人工提炼的关键词),显式标出便于判断可信度
96
+ const viaTag = h.via && h.via.tag.length ? ` [tag: ${h.via.tag.join(', ')}]` : '';
97
+ return `📄 ${h.slug}${by}${id}${st}${fuzzy} (命中: ${hitTxt}${full})${viaTag}\n ${h.snippet}`;
98
+ });
99
+ // 多词且无全命中时告知降级了 —— 不静默给一堆弱相关结果。
100
+ const anyFull = shown.some((h) => h.kind === 'exact' && h.matched.length === words.length);
101
+ const tail = [];
102
+ if (words.length > 1 && !anyFull) tail.push('', `(无页同时命中全部 ${words.length} 个词,以下按命中数排序)`);
103
+ if (fuzzySuppressed) tail.push('', `(另有 ${fuzzyHits.length} 页字形相近但字面未命中,已隐藏 —— 它们通常不相关)`);
104
+ if (hidden) tail.push(``, `(${hidden} 页 superseded 已隐藏;--all 可看)`);
105
+ return [`query [${words.join(', ')}] → ${shown.length} 页:`, '', ...lines, ...tail].join('\n');
106
+ }
107
+
108
+ /** 从页面正文取一段「像答案」的片段。
109
+ * 基线(2026-09-15 实测 296 条片段): 25% 是非内容行(tags:/H1/段落标题)。
110
+ * 典型症状: 查「发布」时 npm-publish-flow 返回 `tags: [concept, npm, publish, 发布]`
111
+ * —— frontmatter 在第 3 行,跑在正文前,于是「含关键词的第一行」永远先命中它。
112
+ * 所以必须:(1) 跳过 frontmatter/标题这类非内容行;(2) 优先从「答案段」里找。
113
+ * 只做机械判断:行首标记 + 所属小节标题,不猜语义。 */
114
+ const ANSWER_SECTION_RE = /解法|根因|修复|验证|流程|标准|判据|处置|怎么办|🛠/;
115
+
116
+ function firstHitLine(body, words) {
117
+ const lines = body.split('\n');
118
+ // 逐行扫描,记录当前所属小节标题,供「答案段优先」用。
119
+ let section = '';
120
+ const candidates = []; // {line, inAnswer}
121
+ for (const line of lines) {
122
+ const t = line.trim();
123
+ if (t.startsWith('#')) {
124
+ section = t.replace(/^#+\s*/, '');
125
+ continue; // 标题本身不是内容
126
+ }
127
+ if (!t || t === '---') continue;
128
+ const l = t.toLowerCase();
129
+ if (!words.some((w) => l.includes(w.toLowerCase()))) continue;
130
+ if (KNOWN_SLUG_HINT.test(t)) continue;
131
+ // 非内容行:frontmatter 的键值对(tags:/id:/status:/updated:/author:/superseded-by:)
132
+ if (/^(tags|id|status|updated|author|superseded-by|superseded|aliases)\s*:/.test(t)) continue;
133
+ candidates.push({ line: t, inAnswer: ANSWER_SECTION_RE.test(section) });
134
+ }
135
+ if (!candidates.length) return '';
136
+ // 答案段里的行优先;否则回退到第一条命中的正文行
137
+ const best = candidates.find((c) => c.inAnswer) || candidates[0];
138
+ return clip(best.line, 160);
139
+ }