@fanchao8609/agent_brain_sync 1.22.0 → 1.24.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 +2 -2
- package/bin/abs.js +27 -5
- package/bin/mcp.js +7 -1
- package/hooks/abs.pi.ts +4 -0
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +30 -1
- package/src/brain/capture.js +13 -2
- package/src/brain/db-cmd.js +44 -3
- package/src/brain/db.js +77 -26
- package/src/brain/evolve.js +22 -10
- package/src/brain/layers.js +16 -1
- package/src/brain/load.js +197 -14
- package/src/brain/maintain.js +29 -20
- package/src/query.js +10 -3
- package/src/store.js +1 -1
package/README.md
CHANGED
|
@@ -120,9 +120,9 @@ abs todo done TASK-1
|
|
|
120
120
|
abs note "一句话经验" --tags 坑,docker # 经验暂存 → sources/
|
|
121
121
|
abs log "完成 X" # 记一行流水;abs log 无参 = 查看
|
|
122
122
|
abs query <词> # 检索图谱(多词 OR);已被推翻的经验默认隐藏(--all 可看)
|
|
123
|
-
abs capture [--layer 1-7] "一句话"
|
|
123
|
+
abs capture [--layer 1-7] [--kind …] "一句话" # 按七层判层后落到位(--kind: gotcha|decision|tradeoff|howto)
|
|
124
124
|
abs db rebuild # 扫所有登记项目 → 建图数据库(~/.abs/graph.db)
|
|
125
|
-
abs db search <词>
|
|
125
|
+
abs db search <词> [--full] # 走库检索:默认只给摘要+token 成本,--full 才附正文
|
|
126
126
|
abs db status # 库体检:七层分布 / 悬挂链接 / 已入库项目
|
|
127
127
|
abs db add # 把当前项目登记进 ~/.abs/projects.json
|
|
128
128
|
abs db evolve # 知识进化体检:被推翻/断链/主题堆积/僵化页
|
package/bin/abs.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// bin/abs.js — abs CLI 入口。
|
|
3
3
|
// abs <cmd> [args]
|
|
4
4
|
// 命令: init / board / status / load / task / install / uninstall / help
|
|
5
|
-
import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede, cmdReview } from '../src/store.js';
|
|
5
|
+
import { cmdInit, cmdStatus, cmdLoad, cmdRecall, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede, cmdReview } from '../src/store.js';
|
|
6
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';
|
|
@@ -135,6 +135,8 @@ const FLAG_SPEC = {
|
|
|
135
135
|
'by': { type: 'string' },
|
|
136
136
|
'all': { type: 'boolean' },
|
|
137
137
|
'keep-days': { type: 'string' },
|
|
138
|
+
// db evolve 用:多少天没动且无人链算 stale。未声明会被静默吞成 true(同 keep-days 坑)。
|
|
139
|
+
'stale-days': { type: 'string' },
|
|
138
140
|
'help': { type: 'boolean' },
|
|
139
141
|
'dry-run': { type: 'boolean' },
|
|
140
142
|
'full': { type: 'boolean' },
|
|
@@ -145,7 +147,7 @@ const FLAG_SPEC = {
|
|
|
145
147
|
};
|
|
146
148
|
|
|
147
149
|
/** 已被"规范键"接管的 raw flag:不再原样漏出(见 parseArgv 返回处的白名单注释)。 */
|
|
148
|
-
const KNOWN_RAW = new Set(['keep-days', 'dry-run', 'no-mcp', 'no-skill']);
|
|
150
|
+
const KNOWN_RAW = new Set(['keep-days', 'stale-days', 'dry-run', 'no-mcp', 'no-skill']);
|
|
149
151
|
|
|
150
152
|
function parseArgv(args) {
|
|
151
153
|
// 坑: parseArgs 会把**任何** `-` 开头的 token 当选项,连正文一起吃:
|
|
@@ -210,6 +212,7 @@ function parseArgv(args) {
|
|
|
210
212
|
tags: values.tags,
|
|
211
213
|
limit: values.limit === undefined ? undefined : Number(values.limit),
|
|
212
214
|
layer: values.layer,
|
|
215
|
+
kind: values.kind,
|
|
213
216
|
mark: !!values.mark,
|
|
214
217
|
status: !!values.status,
|
|
215
218
|
title: values.title,
|
|
@@ -219,6 +222,7 @@ function parseArgv(args) {
|
|
|
219
222
|
repair: !!values.repair,
|
|
220
223
|
full: !!values.full,
|
|
221
224
|
keepDays: values['keep-days'],
|
|
225
|
+
staleDays: values['stale-days'],
|
|
222
226
|
dryRun: !!values['dry-run'],
|
|
223
227
|
mcp: !values['no-mcp'],
|
|
224
228
|
skill: !values['no-skill'],
|
|
@@ -238,6 +242,7 @@ const usage = `abs — 跨会话记忆工具(.brain/ 图谱)
|
|
|
238
242
|
abs todo add X --note "做什么" 登记任务 (done/state/note 管后续)
|
|
239
243
|
abs note "经验一句话" 随手记经验; abs log "完成 X" 记成果
|
|
240
244
|
abs query <词…> 检索 .brain/; abs todo 看任务板
|
|
245
|
+
abs recall [词…] 卡住时主动取相关页(不给词就按当前处境推)
|
|
241
246
|
|
|
242
247
|
全部命令 (细节: abs <命令> --help):
|
|
243
248
|
init [--repair] 建图谱 (当前目录)
|
|
@@ -362,6 +367,18 @@ const subUsage = {
|
|
|
362
367
|
'参数:',
|
|
363
368
|
' --all 连 superseded(已失效)页一起返回',
|
|
364
369
|
].join('\n'),
|
|
370
|
+
recall: [
|
|
371
|
+
'abs recall — 卡住时主动取相关页(不需先想到检索词)',
|
|
372
|
+
'',
|
|
373
|
+
'用法:',
|
|
374
|
+
' abs recall [词1 词2 …] [--limit N]',
|
|
375
|
+
'',
|
|
376
|
+
'不给词时,从当前处境推:活跃 todo 的关键词 + 最近改过的文件名。',
|
|
377
|
+
'给了词就只用你给的词,不混处境词。',
|
|
378
|
+
'',
|
|
379
|
+
'与 load 的区别: load 只在开工跑一次;recall 随时可跑(卡住/换方向时)。',
|
|
380
|
+
'与 query 的区别: query 枚全图命中;recall 只给按亮度亮的几页。',
|
|
381
|
+
].join('\n'),
|
|
365
382
|
config: [
|
|
366
383
|
'abs config — 使用者姓名(写操作的作者标记)',
|
|
367
384
|
'',
|
|
@@ -456,6 +473,11 @@ async function main() {
|
|
|
456
473
|
rejectExtra(opts._, 'abs load');
|
|
457
474
|
console.log(await cmdLoad({ dir: opts.dir }));
|
|
458
475
|
break;
|
|
476
|
+
case 'recall': {
|
|
477
|
+
const limit = Number(opts.limit) > 0 ? Number(opts.limit) : 5;
|
|
478
|
+
console.log(await cmdRecall({ dir: opts.dir, terms: opts._, limit }));
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
459
481
|
case 'index':
|
|
460
482
|
rejectExtra(opts._, 'abs index');
|
|
461
483
|
console.log(await cmdShow({ dir: opts.dir, view: 'index' }));
|
|
@@ -534,7 +556,7 @@ async function main() {
|
|
|
534
556
|
break;
|
|
535
557
|
}
|
|
536
558
|
case 'capture': {
|
|
537
|
-
console.log(await cmdCapture({ dir: opts.dir, text: opts._.join(' '), layer: opts.layer, tags: opts.tags, title: opts.title }));
|
|
559
|
+
console.log(await cmdCapture({ dir: opts.dir, text: opts._.join(' '), layer: opts.layer, tags: opts.tags, title: opts.title, kind: opts.kind }));
|
|
538
560
|
break;
|
|
539
561
|
}
|
|
540
562
|
case 'note': {
|
|
@@ -579,9 +601,9 @@ async function main() {
|
|
|
579
601
|
const sub = opts._[0];
|
|
580
602
|
const rest = opts._.slice(1);
|
|
581
603
|
if (sub === 'rebuild') { console.log(await cmdDbRebuild({ dir: opts.dir })); break; }
|
|
582
|
-
if (sub === 'search') { console.log(await cmdDbSearch({ query: rest.join(' '), limit: Number(opts.limit) || 8 })); break; }
|
|
604
|
+
if (sub === 'search') { console.log(await cmdDbSearch({ query: rest.join(' '), limit: Number(opts.limit) || 8, full: !!opts.full })); break; }
|
|
583
605
|
if (sub === 'status') { console.log(await cmdDbStatus()); break; }
|
|
584
|
-
if (sub === 'evolve') { console.log(await cmdDbEvolve({ staleDays: Number(opts
|
|
606
|
+
if (sub === 'evolve') { console.log(await cmdDbEvolve({ staleDays: Number(opts.staleDays) || 90 })); break; }
|
|
585
607
|
if (sub === 'add') {
|
|
586
608
|
const r = await addProject(opts.dir || process.cwd());
|
|
587
609
|
console.log(`✓ 已登记项目: ${r}\n 建库: abs db rebuild`);
|
package/bin/mcp.js
CHANGED
|
@@ -11,7 +11,7 @@ import { appendFile, mkdir } from 'node:fs/promises';
|
|
|
11
11
|
import { readFileSync } from 'node:fs';
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
|
-
import { findBrainRoot, absLogDir } from '../src/index.js';
|
|
14
|
+
import { findBrainRoot, absLogDir, AbsError } from '../src/index.js';
|
|
15
15
|
import { cmdBoard, cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdWrapup, cmdRule, cmdResolve, cmdSupersede } from '../src/store.js';
|
|
16
16
|
|
|
17
17
|
// ---------- 技术日志: MCP 请求跟踪(调试用, 与图谱 log.md 完全分开) ----------
|
|
@@ -40,6 +40,12 @@ function withTrace(name, handler) {
|
|
|
40
40
|
return r;
|
|
41
41
|
} catch (e) {
|
|
42
42
|
await tlog(`tool=${name} cwd=${args?.cwd || '-'} args=${argBrief} → THROW ${Date.now() - t0}ms | ${String(e?.message || e).slice(0, 200)}`);
|
|
43
|
+
// 带 code 的 AbsError(如 requireUser 的 NO_USER)必须走 err() 结构化返回,
|
|
44
|
+
// 保留 code + fallback 自救指引 —— 裸 rethrow 只带 message,LLM 拿到后
|
|
45
|
+
// 不知道下一步跑什么(与 CLI 侧 `[CODE] + → fallback` 的既有行为不一致)。
|
|
46
|
+
if (e instanceof AbsError) {
|
|
47
|
+
return err(e.message, { code: e.code, fallback: e.fallback || undefined });
|
|
48
|
+
}
|
|
43
49
|
throw e;
|
|
44
50
|
}
|
|
45
51
|
};
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -231,6 +231,10 @@ const TODO_GUIDELINES = [
|
|
|
231
231
|
// ⑦ 文档精简九条(用户 2026-10-22 定,原文见 skill 的「文档精简」一节):
|
|
232
232
|
// 管每一行写进 .brain 的文字;与上面六条行为规则不重叠,故不合并。
|
|
233
233
|
'Every line you write into .brain/ (todo rows, log rows, knowledge pages, session snapshots) must pass the nine-bullet doc-slimming rule in the abs skill: only what serves the topic (drop the rest), no unverified summaries, ordered and layered, plain declarative wording (no rhetorical questions, no guessing), no duplication (cite the other doc instead), terse prohibitions, nothing the user did not ask for, plain Chinese.',
|
|
234
|
+
// ⑧ 有疑惑先查记忆(用户 2026-11-03 定):主动搜索不是等报错才应激,
|
|
235
|
+
// 而是「心里形成关键词/有疑问的那一刻」自己查。上面七条只讲了登记,
|
|
236
|
+
// 没讲「记下来的东西怎么取」——实测一场会话从头到尾没查过一次记忆。
|
|
237
|
+
'When a doubt forms about something the project has likely dealt with before (a design choice, a past bug, a convention), run `abs recall <关键词>` (or `abs query <词>` for wider matches) BEFORE acting on the assumption. Do not wait for an error or for the user to remind you — asking the memory is your own decision, made the moment the question arises.',
|
|
234
238
|
]
|
|
235
239
|
|
|
236
240
|
/** 当前项目是否有 .brain 图谱 —— 有才加指引(没图谱的项目里这指引是噪音)。
|
package/package.json
CHANGED
|
@@ -5,6 +5,23 @@ description: "USE FOR abs / agent-brain-sync 跨会话记忆与任务续接。
|
|
|
5
5
|
|
|
6
6
|
# abs — 跨会话记忆 (agent-brain-sync)
|
|
7
7
|
|
|
8
|
+
## 你有知识库,随时都能查
|
|
9
|
+
|
|
10
|
+
`.brain/` 是**你自己的跨会话记忆库**:踩过的坑、做过的决定、验证过的做法都在里面。
|
|
11
|
+
|
|
12
|
+
**查它是你的决定,不是别人提醒你才做。** 干活中一旦心里形成关键词、对某件事有疑问(这个坑以前踩过吗?这个约定是谁定的?),那一刻就主动查,**不要等报错、不要等用户提醒、不要等开工那一秒**。
|
|
13
|
+
|
|
14
|
+
怎么查:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
abs recall <关键词> # 有疑问时用:按亮度出最相关的几页(不给词就从当前处境推)
|
|
18
|
+
ab query <词> # 想拿更全:多词 OR,枚举全图命中
|
|
19
|
+
db search <词> # 更高级:种子扩散亮起相关页,先出铁律
|
|
20
|
+
abs resolve <页名> # 拿到页名后反查路径,只读那一页
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
查完只读命中那几页,**绝不通读 `.brain/`**。答用 `[[页面名]]` 标来源。
|
|
24
|
+
|
|
8
25
|
## 触发器
|
|
9
26
|
|
|
10
27
|
**关键词不是触发器,意图才是。**句子里出现 todo / 沉淀 / load / note 这些词,若是谈论、提问、定规则,只回应不执行。
|
|
@@ -19,7 +36,7 @@ description: "USE FOR abs / agent-brain-sync 跨会话记忆与任务续接。
|
|
|
19
36
|
abs load # 开机读状态
|
|
20
37
|
abs query <词> # 检索;abs resolve <页名> 反查路径
|
|
21
38
|
abs todo add <id> --note "做什么" # 登记;note/memo/state/done 见「进行中」
|
|
22
|
-
abs capture [--layer 1-7] "…"
|
|
39
|
+
abs capture [--layer 1-7] [--kind …] "…" # 按七层判定落点后写入(--kind 分作用)
|
|
23
40
|
abs note "经验" [--tags a,b] # 暂存经验(一律 L7 混沌层)
|
|
24
41
|
abs concept <slug> --title "…" # 建概念页骨架
|
|
25
42
|
abs rule add "一句话" # 加 Rules;abs lint 体检;abs init 建图谱
|
|
@@ -110,6 +127,18 @@ abs rule add "一句话" # 加 Rules;abs lint 体检;abs init
|
|
|
110
127
|
|
|
111
128
|
判据不明时用 L4 —— 但**知道是工具经验就写 L3**:实测算过,本仓 52 页 concepts 里 23 页其实是 L3。
|
|
112
129
|
|
|
130
|
+
**`--kind` 是另一条正交轴**(借 claude-mem 的 observation 类型):`layer` 说「什么性质」,`kind` 说「起什么作用」。
|
|
131
|
+
|
|
132
|
+
| kind | 含义 | 何时用 |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| `gotcha` | 关键坑 | 踩了会白干、想不到的那种 |
|
|
135
|
+
| `decision` | 决策 | 记「当时为什么这么选」 |
|
|
136
|
+
| `tradeoff` | 权衡 | A 好但 B 也有理,最后选了哪个 |
|
|
137
|
+
| `howto` | 做法 | 这类事该怎么做 |
|
|
138
|
+
|
|
139
|
+
不传就不写(旧页留空,不强制补)。实测只有 `gotcha` 能靠 tags/标题机械识别,
|
|
140
|
+
另三类判据在内容里 —— 别拿关键词瞎猜。
|
|
141
|
+
|
|
113
142
|
`abs capture` 之后跑 `abs db rebuild` 让图数据库收录,否则检索拿不到它。
|
|
114
143
|
|
|
115
144
|
## 进行中:什么时候动它
|
package/src/brain/capture.js
CHANGED
|
@@ -52,13 +52,13 @@ export function layerGuide() {
|
|
|
52
52
|
].join('\n');
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export async function cmdCapture({ dir, text, layer, tags, title }) {
|
|
55
|
+
export async function cmdCapture({ dir, text, layer, tags, title, kind }) {
|
|
56
56
|
const clean = String(text || '').trim();
|
|
57
57
|
const layerArg = String(layer || '').trim();
|
|
58
58
|
|
|
59
59
|
if (!clean) {
|
|
60
60
|
return [
|
|
61
|
-
'用法: abs capture [--layer 1-7] "一句话" [--tags a,b] [--title "页标题"]',
|
|
61
|
+
'用法: abs capture [--layer 1-7] [--kind gotcha|decision|tradeoff|howto] "一句话" [--tags a,b] [--title "页标题"]',
|
|
62
62
|
'',
|
|
63
63
|
layerGuide(),
|
|
64
64
|
].join('\n');
|
|
@@ -86,6 +86,16 @@ export async function cmdCapture({ dir, text, layer, tags, title }) {
|
|
|
86
86
|
return r;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// 作用分档(2026-11-02 借 cloude-mem):gotcha 坑 / decision 决策 / tradeoff 权衡 / howto 做法。
|
|
90
|
+
// 与 layer 正交:layer 说「这是什么性质的知识」,kind 说「它起什么作用」。
|
|
91
|
+
const KIND = {
|
|
92
|
+
gotcha: '坑', decision: '决策', tradeoff: '权衡', howto: '做法',
|
|
93
|
+
};
|
|
94
|
+
const kindArg = String(kind || '').trim().toLowerCase();
|
|
95
|
+
if (kindArg && !KIND[kindArg]) {
|
|
96
|
+
return `✗ --kind 只认 ${Object.keys(KIND).join(' / ')}(收到 "${kind}")`;
|
|
97
|
+
}
|
|
98
|
+
|
|
89
99
|
let root;
|
|
90
100
|
try {
|
|
91
101
|
root = await requireBrain(dir || process.cwd());
|
|
@@ -121,6 +131,7 @@ export async function cmdCapture({ dir, text, layer, tags, title }) {
|
|
|
121
131
|
'---',
|
|
122
132
|
`tags: [${['concept', ...tagList].join(', ')}]`,
|
|
123
133
|
`layer: ${L}`,
|
|
134
|
+
...(kindArg ? [`kind: ${kindArg}`] : []),
|
|
124
135
|
`author: ${who}`,
|
|
125
136
|
`updated: ${today()}`,
|
|
126
137
|
'status: draft',
|
package/src/brain/db-cmd.js
CHANGED
|
@@ -54,7 +54,21 @@ export function uniqueProjectNames(roots) {
|
|
|
54
54
|
for (const [r, v] of cand) out.set(r, v);
|
|
55
55
|
break;
|
|
56
56
|
}
|
|
57
|
-
if (depth === 4)
|
|
57
|
+
if (depth === 4) {
|
|
58
|
+
// 深度到 4 仍重名(如 ~/a/x/软件/.agents 与 ~/b/y/软件/.agents 共享 4 层尾路径):
|
|
59
|
+
// 不能「认了」直接塞 —— 那会让两个项目拿到同一个项目名,rebuild 撞
|
|
60
|
+
// UNIQUE(project,dir,slug) 约束整体崩溃(正是本函数要避免的场景)。
|
|
61
|
+
// 给仍碰撞的条目追加序号(-2/-3…),保证互不重名且可读。
|
|
62
|
+
const byVal = new Map();
|
|
63
|
+
for (const [r, v] of cand) {
|
|
64
|
+
if (!byVal.has(v)) { byVal.set(v, []); }
|
|
65
|
+
byVal.get(v).push(r);
|
|
66
|
+
}
|
|
67
|
+
for (const [v, rs] of byVal) {
|
|
68
|
+
if (rs.length === 1) { out.set(rs[0], v); continue; }
|
|
69
|
+
rs.forEach((r, i) => out.set(r, `${v}-${i + 2}`));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
58
72
|
}
|
|
59
73
|
}
|
|
60
74
|
return out;
|
|
@@ -176,7 +190,7 @@ export async function cmdDbRebuild({ dir } = {}) {
|
|
|
176
190
|
}
|
|
177
191
|
|
|
178
192
|
/** `abs db search <词>` —— 走库搜索:FTS 种子 → 亮度扩散。 */
|
|
179
|
-
export async function cmdDbSearch({ query, limit = 8 } = {}) {
|
|
193
|
+
export async function cmdDbSearch({ query, limit = 8, full = false } = {}) {
|
|
180
194
|
const q = String(query || '').trim();
|
|
181
195
|
if (!q) return '用法: abs db search <词>';
|
|
182
196
|
|
|
@@ -204,6 +218,20 @@ export async function cmdDbSearch({ query, limit = 8 } = {}) {
|
|
|
204
218
|
: '(无命中。库可能是旧的:abs db rebuild 重扫)');
|
|
205
219
|
return out.join('\n');
|
|
206
220
|
}
|
|
221
|
+
// --full:显式要求时才读正文。默认不给 —— 这是索引优先的核心。
|
|
222
|
+
// 实测(2026-11-02):ABS 单页平均 824 token,8 页全带就是 6600 token,
|
|
223
|
+
// 而真正被用上的往往只有 1-2 页。让「看全文」成为一个显式动作。
|
|
224
|
+
const bodies = new Map();
|
|
225
|
+
if (full) {
|
|
226
|
+
const root = await findBrainRoot(process.cwd());
|
|
227
|
+
if (root) {
|
|
228
|
+
for (const p of lit) {
|
|
229
|
+
const body = await fs.readFile(brainPath(root, p.dir, `${p.slug}.md`), 'utf8').catch(() => '');
|
|
230
|
+
if (body) bodies.set(p.id, body);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
207
235
|
out.push('', '--- 按亮度排序 ---');
|
|
208
236
|
for (const p of lit) {
|
|
209
237
|
// 亮度现在是 0.3~1.0(不再是早期可能到 165 万的异常值),
|
|
@@ -214,9 +242,22 @@ export async function cmdDbSearch({ query, limit = 8 } = {}) {
|
|
|
214
242
|
// 跨项目的结果要显式标出来 —— 用户得知道「这条是别的项目的经验」,
|
|
215
243
|
// 否则会以为是自己项目的结论(判据、上下文都可能不同)。
|
|
216
244
|
const other = project && p.project && p.project !== project ? ' [跨项目]' : '';
|
|
217
|
-
|
|
245
|
+
// 索引优先:给摘要 + 取全文成本,让「要不要看全文」成为一个可算的决定。
|
|
246
|
+
const about = p.digest ? `\n ${p.digest}` : '';
|
|
247
|
+
const cost = p.tokens ? ` ~${p.tokens}tok` : '';
|
|
248
|
+
// 作用分档(gotcha/decision/tradeoff/howto)—— 同层里「哪种更该先看」的线索
|
|
249
|
+
const KIND_ICON = { gotcha: '🔴坑', decision: '🟤决策', tradeoff: '⚖️权衡', howto: '🔵做法' };
|
|
250
|
+
const kind = p.kind && KIND_ICON[p.kind] ? ` ${KIND_ICON[p.kind]}` : '';
|
|
251
|
+
out.push(`${bar} ${p.bright.toFixed(2)} L${p.layer} ${where}${tag}${other}${kind}${cost}${about}`);
|
|
252
|
+
if (full && bodies.has(p.id)) {
|
|
253
|
+
// 正文缩进显示,便于与索引行区分
|
|
254
|
+
const body = bodies.get(p.id).replace(/^---[\s\S]*?---\n/, ''); // 去掉 frontmatter
|
|
255
|
+
out.push(...body.split('\n').slice(0, 60).map((l) => ` │ ${l}`));
|
|
256
|
+
if (body.split('\n').length > 60) out.push(' │ …(已截断,用 abs resolve 看全文)');
|
|
257
|
+
}
|
|
218
258
|
}
|
|
219
259
|
out.push('', '(L1 铁律恒在最前;↝ 表示由邻居扩散点亮,非直接命中)');
|
|
260
|
+
out.push('(只给摘要与成本;要看全文: abs db search <词> --full 或 abs resolve <页名>)');
|
|
220
261
|
if (project) {
|
|
221
262
|
out.push(`(铁律只显示本项目 "${project}" 的 —— 别的项目的铁律不外借)`);
|
|
222
263
|
}
|
package/src/brain/db.js
CHANGED
|
@@ -28,7 +28,7 @@ import { unlinkSync } from 'node:fs';
|
|
|
28
28
|
import { homedir } from 'node:os';
|
|
29
29
|
import { resolveLayer, anchorsOf, decayOf, DEFAULT_LAYER } from './layers.js';
|
|
30
30
|
|
|
31
|
-
export const DB_VERSION =
|
|
31
|
+
export const DB_VERSION = 4;
|
|
32
32
|
|
|
33
33
|
/** 库路径。ABS_DB 可覆盖(测试隔离 / 多库切换)。 */
|
|
34
34
|
export function dbPath() {
|
|
@@ -78,6 +78,15 @@ CREATE TABLE IF NOT EXISTS nodes (
|
|
|
78
78
|
-- 被谁推翻」。原来这关系只存在于 markdown 的一行 frontmatter 里,
|
|
79
79
|
-- 库只存 status 不存走向,于是「进化」在检索侧完全看不见。
|
|
80
80
|
superseded_by TEXT NOT NULL DEFAULT '',
|
|
81
|
+
-- 知识的「作用」分档(借 cloude-mem 的 observation 类型,2026-11-02):
|
|
82
|
+
-- gotcha 关键坑(怎么都想不到、踩了会白干)
|
|
83
|
+
-- decision 决策(当时为什么这么选)
|
|
84
|
+
-- tradeoff 权衡(A 好但 B 也有理,最后选了哪个)
|
|
85
|
+
-- howto 做法(这类事该怎么做)
|
|
86
|
+
-- 为何要它:七层按「性质」分(工具/认知/混沌),分不出「同一层里哪种更该先看」。
|
|
87
|
+
-- 实测 L4 有 382 页,其中「坑」106 页,而「决策」「权衡」根本没被记下来。
|
|
88
|
+
-- 留空 = 未分档(不强制补,旧页保持空)。
|
|
89
|
+
kind TEXT NOT NULL DEFAULT '',
|
|
81
90
|
UNIQUE(project, dir, slug)
|
|
82
91
|
);
|
|
83
92
|
CREATE INDEX IF NOT EXISTS i_nodes_layer ON nodes(layer);
|
|
@@ -238,6 +247,37 @@ export async function ensureDbDir(path = dbPath()) {
|
|
|
238
247
|
await fs.mkdir(dirname(path), { recursive: true });
|
|
239
248
|
}
|
|
240
249
|
|
|
250
|
+
/** 估算一段文字要占多少 token。中文按 1.5 字符/token、其余按 4 字符/token。
|
|
251
|
+
*
|
|
252
|
+
* 为何要估算而不是精确算(2026-11-02):精确分词要引依赖,而这里只需
|
|
253
|
+
* 「让用户知道取这一页大概多少钱」—— 数量级对就够了。
|
|
254
|
+
* 实测校准:本项目 40 页正文平均 824 token/页,与按段落读下来的体感一致。
|
|
255
|
+
*/
|
|
256
|
+
export function estTokens(s) {
|
|
257
|
+
const t = String(s || '');
|
|
258
|
+
const cjk = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
|
|
259
|
+
const rest = t.length - cjk;
|
|
260
|
+
return Math.round(cjk / 1.5 + rest / 4);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 从页正文取一句话摘要:优先 frontmatter 的 description,退化到首个非标题行。
|
|
264
|
+
* 与 relevant.js 的 digest 同思路,但这里在库侧用(不读文件)。 */
|
|
265
|
+
export function digest(body, maxLen = 90) {
|
|
266
|
+
const lines = String(body || '').split('\n');
|
|
267
|
+
const fmEnd = lines[0] === '---' ? lines.indexOf('---', 1) : -1;
|
|
268
|
+
if (fmEnd > 0) {
|
|
269
|
+
const d = lines.slice(1, fmEnd).find((l) => /^description\s*:/.test(l));
|
|
270
|
+
if (d) return d.replace(/^description\s*:\s*/, '').trim().slice(0, maxLen);
|
|
271
|
+
}
|
|
272
|
+
// 跳过 frontmatter 与标题,取第一句正文
|
|
273
|
+
const start = fmEnd > 0 ? fmEnd + 1 : 0;
|
|
274
|
+
const first = lines.slice(start).find((l) => {
|
|
275
|
+
const x = l.trim();
|
|
276
|
+
return x && !x.startsWith('#') && !x.startsWith('---') && !x.startsWith('>') && !x.startsWith('|');
|
|
277
|
+
});
|
|
278
|
+
return String(first || '').replace(/^[-*]\s*/, '').replace(/^TITLE\s*:\s*/, '').trim().slice(0, maxLen);
|
|
279
|
+
}
|
|
280
|
+
|
|
241
281
|
/** 从 markdown 正文抽 title / tags / status(与页面 frontmatter 同源)。 */
|
|
242
282
|
function metaOf(body) {
|
|
243
283
|
const t = String(body || '');
|
|
@@ -245,7 +285,8 @@ function metaOf(body) {
|
|
|
245
285
|
const tags = (t.match(/^tags:\s*(.+)$/m) || [])[1]?.replace(/^\[|\]$/g, '').trim() || '';
|
|
246
286
|
const st = (t.match(/^status:\s*(.+)$/m) || [])[1]?.trim() || 'active';
|
|
247
287
|
const sup = (t.match(/^superseded-by:\s*(.+)$/m) || [])[1]?.trim() || '';
|
|
248
|
-
|
|
288
|
+
const kind = (t.match(/^kind:\s*(.+)$/m) || [])[1]?.trim() || '';
|
|
289
|
+
return { title, tags, status: st, supersededBy: sup, kind };
|
|
249
290
|
}
|
|
250
291
|
|
|
251
292
|
/** 全量重建:清表 → 重扫所有注册项目 → 重建边与断点词。
|
|
@@ -260,8 +301,8 @@ export function rebuild(handle, pages) {
|
|
|
260
301
|
db.exec('DELETE FROM anchors; DELETE FROM edges; DELETE FROM nodes; DELETE FROM rules; DELETE FROM nodes_bigram;');
|
|
261
302
|
try { db.exec("INSERT INTO fts(fts) VALUES('delete-all')"); } catch { /* 空表时无可删 */ }
|
|
262
303
|
|
|
263
|
-
const insNode = db.prepare(`INSERT INTO nodes(project,dir,slug,layer,title,tags,status,body,mtime,is_ledger,superseded_by)
|
|
264
|
-
VALUES(
|
|
304
|
+
const insNode = db.prepare(`INSERT INTO nodes(project,dir,slug,layer,title,tags,status,body,mtime,is_ledger,superseded_by,kind)
|
|
305
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`);
|
|
265
306
|
const insGram = db.prepare('INSERT OR REPLACE INTO nodes_bigram(id,g) VALUES(?,?)');
|
|
266
307
|
const insEdge = db.prepare('INSERT OR IGNORE INTO edges(src,dst,raw) VALUES(?,?,?)');
|
|
267
308
|
const insAnchor = db.prepare('INSERT OR IGNORE INTO anchors(word,node) VALUES(?,?)');
|
|
@@ -270,11 +311,11 @@ export function rebuild(handle, pages) {
|
|
|
270
311
|
// 第一遍:建节点,拿 id 映射 key = `project/dir/slug`
|
|
271
312
|
const idOf = new Map();
|
|
272
313
|
for (const p of pages) {
|
|
273
|
-
const { title, tags, status, supersededBy } = metaOf(p.body);
|
|
314
|
+
const { title, tags, status, supersededBy, kind } = metaOf(p.body);
|
|
274
315
|
const { layer } = resolveLayer(p.body, p.dir);
|
|
275
316
|
const ledger = (p.dir === '' && LEDGER_SLUGS.has(p.slug)) ? 1 : 0;
|
|
276
317
|
const r = insNode.run(p.project, p.dir, p.slug, layer, title, tags, status,
|
|
277
|
-
p.body, p.mtime || now, ledger, supersededBy);
|
|
318
|
+
p.body, p.mtime || now, ledger, supersededBy, kind);
|
|
278
319
|
const id = Number(r.lastInsertRowid);
|
|
279
320
|
// FTS 源:正文 + 标题 + tags 切 2-gram。查询侧必须用同一个 bigrams()。
|
|
280
321
|
insGram.run(id, bigrams([p.slug, title, tags, p.body].join('\n')).join(' '));
|
|
@@ -423,7 +464,7 @@ export function search(handle, query, { limit = 20 } = {}) {
|
|
|
423
464
|
try {
|
|
424
465
|
// 取 g 列才能数命中数;多取一些候选,过滤后仍可能不够 limit
|
|
425
466
|
const rows = db.prepare(`
|
|
426
|
-
SELECT n.id, n.project, n.dir, n.slug, n.title, n.layer,
|
|
467
|
+
SELECT n.id, n.project, n.dir, n.slug, n.title, n.layer, n.kind, n.body,
|
|
427
468
|
bm25(fts) AS rank, b.g
|
|
428
469
|
FROM fts
|
|
429
470
|
JOIN nodes_bigram b ON b.id = fts.rowid
|
|
@@ -462,29 +503,33 @@ export function search(handle, query, { limit = 20 } = {}) {
|
|
|
462
503
|
// 档位由加权覆盖率决定(0.6~1.0):全命中最高,勉强沾边压到 0.6 档再往下。
|
|
463
504
|
// 档内名次由相对 bm25 决定(0.1~1.0):覆盖率相同但 bm25 不同的页仍可分。
|
|
464
505
|
// 下限 0.3 保留 —— 勉强命中的种子仍要能向外扩散,只是传得更弱。
|
|
465
|
-
return kept.slice(0, limit)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
506
|
+
return kept.slice(0, limit)
|
|
507
|
+
.map(({ g: _g, ...r }) => {
|
|
508
|
+
const rel = Math.min(1, Math.abs(r.rank) / best);
|
|
509
|
+
const band = 0.6 + 0.4 * r.cover;
|
|
510
|
+
// 亮度保持 0.3~1.0 的可读区间,**不乘层权重**。
|
|
511
|
+
// 坑(2026-11-02 实测):把层权重乘进亮度后,L4 的满分页只剩 0.40,
|
|
512
|
+
// 输出里一片 0.3~0.5,用户看不出谁更相关 —— 亮度失去了「一眼看强弱」的作用。
|
|
513
|
+
// 层的作用改由**排序**承担(见下面 sort),亮度回归「这一页与查询多贴」本身。
|
|
514
|
+
const bright = Math.max(0.3, Math.min(1, band * (0.1 + 0.9 * rel)));
|
|
515
|
+
return { ...r, bright };
|
|
516
|
+
})
|
|
475
517
|
// 排序:层优先,同层再看亮度。
|
|
476
518
|
// 为何层要排第一(2026-11-02,586 页实测):查「静默失效」的前三名全是 L7
|
|
477
519
|
// 混沌页(临时笔记),字面 cover=1.00 但内容与主题无关;
|
|
478
520
|
// 而 L4 认知层里真正讲这个主题的页被压到后面。
|
|
479
521
|
// 判据:知识层的页即便贴得稍微松一点,也比混沌层的碎片更该先看。
|
|
480
|
-
//
|
|
481
522
|
// 例外:L1/L2 不参与排序(铁律不立页,单独在最前输出)。
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
523
|
+
.sort((a, b) => {
|
|
524
|
+
const wa = layerWeight(a.layer);
|
|
525
|
+
const wb = layerWeight(b.layer);
|
|
526
|
+
return (wb * b.bright - wa * a.bright) || (b.bright - a.bright) || (a.id - b.id);
|
|
527
|
+
})
|
|
528
|
+
// 索引优先(2026-11-02 借 cloude-mem 的 Progressive Disclosure):
|
|
529
|
+
// 返回值只给「摘要 + 取全文成本」,**不带正文** —— 正文要用 getPage 再取。
|
|
530
|
+
// 实测这一层的价值:ABS 单页平均 824 token,8 页全带就是 6600 token,
|
|
531
|
+
// 而其中真正被用上的往往只有 1-2 页(cloude-mem 实测 90% 是浪费)。
|
|
532
|
+
.map(({ body, ...r }) => ({ ...r, digest: digest(body, 90), tokens: estTokens(body) }));
|
|
488
533
|
} catch {
|
|
489
534
|
// 方言/语法问题不能让搜索整体挂掉 —— 退化成空种子,由调用方决定提示
|
|
490
535
|
return [];
|
|
@@ -546,8 +591,14 @@ export function illuminate(handle, seeds, { hops = HOPS, limit = 10 } = {}) {
|
|
|
546
591
|
for (const [id, v] of bright) {
|
|
547
592
|
// 扩散出口也要排除 superseded —— 入口(search)排了,出口不排就绕过去:
|
|
548
593
|
// 实测旧结论页自己不出现在种子里,却被邻居扩散点亮(亮度 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);
|
|
550
|
-
if (n)
|
|
594
|
+
const n = db.prepare("SELECT id,project,dir,slug,title,layer,kind,body FROM nodes WHERE id = ? AND is_ledger = 0 AND status != 'superseded'").get(id);
|
|
595
|
+
if (n) {
|
|
596
|
+
// 与 search 保持同一形态:索引优先,只给摘要与成本,不带正文。
|
|
597
|
+
// 坑(2026-11-02 实测):这里原来只查少数字段,于是 CLI 侧读不到 digest/tokens,
|
|
598
|
+
// 摘要与成本全丢 —— search 改了、出口没改,典型的「改一半」。
|
|
599
|
+
const { body, ...rest } = n;
|
|
600
|
+
out.push({ ...rest, bright: v.bright, from: v.from, digest: digest(body, 90), tokens: estTokens(body) });
|
|
601
|
+
}
|
|
551
602
|
}
|
|
552
603
|
// 排序口径必须与 search 一致:层优先,同层再比亮度。
|
|
553
604
|
// 坑(2026-11-02 实测):原来这里只按 bright 排,把 search 已经排好的层序冲掉了 ——
|
package/src/brain/evolve.js
CHANGED
|
@@ -22,12 +22,20 @@ export function evolveSignals(handle, { staleDays = 90, now = Date.now() } = {})
|
|
|
22
22
|
const outbound = new Map();
|
|
23
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
24
|
|
|
25
|
-
// 1. 被推翻的
|
|
25
|
+
// 1. 被推翻的 vs 单纯弃用
|
|
26
|
+
//
|
|
27
|
+
// 两者都是 status=superseded,但含义不同(2026-11-02 修正):
|
|
28
|
+
// · 被取代 —— 有 superseded-by,指向新页(知识更新了)
|
|
29
|
+
// · 单纯弃用 —— 没有 superseded-by(如「需求已否决,本页存档设计」)
|
|
30
|
+
// 原来把「没写 superseded-by」一律报成断链,于是用户每跑一次 evolve 都被提醒
|
|
31
|
+
// 一件并不存在问题的事(实测:corp_agent 的 run-command-controlled-exec
|
|
32
|
+
// 正文写着「需求已否决」,那是存档不是断链)。
|
|
33
|
+
// 真断链只有一种:**写了 by、但指向的页不存在**。
|
|
26
34
|
const superseded = all.filter((r) => r.status === 'superseded');
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
const retired = superseded.filter((r) => !r.superseded_by); // 单纯弃用,不报警
|
|
36
|
+
const replaced = superseded.filter((r) => r.superseded_by); // 真被取代
|
|
37
|
+
const danglingSupersede = replaced.filter((r) =>
|
|
38
|
+
!all.some((x) => x.slug === r.superseded_by && x.project === r.project));
|
|
31
39
|
|
|
32
40
|
// 2. 主题堆积:按 tags 里的**业务标签**归类。
|
|
33
41
|
//
|
|
@@ -60,7 +68,7 @@ export function evolveSignals(handle, { staleDays = 90, now = Date.now() } = {})
|
|
|
60
68
|
|
|
61
69
|
return {
|
|
62
70
|
total: all.length,
|
|
63
|
-
superseded, danglingSupersede,
|
|
71
|
+
superseded, replaced, retired, danglingSupersede,
|
|
64
72
|
piled, sinkOnly, stale,
|
|
65
73
|
byId, inbound, outbound,
|
|
66
74
|
};
|
|
@@ -79,16 +87,20 @@ export function renderEvolve(sig, { sample = 5, staleDays = 90 } = {}) {
|
|
|
79
87
|
out.push('');
|
|
80
88
|
|
|
81
89
|
// 好消息放前面:推翻说明知识在更新
|
|
82
|
-
if (sig.
|
|
83
|
-
out.push(`✓ 已推翻 ${sig.
|
|
84
|
-
out.push(...trunc(sig.
|
|
90
|
+
if (sig.replaced.length) {
|
|
91
|
+
out.push(`✓ 已推翻 ${sig.replaced.length} 页(知识在更新,不是坏事)`);
|
|
92
|
+
out.push(...trunc(sig.replaced, (r) => ` ${r.slug} → ${r.superseded_by}`));
|
|
85
93
|
} else {
|
|
86
94
|
out.push('· 还没有被推翻的页 —— 要么知识很稳,要么没人回头修正');
|
|
87
95
|
}
|
|
96
|
+
if (sig.retired.length) {
|
|
97
|
+
out.push(`· 另有 ${sig.retired.length} 页是单纯弃用(需求否决/方案作废),不是断链`);
|
|
98
|
+
out.push(...trunc(sig.retired, (r) => ` ${r.slug}`));
|
|
99
|
+
}
|
|
88
100
|
out.push('');
|
|
89
101
|
|
|
90
102
|
if (sig.danglingSupersede.length) {
|
|
91
|
-
out.push(`⚠ ${sig.danglingSupersede.length}
|
|
103
|
+
out.push(`⚠ ${sig.danglingSupersede.length} 页写了取代者但目标页不存在(历史断了)`);
|
|
92
104
|
out.push(...trunc(sig.danglingSupersede, (r) => ` ${r.slug}`));
|
|
93
105
|
out.push(' 修: abs supersede <旧页> --by <新页>');
|
|
94
106
|
out.push('');
|
package/src/brain/layers.js
CHANGED
|
@@ -78,7 +78,21 @@ export function resolveLayer(body, dirName) {
|
|
|
78
78
|
/** 模板占位符 —— 它们出现在正文里是在**讨论语法**,不是真链接。
|
|
79
79
|
* 实测(2026-11-01):.brain 里 `[[作者]]` `[[页面名]]` `[[slug]]` 被当成真双链,
|
|
80
80
|
* 污染断点词表并被报成悬挂链接。排除它们是判据,不是特例。 */
|
|
81
|
-
const PLACEHOLDER = new Set([
|
|
81
|
+
const PLACEHOLDER = new Set([
|
|
82
|
+
'作者', '页面名', 'slug', '页面', '页名', '名称', 'x', 'y', 'foo', 'bar',
|
|
83
|
+
// 讨论「双链 vs wiki」时提到的词(2026-11-02 实测:log-2026-09-17 里
|
|
84
|
+
// 「抽词前剥掉 [[wiki]]」是指令,不是链接到某个 wiki 页)
|
|
85
|
+
'wiki',
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/** 占位符形状:`[[TOOLS:...]]`、`[[A...B]]`、`[[前缀:xxx]]` 这类。
|
|
89
|
+
* 它们是「描述语法时写的方括号」,不是真链接。
|
|
90
|
+
* 判据(2026-11-02):含省略号,或含冒号且冒号前是纯大写字母(像常量/枚举写法)。 */
|
|
91
|
+
function isPlaceholderShape(t) {
|
|
92
|
+
if (/[..]{2,}|\.\.\.|…/.test(t)) return true; // 省略号
|
|
93
|
+
if (/^[A-Z_]+[::]/.test(t)) return true; // TOOLS:xxx 这类
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
82
96
|
|
|
83
97
|
/** 提取页面里的双链词(L6 断点层的数据来源)。
|
|
84
98
|
* `[[x]]` / `[[x|别名]]` / `[[x#小节]]` 都取 x。
|
|
@@ -92,6 +106,7 @@ export function anchorsOf(body) {
|
|
|
92
106
|
const t = m[1].trim();
|
|
93
107
|
if (!t) continue;
|
|
94
108
|
if (PLACEHOLDER.has(t)) continue;
|
|
109
|
+
if (isPlaceholderShape(t)) continue;
|
|
95
110
|
// 纯符号/纯数字的不是页名
|
|
96
111
|
if (!/[\p{L}\p{N}]/u.test(t)) continue;
|
|
97
112
|
out.add(t);
|
package/src/brain/load.js
CHANGED
|
@@ -5,7 +5,7 @@ import { brainPath, requireBrain } from '../index.js';
|
|
|
5
5
|
import { getUser, placeholderWarn } from '../userconfig.js';
|
|
6
6
|
import { boardText, readTodo, collapseDone } from '../todo.js';
|
|
7
7
|
import { strandedFor } from '../wrapup.js';
|
|
8
|
-
import { keywords, recentFiles, topicStrength } from '../relevant.js';
|
|
8
|
+
import { keywords, recentFiles, topicStrength, digest } from '../relevant.js';
|
|
9
9
|
import { readIfExists, recentLogLines } from '../text.js';
|
|
10
10
|
import { listPages } from '../lint.js';
|
|
11
11
|
import { SOURCES_MAX } from '../lint.js';
|
|
@@ -136,6 +136,12 @@ export async function cmdLoad({ dir }) {
|
|
|
136
136
|
const rel = await queryHintSection(root, todo);
|
|
137
137
|
if (rel) sections.push('', rel);
|
|
138
138
|
} catch { /* 提示失败不影响 load */ }
|
|
139
|
+
// 最相关几页(2026-11-02):queryHint 给「该查什么词」,这里给「该读哪几页」。
|
|
140
|
+
// 放最后 —— 它是「再深入一层」的东西,前面先给状态与待办。
|
|
141
|
+
try {
|
|
142
|
+
const hot = await hotPagesSection(root, todo);
|
|
143
|
+
if (hot) sections.push('', hot);
|
|
144
|
+
} catch { /* 同上,失败静默 */ }
|
|
139
145
|
// sources 堆积提醒:与「滞留」同构 —— 放在每次开工必经的顶部,而不是等人跑 lint。
|
|
140
146
|
// 只数目录条目(不读文件),零成本。提炼仍手工:这里只负责送达,不替判断。
|
|
141
147
|
try {
|
|
@@ -174,6 +180,94 @@ export function extractBlocks(todoText) {
|
|
|
174
180
|
return out;
|
|
175
181
|
}
|
|
176
182
|
|
|
183
|
+
/** 从「活跃 todo + 最近改动的文件」推出当前上下文的关键词。
|
|
184
|
+
*
|
|
185
|
+
* 抽成公共函数(2026-11-02):queryHint(给查询词)与 hotPages(给正文)
|
|
186
|
+
* 都要用,两处各算一遍必然漂移。
|
|
187
|
+
*
|
|
188
|
+
* 已剔掉任务行的结构记号:`- [ ] [状态] <id> [[作者]] —` 里只有「—」后面是真内容。
|
|
189
|
+
* 实测教训(2026-09-17):不剔的话 `<id>` 与 `[[作者]]` 会进入词表,
|
|
190
|
+
* 而作者名恰好有自己的 entities/<作者>.md 页 → 命中页名 → 提出「查你自己的名字」。
|
|
191
|
+
*/
|
|
192
|
+
async function contextKeywords(root, todoText) {
|
|
193
|
+
const active = String(todoText || '')
|
|
194
|
+
.split('\n')
|
|
195
|
+
.filter((l) => /^\s*-\s*\[\s*\]/.test(l))
|
|
196
|
+
.map((l) => l.replace(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(?:\S+\s*)?/, '').replace(/\[\[[^\]]*\]\]/g, ' '))
|
|
197
|
+
.join(' ');
|
|
198
|
+
const files = await recentFiles(root, { n: 5 }).catch(() => []);
|
|
199
|
+
const src = [active, files.map((f) => f.name).join(' ')].filter(Boolean).join(' ');
|
|
200
|
+
return { kws: keywords(src), files };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 开机带出「当前上下文最相关的几页」—— 只给页名 + 一句话摘要,不贴正文。
|
|
205
|
+
*
|
|
206
|
+
* 与 queryHint 的分工(2026-11-02 用户定):
|
|
207
|
+
* queryHint 给「该查什么词」,本函数给「该读哪几页」。两者都只是**送达线索**,
|
|
208
|
+
* 不替代 query / db search,也不贴正文。
|
|
209
|
+
*
|
|
210
|
+
* 为何不贴正文:既有判据是「自动带出正文会让 query 更没人用,且无法按需求变化调整词」。
|
|
211
|
+
* 只给页名 + 摘要把选择权留给人:想看再 abs resolve / 读文件。
|
|
212
|
+
*
|
|
213
|
+
* 输入是环境事实(活跃 todo 的关键词 + 最近改过的文件),不需要人先想到搜索词 ——
|
|
214
|
+
* 这正是不依赖「主动查询」的那条路。
|
|
215
|
+
*
|
|
216
|
+
* 降级:库不存在 / 库坏了 / 任何异常 → 返回空串,不影响 load。
|
|
217
|
+
*
|
|
218
|
+
* @param {string} root 项目根
|
|
219
|
+
* @param {string} todoText
|
|
220
|
+
* @param {number} limit 最多几页
|
|
221
|
+
*/
|
|
222
|
+
export async function hotPagesSection(root, todoText, { limit = 3 } = {}) {
|
|
223
|
+
try {
|
|
224
|
+
const { kws } = await contextKeywords(root, todoText);
|
|
225
|
+
if (!kws.length) return '';
|
|
226
|
+
const { openDb, search, illuminate, dbPath } = await import('./db.js');
|
|
227
|
+
const { promises: fsp } = await import('node:fs');
|
|
228
|
+
const exists = await fsp.stat(dbPath()).then(() => true).catch(() => false);
|
|
229
|
+
if (!exists) return ''; // 库没建 → 静默跳过(不是错误)
|
|
230
|
+
|
|
231
|
+
// 项目名要与入库时一致 —— 铁律按项目隔离,读错项目就是串味
|
|
232
|
+
const project = String(root).split('/').filter(Boolean).pop() || '';
|
|
233
|
+
const h = openDb(dbPath());
|
|
234
|
+
try {
|
|
235
|
+
// 用几个强词合并成一组种子:逐个查再按 id 去重取最高亮度
|
|
236
|
+
const seeds = new Map();
|
|
237
|
+
for (const kw of kws.slice(0, 5)) {
|
|
238
|
+
for (const s of search(h, kw, { limit: 6 })) {
|
|
239
|
+
const prev = seeds.get(s.id);
|
|
240
|
+
if (!prev || s.bright > prev.bright) seeds.set(s.id, s);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (!seeds.size) return '';
|
|
244
|
+
const lit = illuminate(h, [...seeds.values()], { limit: limit + 6 })
|
|
245
|
+
// 只留本项目的页:跨项目的经验可以查,但不该在开机首屏默认塞进来
|
|
246
|
+
.filter((r) => r.project === project)
|
|
247
|
+
// 再按**纯亮度**重排 —— illuminate 用的是「层权重 × 亮度」,
|
|
248
|
+
// 那是为了让知识页压过流水页(搜索场景对);但开机首屏要看的是
|
|
249
|
+
// 「跟我现在干的事最贴的」,不是「层级最高的」。
|
|
250
|
+
// 实测:不重排时 L3 的 0.925 会排在 L4 的 1.000 前面 —— 读起来反直觉。
|
|
251
|
+
.sort((a, b) => b.bright - a.bright)
|
|
252
|
+
.slice(0, limit);
|
|
253
|
+
if (!lit.length) return '';
|
|
254
|
+
|
|
255
|
+
const rows = ['--- 当前最相关(按亮度,纯线索不贴正文;要看用 abs resolve <页名>)---'];
|
|
256
|
+
for (const r of lit) {
|
|
257
|
+
const one = await fsp.readFile(brainPath(root, r.dir, `${r.slug}.md`), 'utf8').catch(() => '');
|
|
258
|
+
const about = one ? digest(one, 80) : '';
|
|
259
|
+
const tag = r.from === 'spread' ? ' ↝' : '';
|
|
260
|
+
rows.push(` ${r.bright.toFixed(2)} L${r.layer} [[${r.slug}]]${tag}${about ? ' — ' + about : ''}`);
|
|
261
|
+
}
|
|
262
|
+
return rows.join('\n');
|
|
263
|
+
} finally {
|
|
264
|
+
h.close();
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
return ''; // 任何异常都不该弄坏 load
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
177
271
|
/**
|
|
178
272
|
* 开工提示:直接给出该查的几个词,降低发起 query 的成本。
|
|
179
273
|
*
|
|
@@ -195,19 +289,7 @@ export function extractBlocks(todoText) {
|
|
|
195
289
|
* @param {{name:string,body:string}[]} [pages] 已读好的页(省一次磁盘扫描)
|
|
196
290
|
*/
|
|
197
291
|
async function queryHint(root, todoText, pages) {
|
|
198
|
-
const
|
|
199
|
-
.split('\n')
|
|
200
|
-
.filter((l) => /^\s*-\s*\[\s*\]/.test(l))
|
|
201
|
-
// 剔掉任务行的结构记号再分词:`- [ ] [状态] <id> [[作者]] —` 里只有「—」后面是真内容。
|
|
202
|
-
// 实测教训(2026-09-17):不剔的话 `<id>` 与 `[[作者]]` 会进入词表,
|
|
203
|
-
// 而作者名恰好有自己的 entities/<作者>.md 页 → 命中**页名** → 强度 1 过了门槛,
|
|
204
|
-
// 于是提示语变成「查一下你自己的名字」。建议查作者页不是“别重踩的经验”。
|
|
205
|
-
// 用 task 行自身的形状剥(与 idOfTaskLine 同源),不维护人名/任务名黑名单。
|
|
206
|
-
.map((l) => l.replace(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(?:\S+\s*)?/, '').replace(/\[\[[^\]]*\]\]/g, ' '))
|
|
207
|
-
.join(' ');
|
|
208
|
-
const files = await recentFiles(root, { n: 5 }).catch(() => []);
|
|
209
|
-
const src = [active, files.map((f) => f.name).join(' ')].filter(Boolean).join(' ');
|
|
210
|
-
const kws = keywords(src);
|
|
292
|
+
const { kws, files } = await contextKeywords(root, todoText);
|
|
211
293
|
if (!kws.length) return '';
|
|
212
294
|
const all = pages || await listPages(brainPath(root)).catch(() => []);
|
|
213
295
|
const strength = topicStrength(all.map((p) => ({ name: p.slug, body: p.body })), kws);
|
|
@@ -240,6 +322,107 @@ async function queryHintSection(root, todo) {
|
|
|
240
322
|
return queryHint(root, todo);
|
|
241
323
|
}
|
|
242
324
|
|
|
325
|
+
/**
|
|
326
|
+
* recall:卡住时主动取「跟当前处境相关的几页」,不需要先想到检索词。
|
|
327
|
+
*
|
|
328
|
+
* 为何要它(2026-11-03 实测):hotPages 只挂在 load 上,于是只有开工那一刻会送页。
|
|
329
|
+
* 实测一场完整会话从头到尾没调过一次 abs —— 不是检索不准(命中确实准),
|
|
330
|
+
* 而是「要人先想到发起查询」这一步天然被跳过。一个出口只开一次,就等于没开。
|
|
331
|
+
*
|
|
332
|
+
* 与 hotPages 的分工:hotPages 是开机首屏(自动、固定 3 页);
|
|
333
|
+
* recall 是随时可调(agent 卡住/换方向时自己跑),且能补词。
|
|
334
|
+
* 两者共用 contextKeywords + 亮度搜索,不各算一套。
|
|
335
|
+
*
|
|
336
|
+
* 词从哪来:
|
|
337
|
+
* - 传了词 → 用它(这时它就是个带上下文的搜索,仍会补处境词)
|
|
338
|
+
* - 没传词 → 纯靠处境(活跃 todo 的关键词 + 最近改过的文件)
|
|
339
|
+
* 全部凭空无词 → 说清楚缺什么,不返回空列表充数。
|
|
340
|
+
*
|
|
341
|
+
* @param {{dir?:string, terms?:string[], limit?:number}} opts
|
|
342
|
+
*/
|
|
343
|
+
export async function cmdRecall({ dir, terms, limit = 5 } = {}) {
|
|
344
|
+
const given = (terms || []).flatMap((t) => String(t).trim().split(/\s+/)).filter(Boolean);
|
|
345
|
+
let root;
|
|
346
|
+
try {
|
|
347
|
+
root = await requireBrain(dir || process.cwd());
|
|
348
|
+
} catch {
|
|
349
|
+
return '未找到 .brain/ 图谱(无记忆可查)。先在项目根运行: abs init';
|
|
350
|
+
}
|
|
351
|
+
const todo = await readTodo(root).catch(() => '');
|
|
352
|
+
let kws = [];
|
|
353
|
+
let files = [];
|
|
354
|
+
try {
|
|
355
|
+
({ kws, files } = await contextKeywords(root, todo));
|
|
356
|
+
} catch {
|
|
357
|
+
// 拔词失败不该拦住整个 recall
|
|
358
|
+
}
|
|
359
|
+
// 手动传的词优先级最高:用户/agent 已经想清楚要什么,就别把处境词混进去稀释
|
|
360
|
+
const words = given.length ? given : kws.slice(0, 5);
|
|
361
|
+
if (!words.length) {
|
|
362
|
+
return 'recall: 无从判断当前处境(todo 空、近期无改动、也没给词)。\n'
|
|
363
|
+
+ '→ 直接给词: abs recall <词1> <词2>;或先 abs todo add 把在干的事登记上。';
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const { openDb, search, illuminate, dbPath } = await import('./db.js');
|
|
367
|
+
const { promises: fsp } = await import('node:fs');
|
|
368
|
+
const exists = await fsp.stat(dbPath()).then(() => true).catch(() => false);
|
|
369
|
+
if (!exists) return 'recall: 图数据库还没建(亮度检索需要它)。先跑: abs db rebuild';
|
|
370
|
+
|
|
371
|
+
const project = String(root).split('/').filter(Boolean).pop() || '';
|
|
372
|
+
const h = openDb(dbPath());
|
|
373
|
+
let lit;
|
|
374
|
+
try {
|
|
375
|
+
const seeds = new Map();
|
|
376
|
+
// 整串先查(实测:`db search "并发 锁"` 能出 file-write-locking,
|
|
377
|
+
// 而逐词分开查的种子表里根本没它 —— 2-gram 索引对整串和单词的切法不同,
|
|
378
|
+
// 拆开就把最有用的命中拆没了。所以整串优先,再用逐词兜底补漏。
|
|
379
|
+
const whole = given.length ? words.join(' ') : words.join(' ');
|
|
380
|
+
for (const s of search(h, whole, { limit: 12 })) seeds.set(s.id, s);
|
|
381
|
+
for (const w of words) {
|
|
382
|
+
for (const s of search(h, w, { limit: 6 })) {
|
|
383
|
+
const prev = seeds.get(s.id);
|
|
384
|
+
if (!prev || s.bright > prev.bright) seeds.set(s.id, s);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!seeds.size) {
|
|
388
|
+
return `recall [${words.join(', ')}]: 库里没搜到相关页。\n`
|
|
389
|
+
+ '→ 换个说法: abs recall <别的词>;看图谱全貌: abs query <词>。';
|
|
390
|
+
}
|
|
391
|
+
// 流水页降权(2026-11-03 实测):不加时前 5 位里挤进 2 页 sessions/log-*,
|
|
392
|
+
// 还被排到第一,把 file-write-locking 这种真经验页顶下去。
|
|
393
|
+
// 与 query 用同一套判据(lint.js 的 isTerminal),不另造。只降权不剔除。
|
|
394
|
+
const isFlow = (r) => r.dir === 'sources' || (r.dir === 'sessions' && /^log-/.test(r.slug));
|
|
395
|
+
lit = illuminate(h, [...seeds.values()], { limit: limit * 8 })
|
|
396
|
+
.filter((r) => r.project === project)
|
|
397
|
+
.sort((a, b) => (b.bright - (isFlow(b) ? 1 : 0)) - (a.bright - (isFlow(a) ? 1 : 0)))
|
|
398
|
+
.slice(0, limit);
|
|
399
|
+
} finally {
|
|
400
|
+
h.close();
|
|
401
|
+
}
|
|
402
|
+
if (!lit.length) {
|
|
403
|
+
return `recall [${words.join(', ')}]: 搜到了种子,但本项目的页没有亮起来。\n`
|
|
404
|
+
+ '→ 试试 abs query <词> 看全图命中。';
|
|
405
|
+
}
|
|
406
|
+
const rows = [`recall [${words.join(', ')}] → ${lit.length} 页(按亮度;看全文: abs resolve <页名>)`];
|
|
407
|
+
if (!given.length) {
|
|
408
|
+
const why = [
|
|
409
|
+
files.length ? `近期改动 ${files.length} 个文件` : '',
|
|
410
|
+
todo ? '活跃 todo' : '',
|
|
411
|
+
].filter(Boolean).join(' + ');
|
|
412
|
+
if (why) rows.push(` 处境: ${why}`);
|
|
413
|
+
}
|
|
414
|
+
rows.push('');
|
|
415
|
+
for (const r of lit) {
|
|
416
|
+
const one = await fsp.readFile(brainPath(root, r.dir, `${r.slug}.md`), 'utf8').catch(() => '');
|
|
417
|
+
const about = one ? digest(one, 80) : '';
|
|
418
|
+
rows.push(` ${r.bright.toFixed(2)} L${r.layer} [[${r.slug}]]${about ? ' — ' + about : ''}`);
|
|
419
|
+
}
|
|
420
|
+
return rows.join('\n');
|
|
421
|
+
} catch (e) {
|
|
422
|
+
return `recall 失败: ${e instanceof Error ? e.message : String(e)}`;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
243
426
|
/** sources 堆积提醒:返回 [标题行, ...内容行] 或 null。失败静默。 */
|
|
244
427
|
async function sourcesCountSection(root) {
|
|
245
428
|
const nsrc = await countSources(root);
|
package/src/brain/maintain.js
CHANGED
|
@@ -57,38 +57,45 @@ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {})
|
|
|
57
57
|
return `[dry-run] 将归档 ${plan.archived.length} 天 / ${plan.count} 条(每天一个文件)\n ${brief}${why}`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// 1)
|
|
60
|
+
// 1) todo.md:锁内重算(拿最新内容,避免与并发 done 互相覆盖),
|
|
61
|
+
// todo 是权威 —— 会话页/ index 都跟随它,不反向。
|
|
62
|
+
// 先写 todo 再写会话页:todo 写失败(闸门拒绝/SKIP)时会话页不写,
|
|
63
|
+
// 避免「会话页已写、看板没动」的两边不一致(原顺序相反,且会话页裸写绕过锁)。
|
|
64
|
+
let archivedFinal = [];
|
|
65
|
+
await editFile(todoP, (cur) => {
|
|
66
|
+
// 坑(2026-10-05 实测抓到): 这里漏传 maxLines → 锁内重算的 archived 为空 →
|
|
67
|
+
// 返回 SKIP → 磁盘上的 todo.md 一个字没动(而 sessions/ 归档页已经写了,
|
|
68
|
+
// 两边不一致)。**同参数的每个调用点都要一起改**。
|
|
69
|
+
const p2 = archiveDoneInText(cur ?? '', { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
|
|
70
|
+
if (!p2.archived.length) return SKIP;
|
|
71
|
+
archivedFinal = p2.archived; // 锁内算出的权威清单,后续写会话页/index 用它
|
|
72
|
+
return { text: p2.text };
|
|
73
|
+
});
|
|
74
|
+
if (!archivedFinal.length) {
|
|
75
|
+
return `(todo 锁内重算无归档项,已跳过)${why}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2) 归档目标 = 当天的会话快照文件(log-<日期>.md),写进其「任务归档」段。
|
|
61
79
|
// 为何合一(2026-09-15 用户定): 归档与会话快照是**同一天的记录**,分两个文件查着要开两处。
|
|
62
80
|
// 边界: ① 文件不存在→建只有归档段的页(那天可能没写快照)
|
|
63
81
|
// ② 文件已存在(含 AI 手写快照)→ 只替换归档段,段外逐字保留
|
|
64
82
|
// (硬规则「已存在的人工内容一律不覆盖」由 upsertArchiveSection 保证)。
|
|
83
|
+
// 走 editFile 锁(2026-11-03 审查修复):append 语义必须与 setMemo 同一路数,
|
|
84
|
+
// 裸 readFile+tmp+rename 会让并发 archive 互相覆盖,丢一批归档任务行。
|
|
65
85
|
const sessDir = brainPath(root, 'sessions');
|
|
66
|
-
|
|
86
|
+
await fs.mkdir(sessDir, { recursive: true });
|
|
87
|
+
for (const g of archivedFinal) {
|
|
67
88
|
const pageP = join(sessDir, `${g.slug}.md`);
|
|
68
|
-
|
|
69
|
-
try { page = await fs.readFile(pageP, 'utf8'); } catch { /* 首次 */ }
|
|
70
|
-
const nextPage = upsertArchiveSection(page, g);
|
|
71
|
-
const tmp = join(sessDir, `.${g.slug}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
|
|
72
|
-
await fs.writeFile(tmp, nextPage, 'utf8');
|
|
73
|
-
await fs.rename(tmp, pageP);
|
|
89
|
+
await editFile(pageP, (cur) => ({ text: upsertArchiveSection(cur, g) }));
|
|
74
90
|
}
|
|
75
91
|
|
|
76
|
-
// 2) todo.md:锁内重算(拿最新内容,避免与并发 done 互相覆盖)
|
|
77
|
-
await editFile(todoP, (cur) => {
|
|
78
|
-
// 坑(2026-10-05 实测抓到): 这里漏传 maxLines → 锁内重算的 archived 为空 →
|
|
79
|
-
// 返回 SKIP → 磁盘上的 todo.md 一个字没动(而 sessions/ 归档页已经写了,
|
|
80
|
-
// 两边不一致:"说归档了但看板没变")。**同参数的每个调用点都要一起改**。
|
|
81
|
-
const p2 = archiveDoneInText(cur ?? '', { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
|
|
82
|
-
return p2.archived.length ? { text: p2.text } : SKIP;
|
|
83
|
-
});
|
|
84
|
-
|
|
85
92
|
// 3) index 登记(每个日期页一行,幂等)
|
|
86
93
|
// 坑(2026-09-15 修): 原先把归档页登记到 `## Sources` 区 —— 但它们在 sessions/ 里,
|
|
87
94
|
// 旧数据实测就是错位的(09-10/09-12 两条曾键在 Sources 段,人工才发现)。现改登 `## Sessions`。
|
|
88
95
|
const iP = brainPath(root, 'index.md');
|
|
89
96
|
await editFile(iP, (index) => {
|
|
90
97
|
if (!index) return SKIP;
|
|
91
|
-
const missing =
|
|
98
|
+
const missing = archivedFinal.filter((g) => !index.includes(`[[${g.slug}]]`));
|
|
92
99
|
if (!missing.length) return SKIP;
|
|
93
100
|
const sIdx = index.indexOf('## Sessions');
|
|
94
101
|
if (sIdx === -1) return SKIP;
|
|
@@ -100,8 +107,10 @@ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {})
|
|
|
100
107
|
return { text: next };
|
|
101
108
|
});
|
|
102
109
|
|
|
103
|
-
const files =
|
|
104
|
-
|
|
110
|
+
const files = archivedFinal.map((g) => `sessions/${g.slug}.md`).join('\n ');
|
|
111
|
+
const count = archivedFinal.reduce((n, g) => n + g.count, 0);
|
|
112
|
+
const briefFinal = archivedFinal.map((g) => `${g.date}(${g.count})`).join(' ');
|
|
113
|
+
return `✓ 已归档 ${archivedFinal.length} 天 / ${count} 条\n ${briefFinal}\n → ${files}${why}`;
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
/**
|
package/src/query.js
CHANGED
|
@@ -53,7 +53,7 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
|
|
|
53
53
|
continue;
|
|
54
54
|
}
|
|
55
55
|
hits.push({
|
|
56
|
-
full, slug, matched: rank.matched, kind: rank.kind, fuzzyScore: rank.score, via: rank.via,
|
|
56
|
+
full, slug, dir: d, matched: rank.matched, kind: rank.kind, fuzzyScore: rank.score, via: rank.via,
|
|
57
57
|
author: au ? au[1].trim() : '',
|
|
58
58
|
id: idOfPage(body, slug),
|
|
59
59
|
status: st,
|
|
@@ -68,11 +68,18 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
|
|
|
68
68
|
// 坑(2026-09-16 实测): 不加区分时查 file-write-locking 返回 26 页(几乎全图)——
|
|
69
69
|
// 连字符词的 2-gram 太通用,模糊命中把无关页也拉进来。
|
|
70
70
|
// 规则:只要有任何精确命中,模糊命中的就不排在前面(但也不丢弃,单列一段)。
|
|
71
|
+
// 流水页降权(2026-11-02 实测):查「并发 写 锁」时前 10 位里挤进 4 页
|
|
72
|
+
// sessions/log-*,内容页被顶下去。流水页只是「那天做了什么」的草稿记录,
|
|
73
|
+
// 正文里碰巧出现检索词不代表它是这条经验所在。判据沿用 lint.js 的
|
|
74
|
+
// isTerminal(sources/ 与 sessions/log-*),不另造一套。
|
|
75
|
+
// 只降权不剔除:真要查某天的流水,它仍在结果里,只是排到内容页之后。
|
|
76
|
+
const isFlow = (h) => h.dir === 'sources' || (h.dir === 'sessions' && /^log-/.test(h.slug));
|
|
77
|
+
const rankBy = (h) => h.fuzzyScore - (isFlow(h) ? 20 : 0);
|
|
71
78
|
const exactHits = hits.filter((h) => !h.hidden && h.kind === 'exact')
|
|
72
|
-
.map((h) => ({ ...h, score: h
|
|
79
|
+
.map((h) => ({ ...h, score: rankBy(h) }))
|
|
73
80
|
.sort((a, b) => b.score - a.score);
|
|
74
81
|
const fuzzyHits = hits.filter((h) => !h.hidden && h.kind === 'fuzzy')
|
|
75
|
-
.map((h) => ({ ...h, score: h
|
|
82
|
+
.map((h) => ({ ...h, score: rankBy(h) }))
|
|
76
83
|
.sort((a, b) => b.score - a.score);
|
|
77
84
|
// 有精确命中 → 模糊全藏起来(但不静默:告知数量 + 怎么看)
|
|
78
85
|
const fuzzySuppressed = exactHits.length > 0 && fuzzyHits.length > 0;
|
package/src/store.js
CHANGED
|
@@ -19,7 +19,7 @@ export { BRAIN_SHAPE, LEGACY_MARKS, checkBrainShape } from './brain/shape.js';
|
|
|
19
19
|
import './brain/gate.js';
|
|
20
20
|
|
|
21
21
|
// ---- 只读呈现 ----
|
|
22
|
-
export { cmdBoard, cmdStatus, cmdLoad, cmdShow, extractBlocks } from './brain/load.js';
|
|
22
|
+
export { cmdBoard, cmdStatus, cmdLoad, cmdShow, cmdRecall, extractBlocks } from './brain/load.js';
|
|
23
23
|
|
|
24
24
|
// ---- Rules ----
|
|
25
25
|
export { RULES_HEADING, RULES_MAX, RULE_BODY_MAX, readRules, cmdRule } from './brain/rules.js';
|