@fanchao8609/agent_brain_sync 1.8.8 → 1.9.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/bin/abs.js +155 -53
- package/bin/mcp.js +5 -4
- package/hooks/abs.opencode.ts +14 -4
- package/hooks/event.sh +8 -0
- package/package.json +3 -2
- package/skill/abs-agent-brain-sync/SKILL.md +2 -2
- package/skill/abs-bug-hunter/SKILL.md +109 -158
- package/src/index.js +22 -3
- package/src/relevant.js +244 -0
- package/src/serve.js +506 -0
- package/src/store.js +149 -26
- package/src/todo.js +1 -1
- package/src/userconfig.js +9 -5
- package/skill/abs-think-tree/SKILL.md +0 -194
- package/skill/abs-think-tree/check.js +0 -119
- package/skill/abs-think-tree/check.test.js +0 -161
|
@@ -1,228 +1,179 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: abs-bug-hunter
|
|
3
|
-
description:
|
|
3
|
+
description: 排查 bug 的流程纪律 —— 先列假设再动手,用数据定位,修根因。排查完把踩坑写进 .brain/。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Bug 排查方法论
|
|
7
7
|
|
|
8
8
|
## 核心原则
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
**不凭空猜测,用数据说话。但数据不是终点 —— 先复现,再定位,再理解根因。**
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
最常见的失败:把"能复现"当成"已定位"。定位到出错的**位置** ≠ 找到**根因**。
|
|
13
|
+
改症状不改根因,换一批数据就复发。
|
|
13
14
|
|
|
14
|
-
##
|
|
15
|
+
## 第 -1 步:列假设(不能跳过)
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
**触发**:用户报了症状("打不开""报错了""不对"),但没给一手错误信息。
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
**这一步在"复现"之前**,因为拿不到报错时,"先复现"会逼你造假。
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
编了个接口名 `app.init` → curl 测出 404 → “我测出来的,是事实” → 推出“nginx 没配 PATH_INFO”
|
|
22
|
-
→ 用户纠正后**重跑同一实验、得到同一 404** → 坚信自己没错。
|
|
21
|
+
### 自造证据 —— 最毒的一类错误
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
> (输入名)在最上游,且已消失在过程里 → 所以“再验证一次”救不了,
|
|
26
|
-
> 他会重跑同假输入得同真输出。
|
|
27
|
-
> 下面第 0 步“先复现”在拿不到报错时反而会逼出造假 —— 所以本步在它之前。
|
|
23
|
+
**真实案例**:只有一个假设时,它要么被证实要么思路断掉 —— 于是自己造一个输入去测:
|
|
28
24
|
|
|
29
|
-
|
|
25
|
+
```
|
|
26
|
+
编了个接口名 app.init → curl 测出 404 → "我测出来的,是事实"
|
|
27
|
+
→ 推出"nginx 没配 PATH_INFO"
|
|
28
|
+
→ 用户纠正后,重跑同一实验、得到同一 404 → 坚信自己没错
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**为什么最毒**:下游全程都是真的(404 真、命令真跑过)。唯一假的那环在**最上游**,
|
|
32
|
+
且已消失在过程里 → 所以"再验证一次"救不了,重跑同假输入得同真输出。
|
|
33
|
+
|
|
34
|
+
### 所以
|
|
35
|
+
|
|
36
|
+
1. **开局列 ≥2 个假设**(代码 / 配置 / 构建产物 / 缓存 / 第三方)。
|
|
30
37
|
只列一个 = 逼自己去证实它 = 造证据的动力。
|
|
31
38
|
2. **每个假设配反证条件**:什么现象出现就认它错。
|
|
32
|
-
|
|
33
|
-
> 测出也是 404 → 接口本身不存在,与 nginx 无关 → 弃此假设。”
|
|
34
|
-
> 反证条件里写明“真实接口名”,`app.init` 这种编造的就自动不能用了。
|
|
39
|
+
反证条件里要写明**能从代码里读到的真实名字**,编造的自动不能用。
|
|
35
40
|
3. **卡住两轮就换假设**,不是在同一假设上加细节。
|
|
36
|
-
4. **领域外的先声明无取证能力**(构建产物在不在 / 缓存 /
|
|
41
|
+
4. **领域外的先声明无取证能力**(构建产物在不在 / 缓存 / 第三方后台行为),
|
|
37
42
|
要用户提供事实,**不要脑补**。
|
|
38
43
|
|
|
39
44
|
**结论所依据的每个具体名字(接口/字段/报错文本/路径)必须指得出处(文件:行)。**
|
|
40
|
-
|
|
45
|
+
指不出的只能标"推测",**不得当证据用**。
|
|
41
46
|
|
|
42
47
|
> **为什么多假设能防造假**:单假设必须被证实,否则思路断 → 有动机造。
|
|
43
|
-
>
|
|
48
|
+
> 多假设下,"支持了 A" ≠ "就是 A"(B/C/D 还没排除)→ 假证据推不出结论,造它就没意义。
|
|
49
|
+
|
|
50
|
+
## 第 0 步:复现
|
|
44
51
|
|
|
45
|
-
|
|
52
|
+
没有稳定复现,后面全是猜。
|
|
46
53
|
|
|
47
|
-
没有稳定复现,后面全是猜。先确定:
|
|
48
54
|
- 复现的必要条件是什么?哪个输入/操作/环境触发?
|
|
49
|
-
-
|
|
50
|
-
-
|
|
55
|
+
- 100% 复现还是偶发?偶发先找触发模式(特定数据/时机/顺序)。
|
|
56
|
+
- 有现成错误栈吗?**错误信息 + 行号是最便宜的定位入口。**
|
|
51
57
|
|
|
52
|
-
**给 Bug 起个名字**(一句话描述 +
|
|
58
|
+
**给 Bug 起个名字**(一句话描述 + 触发条件)。卡住时回来核对是否还在同一个 Bug 上。
|
|
53
59
|
|
|
54
|
-
|
|
60
|
+
## 第一步:定位范围 —— 改动了什么
|
|
55
61
|
|
|
56
|
-
Bug
|
|
62
|
+
Bug 不会凭空出现。先查最近的改动:
|
|
57
63
|
|
|
58
64
|
```bash
|
|
59
|
-
git diff HEAD~3 --stat
|
|
60
|
-
git log
|
|
61
|
-
git
|
|
62
|
-
git log -p -S '可疑字段/函数名' # 谁改过这段代码
|
|
63
|
-
git blame <file> -L <行号,行号> # 定位到具体某行是谁写的
|
|
65
|
+
git diff HEAD~3 --stat # 改了哪些文件
|
|
66
|
+
git log -p -S '可疑字段/函数名' # 谁改过这段
|
|
67
|
+
git blame <file> -L <行号,行号> # 定位到具体某行
|
|
64
68
|
```
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
- 这次新增/修改了什么功能?
|
|
68
|
-
- 改动涉及哪些文件?
|
|
69
|
-
- 哪个改动最可能影响到出问题的地方?
|
|
70
|
-
|
|
71
|
-
**不要排查无关代码,把精力集中在改动范围。**
|
|
70
|
+
问自己:这次新增/修改了什么?涉及哪些文件?哪个最可能影响出问题的地方?
|
|
72
71
|
|
|
73
|
-
|
|
72
|
+
**别排查无关代码。**
|
|
74
73
|
|
|
75
|
-
|
|
74
|
+
## 第二步:让数据说话
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|--------|----------|----------|
|
|
79
|
-
| PHP (ThinkPHP) | `\think\Log::error($data)` 或 `trace($data)` | `runtime/log/` |
|
|
80
|
-
| PHP (Laravel) | `Log::info($data)` 或 `logger($data)` | `storage/logs/` |
|
|
81
|
-
| Vue / JS | `console.log(data)` | 浏览器控制台 |
|
|
82
|
-
| Node.js | `console.log(data)` 或 `logger.debug(data)` | 终端 / 日志文件 |
|
|
83
|
-
| SQL | `\think\Db::getLastSql()` 或开启 SQL 日志 | runtime/log 或控制台 |
|
|
76
|
+
不猜,打印出来看。
|
|
84
77
|
|
|
85
|
-
|
|
78
|
+
- **打印关键数据,不要只打印"到了这里"** —— 要打印实际的变量值、类型、入参出参。
|
|
79
|
+
- **二分法**:在可疑链路上隔几层插日志,先跑一遍看哪段有数据、哪段没有,
|
|
80
|
+
范围砍半再往里加。比一次打满所有层更快收敛。
|
|
81
|
+
- **带唯一前缀**(如 `[BUG-01]`):日志里 grep 一次看到全链路顺序。
|
|
82
|
+
- 怀疑并发/时序时,打时间戳与调用来源。
|
|
86
83
|
|
|
87
|
-
|
|
88
|
-
// ❌ 没用
|
|
89
|
-
\Log::error('进入方法');
|
|
84
|
+
## 第三步:顺藤摸瓜
|
|
90
85
|
|
|
91
|
-
|
|
92
|
-
\Log::error('goods detail', ['id' => $id, 'goods' => $goods, 'type' => gettype($goods)]);
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
**二分法打印**:在可疑链路上隔几层就插一个日志点,先跑一遍看哪段有数据、哪段没数据,把范围砍半,再往里面加日志。比一次打满所有层更快收敛。
|
|
96
|
-
|
|
97
|
-
**加分技巧**:
|
|
98
|
-
- 给每个日志点带唯一前缀(如 `[BUG-01]`、`[BUG-02]`),日志里 grep 一次就能看到全链路顺序。
|
|
99
|
-
- 打印耗时和调用来源:怀疑并发/时序时,打上时间戳与 `debug_backtrace()` 或请求 ID。
|
|
100
|
-
|
|
101
|
-
### 第三步:顺藤摸瓜 — 按调用链逐层排查
|
|
86
|
+
从用户操作出发,沿调用链一层层往下:入口 → 路由 → 中间件 → 业务层 → 数据层 → 存储。
|
|
102
87
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
```
|
|
106
|
-
用户操作(点击/请求)
|
|
107
|
-
→ 路由(routes / pages.json / url)
|
|
108
|
-
→ 门面/中间件(facade / middleware)
|
|
109
|
-
→ 控制器(controller)
|
|
110
|
-
→ 服务层(service)
|
|
111
|
-
→ 模型(model)
|
|
112
|
-
→ 数据库(SQL)
|
|
113
|
-
```
|
|
88
|
+
**每一层打印关键数据,找到数据从正确变为错误的那一层 —— bug 就在那一层。**
|
|
114
89
|
|
|
115
|
-
|
|
90
|
+
## 第四步:找根因,不只修症状
|
|
116
91
|
|
|
117
|
-
|
|
118
|
-
// 控制器 — 打印接收到的参数
|
|
119
|
-
\Log::error('controller input', ['params' => $params]);
|
|
92
|
+
修复前回答:
|
|
120
93
|
|
|
121
|
-
|
|
122
|
-
|
|
94
|
+
- 为什么数据在这一层变错了?**逻辑错误**(判断写反/取错字段)、
|
|
95
|
+
**类型错误**(null/数组/对象混用)、还是**数据本身脏**(上游写入时就错)?
|
|
96
|
+
- 根因若在上游,这里 patch 只是挡一下 —— 应该去修上游,或在入口统一兜底。
|
|
123
97
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
```
|
|
98
|
+
**修一层,不改所有调用点**:如果多个地方调用同一函数,只在出问题的调用点打补丁,
|
|
99
|
+
其他调用点照样坏。**在共享函数里修一次,是所有调用点的最小修复。**
|
|
127
100
|
|
|
128
|
-
|
|
101
|
+
## 第五步:验证修复
|
|
129
102
|
|
|
130
|
-
|
|
103
|
+
不要凭空验证。**先拿第 0 步的复现条件重跑,必须看到它失败** ——
|
|
104
|
+
没失败就说明你修好了但没复现过,或者复现条件记错了。
|
|
105
|
+
不先看到 fail,就无法区分"修好了"和"根本没坏过"。
|
|
106
|
+
改完再看它变 pass,三层都要过:
|
|
131
107
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
108
|
+
| 层 | 查什么 |
|
|
109
|
+
|---|---|
|
|
110
|
+
| **修好了** | 复现路径不再报错,输出正确 |
|
|
111
|
+
| **没修坏** | 相关正常路径仍正常(回归) |
|
|
112
|
+
| **边界还在** | 边缘输入(空值/超大值/并发/重复提交)没引入新洞 |
|
|
135
113
|
|
|
136
|
-
|
|
114
|
+
确认无误后再删调试日志。
|
|
137
115
|
|
|
138
|
-
|
|
116
|
+
## 排查完:写进 .brain/
|
|
139
117
|
|
|
140
|
-
|
|
118
|
+
**这一步是 `abs-` 前缀的意义 —— 排查的结论不写下来,下个会话会重踩同一个坑。**
|
|
141
119
|
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
\Log::error('step1: fetch', ['data' => $data]);
|
|
145
|
-
$data = transform($data);
|
|
146
|
-
\Log::error('step2: transform', ['data' => $data]);
|
|
147
|
-
$result = save($data);
|
|
148
|
-
\Log::error('step3: save', ['result' => $result]);
|
|
120
|
+
```bash
|
|
121
|
+
abs note "一句话结论" --when "什么时候该看这条"
|
|
149
122
|
```
|
|
150
123
|
|
|
151
|
-
|
|
152
|
-
- **修好了**:复现路径不再报错,输出正确。
|
|
153
|
-
- **没修坏**:相关正常路径仍正常(回归)。
|
|
154
|
-
- **边界还在**:尝试边缘输入(空值、超大值、并发、重复提交),确认修复没引入新洞。
|
|
155
|
-
|
|
156
|
-
确认无误后,再删除调试日志。
|
|
157
|
-
|
|
158
|
-
## 实战示例
|
|
159
|
-
|
|
160
|
-
**场景:** 商品详情页报 `Call to a member function toArray() on array`
|
|
161
|
-
|
|
162
|
-
**第 0 步:复现**
|
|
163
|
-
- 访问 `/shopro/goods/goods/detail/id/31` 稳定复现
|
|
164
|
-
- 日志:`[error] 致命错误: Call to a member function toArray() on array [/var/www/html/.../GoodsMemberPrice.php:50]`
|
|
165
|
-
|
|
166
|
-
**第一步:定位范围**
|
|
167
|
-
- 最近新增了会员系统,改了 Goods 控制器和新增了 GoodsMemberPrice 模型
|
|
168
|
-
- 怀疑新增代码有问题
|
|
124
|
+
值得写的(**能从代码 grep 到的不写**):
|
|
169
125
|
|
|
170
|
-
|
|
171
|
-
|
|
126
|
+
| 写什么 | 例 |
|
|
127
|
+
|---|---|
|
|
128
|
+
| **假象的根因** | "报错位置是 A,真因在 B 的写入侧" |
|
|
129
|
+
| **判据** | "统计硬切率 >30% 即确诊,别去调 prompt" |
|
|
130
|
+
| **反直觉处** | "慢的不是读写,是 node 启动" |
|
|
131
|
+
| **排查路径** | "先查写入侧,别先怪模型" |
|
|
172
132
|
|
|
173
|
-
|
|
174
|
-
- 路由:`/shopro/goods/goods/detail/id/31`
|
|
175
|
-
- 控制器:`Goods::detail()` → 调用了 `GoodsMemberPriceModel::getByGoods()`
|
|
176
|
-
- 模型:`getByGoods()` 第50行 `select()->toArray()` → `select()` 返回数组,不是 Collection
|
|
133
|
+
不值得写的:报错原文(日志里有)、修好的代码(git 里有)、通用常识。
|
|
177
134
|
|
|
178
|
-
|
|
179
|
-
- 为什么 id=31 出错而别的正常?查数据:31 这件商品有会员价记录 → 触发 `select()` 返回多行场景。可能是 `find()`/`select()` 返回值混用的历史问题。若只是个别记录脏,应清理数据或给方法加统一返回类型。
|
|
135
|
+
**如果是反复踩的坑**,排查结束后提成硬规则:
|
|
180
136
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// 修复前
|
|
184
|
-
return self::where('goods_id', $goodsId)->select()->toArray();
|
|
185
|
-
|
|
186
|
-
// 修复后
|
|
187
|
-
$result = self::where('goods_id', $goodsId)->select();
|
|
188
|
-
return $result instanceof \think\Collection ? $result->toArray() : (array)$result;
|
|
137
|
+
```bash
|
|
138
|
+
abs rule add "靠提醒才能工作的功能,该删不该补"
|
|
189
139
|
```
|
|
190
|
-
- 重跑 id=31(不再报错)+ 跑一个无会员价记录的 id(确认没回归)
|
|
191
140
|
|
|
192
141
|
## 常见陷阱
|
|
193
142
|
|
|
194
143
|
| 陷阱 | 正确做法 |
|
|
195
144
|
|------|----------|
|
|
196
|
-
|
|
|
197
|
-
| 只看代码不运行 |
|
|
198
|
-
|
|
|
199
|
-
| 修症状不修根因 | 追问"为什么这层数据变了"
|
|
200
|
-
|
|
|
201
|
-
|
|
|
202
|
-
| 只在本地验证 |
|
|
203
|
-
|
|
|
204
|
-
|
|
|
205
|
-
|
|
206
|
-
##
|
|
207
|
-
|
|
208
|
-
排查 >20
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
145
|
+
| 凭经验猜位置 | 先复现 + 看日志/打印 |
|
|
146
|
+
| 只看代码不运行 | 打印运行时的实际数据 |
|
|
147
|
+
| 一次改很多地方再测 | 改一处、验证一处 |
|
|
148
|
+
| 修症状不修根因 | 追问"为什么这层数据变了" |
|
|
149
|
+
| 只验证出错路径 | 复现 + 回归 + 边界 |
|
|
150
|
+
| 忘记删调试代码 | 确认后清理所有打印 |
|
|
151
|
+
| 只在本地验证 | 确认部署的代码/数据一致 |
|
|
152
|
+
| 偶发当成必然 | 先找触发模式,别用单样本下结论 |
|
|
153
|
+
| 排查完不落盘 | 提一条 `abs note`,反复踩的提 `abs rule` |
|
|
154
|
+
|
|
155
|
+
## 兜底:卡住时
|
|
156
|
+
|
|
157
|
+
排查 >20 分钟没进展,不硬扛,回流程检查:
|
|
158
|
+
|
|
159
|
+
1. **Bug 描述还准吗?** 新观察是否改变了问题定义。
|
|
160
|
+
2. **复现还稳定吗?** 换个触发样本还出现吗。
|
|
161
|
+
3. **漏看日志了吗?** grep 全量(不只最近的),可能早期就报过错。
|
|
162
|
+
4. **改动范围查全了吗?** 只看 HEAD~3 会漏分支合并、配置、部署差异。
|
|
163
|
+
5. **要不要问人?** 这条功能最近谁改的、意图是什么,可能一句话点醒。
|
|
164
|
+
6. **向上游看一层。** 来源方是否也变了,不只是消费方的问题。
|
|
165
|
+
|
|
166
|
+
## 收尾:结论要明确
|
|
217
167
|
|
|
218
168
|
**不许**:"可能是 X,也可能是 Y,建议排查一下" —— 这是把判断推回给用户。
|
|
219
169
|
|
|
220
|
-
|
|
221
|
-
|
|
170
|
+
**必须**给出:
|
|
171
|
+
|
|
172
|
+
1. **当前结论**(是什么 / 或"未定位")
|
|
222
173
|
2. **置信度与依据**(哪来的证据 / 还是只是推测)
|
|
223
|
-
3.
|
|
174
|
+
3. **下一步具体动作**(跑什么、看什么、要用户提供什么)
|
|
224
175
|
|
|
225
|
-
"未定位"是合法输出,但必须配一句"**需要你提供 X**"
|
|
176
|
+
"未定位"是合法输出,但必须配一句"**需要你提供 X**"(具体到要什么),
|
|
226
177
|
不能只说"无法确定"就结束。
|
|
227
178
|
|
|
228
|
-
> 与第 -1
|
|
179
|
+
> 与第 -1 步同一根因:**模糊化是逃避判断。** 要么给结论 + 依据,要么明确索取证据。
|
package/src/index.js
CHANGED
|
@@ -26,13 +26,32 @@ export function brainPath(brainRoot, ...rel) {
|
|
|
26
26
|
return join(brainRoot, BRAIN_DIR, ...rel);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 带错误码的错(CLI/MCP 同源)。
|
|
31
|
+
*
|
|
32
|
+
* 为何要码(2026-09-16 借 Anneal 的 templateRefusal):
|
|
33
|
+
* MCP 侧早就有 code(err(msg,{code,fallback}),5 种),CLI 侧全是自然语言句子。
|
|
34
|
+
* 于是 hook/脚本无法区分「该静默」与「该报警」——只能靠抓字符串,改文案就碎。
|
|
35
|
+
* 约定:所有可预期的失败都带 code;调用方按码分支,不看文案。
|
|
36
|
+
* 命名:大写下划线(与 MCP 侧一致)。
|
|
37
|
+
*/
|
|
38
|
+
export class AbsError extends Error {
|
|
39
|
+
constructor(code, message, fallback) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'AbsError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.fallback = fallback || null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
/** 断言 .brain/ 存在,否则抛错(宁可失败不落错项目)。 */
|
|
30
48
|
export async function requireBrain(startDir) {
|
|
31
49
|
const root = await findBrainRoot(startDir);
|
|
32
50
|
if (!root) {
|
|
33
|
-
throw new
|
|
34
|
-
|
|
35
|
-
`
|
|
51
|
+
throw new AbsError(
|
|
52
|
+
'NO_BRAIN',
|
|
53
|
+
`abs: 目录 ${resolve(startDir)} 下没有 .brain/ 图谱(不向上搜索)。`,
|
|
54
|
+
'请在该目录运行: abs init'
|
|
36
55
|
);
|
|
37
56
|
}
|
|
38
57
|
return root;
|
package/src/relevant.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 相关页推荐:按"当前在干什么"挑出该读的知识页。
|
|
3
|
+
*
|
|
4
|
+
* 为何需要它(2026-09-16 实测):
|
|
5
|
+
* MCP 日志里 abs_task 234 / abs_note 54 / abs_load 53,而 abs_query 只有 2 次。
|
|
6
|
+
* 写入 288 : 检索 2 = 144:1。经验写进去了,几乎从没被读回来。
|
|
7
|
+
*
|
|
8
|
+
* 根因不是"忘了查",是【给人看目录,人不会去翻书】:
|
|
9
|
+
* load 只给 index 的一句话清单 → 模型觉得"我记过了" → 开工 → 真需要时靠印象。
|
|
10
|
+
* 模型不知道自己不知道什么,所以永远不会主动 query。
|
|
11
|
+
*
|
|
12
|
+
* 解法:不依赖主动查询 —— load 时【被动带出】相关页正文。
|
|
13
|
+
* 匹配依据是"当前在干什么",不是"主题词":
|
|
14
|
+
* · 最近改过的源文件名 → 我现在在动哪个模块
|
|
15
|
+
* · todo 里活跃任务 → 我现在在做什么事
|
|
16
|
+
*
|
|
17
|
+
* 为何不用 git:本项目 store.js 是纯文件操作,引 git 要处理
|
|
18
|
+
* “没装 git / 不是 repo / 子进程 27ms”三种情况,为取几个文件名不值。
|
|
19
|
+
* 改用 mtime 扫最近改动 —— 信息量相同,零依赖。
|
|
20
|
+
*
|
|
21
|
+
* 与 query 的区别:
|
|
22
|
+
* query 需要用户/模型先想到一个词;本模块不需要 —— 输入是环境事实。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** 中文/英文都按"够长才算有效词"处理,避免 the/的/了 这类噪音。 */
|
|
26
|
+
const STOP = new Set([
|
|
27
|
+
'the', 'and', 'for', 'with', 'this', 'that', 'from', 'into', 'when', 'then',
|
|
28
|
+
'abs', 'src', 'test', 'js', 'ts', 'md', 'json', 'node',
|
|
29
|
+
'的', '了', '是', '在', '和', '与', '或', '把', '被', '给', '对', '从',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** 从任意文本抽关键词:英文词(>=3) + 中文 2-gram。 */
|
|
33
|
+
export function keywords(text) {
|
|
34
|
+
const out = new Set();
|
|
35
|
+
const s = String(text || '');
|
|
36
|
+
// 英文/数字/下划线词
|
|
37
|
+
for (const m of s.matchAll(/[A-Za-z][A-Za-z0-9_-]{2,}/g)) {
|
|
38
|
+
const w = m[0].toLowerCase();
|
|
39
|
+
if (!STOP.has(w)) out.add(w);
|
|
40
|
+
}
|
|
41
|
+
// 中文串 → 2-gram(中文没有词边界,2-gram 是最省事的可匹配单位)
|
|
42
|
+
for (const m of s.matchAll(/[\u4e00-\u9fa5]{2,}/g)) {
|
|
43
|
+
const seg = m[0];
|
|
44
|
+
for (let i = 0; i + 2 <= seg.length; i++) {
|
|
45
|
+
const g = seg.slice(i, i + 2);
|
|
46
|
+
if (!STOP.has(g)) out.add(g);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...out];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 给一页打分:命中多少关键词(标题权重 3,正文权重 1)。
|
|
54
|
+
* 不做 TF-IDF —— 图谱就 30 页,朴素加权够用,也更好解释。
|
|
55
|
+
*/
|
|
56
|
+
export function scorePage(body, pageName, kws) {
|
|
57
|
+
const title = String(pageName).toLowerCase();
|
|
58
|
+
const text = String(body).toLowerCase();
|
|
59
|
+
let score = 0;
|
|
60
|
+
const hit = [];
|
|
61
|
+
for (const k of kws) {
|
|
62
|
+
const inTitle = title.includes(k);
|
|
63
|
+
const n = text.split(k).length - 1;
|
|
64
|
+
if (!inTitle && !n) continue;
|
|
65
|
+
score += (inTitle ? 3 : 0) + Math.min(n, 3);
|
|
66
|
+
hit.push(k);
|
|
67
|
+
}
|
|
68
|
+
return { score, hit };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 挑出 top-N 相关页。
|
|
73
|
+
* @param {{name:string,body:string}[]} pages
|
|
74
|
+
* @param {string[]} kws
|
|
75
|
+
* @param {number} n
|
|
76
|
+
*/
|
|
77
|
+
export function pickRelevant(pages, kws, n = 3) {
|
|
78
|
+
if (!kws.length) return [];
|
|
79
|
+
return pages
|
|
80
|
+
.map((p) => {
|
|
81
|
+
const { score, hit } = scorePage(p.body, p.name, kws);
|
|
82
|
+
return { ...p, score, hit };
|
|
83
|
+
})
|
|
84
|
+
.filter((p) => p.score > 0)
|
|
85
|
+
// 同分时按名字排,保证输出稳定(否则测试会抖)
|
|
86
|
+
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
|
|
87
|
+
.slice(0, n);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 扫最近改过的源文件名(mtime 排序,不调 git)。
|
|
92
|
+
* 跳过 .brain/(那是图谱自己)、node_modules、隐藏目录。
|
|
93
|
+
*/
|
|
94
|
+
export async function recentFiles(root, { n = 8, days = 3 } = {}) {
|
|
95
|
+
const fs = await import('node:fs/promises');
|
|
96
|
+
const { join } = await import('node:path');
|
|
97
|
+
const cutoff = Date.now() - days * 86400_000;
|
|
98
|
+
const found = [];
|
|
99
|
+
const SKIP = new Set(['node_modules', '.git', '.brain', 'dist', 'build']);
|
|
100
|
+
async function walk(d, depth) {
|
|
101
|
+
if (depth > 3 || found.length > 400) return;
|
|
102
|
+
let ents;
|
|
103
|
+
try { ents = await fs.readdir(d, { withFileTypes: true }); } catch { return; }
|
|
104
|
+
for (const e of ents) {
|
|
105
|
+
if (e.name.startsWith('.') || SKIP.has(e.name)) continue;
|
|
106
|
+
const p = join(d, e.name);
|
|
107
|
+
if (e.isDirectory()) { await walk(p, depth + 1); continue; }
|
|
108
|
+
if (!/\.(js|ts|mjs|cjs|jsx|tsx|py|go|rs|sh)$/.test(e.name)) continue;
|
|
109
|
+
try {
|
|
110
|
+
const st = await fs.stat(p);
|
|
111
|
+
if (st.mtimeMs >= cutoff) found.push({ name: e.name, path: p.replace(root + '/', ''), mtime: st.mtimeMs });
|
|
112
|
+
} catch { /* 忽略不可读 */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await walk(root, 0);
|
|
116
|
+
return found.sort((a, b) => b.mtime - a.mtime).slice(0, n);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 两级匹配:
|
|
121
|
+
* exact = 查询词直接出现(子串)
|
|
122
|
+
* fuzzy = 查询词的 2-gram 有重叠(如查 "并发写" 能中写"互斥/锁"的页)
|
|
123
|
+
*
|
|
124
|
+
* 为何需要 fuzzy(2026-09-16 实测根因):
|
|
125
|
+
* 原 cmdQuery 只做 body.includes(word) —— 查"并发写"时页里写的是"互斥"、
|
|
126
|
+
* 查"发布流程"时页名是 npm-publish-flow,全部匹配不上。
|
|
127
|
+
* 用户看到"无命中"会以为图谱里没这条经验(静默失效)。
|
|
128
|
+
*/
|
|
129
|
+
export function matchPage(body, pageName, queryWords) {
|
|
130
|
+
const text = String(body).toLowerCase();
|
|
131
|
+
const name = String(pageName).toLowerCase();
|
|
132
|
+
const exact = [];
|
|
133
|
+
for (const w of queryWords) {
|
|
134
|
+
const q = w.toLowerCase();
|
|
135
|
+
if (text.includes(q) || name.includes(q)) exact.push(w);
|
|
136
|
+
}
|
|
137
|
+
// 模糊:拿查询词的字符 2-gram 去页里找,重叠度足够就算关联
|
|
138
|
+
// 坑(2026-09-16 实测): 曾用 overlap/qGrams.size >= 0.5 —— 太松。
|
|
139
|
+
// 查"zzzz不存在"时 qGrams={zz,不存,存在}, 页里恰好有"存在" → ratio=0.5 误命中。
|
|
140
|
+
// 两个修正: (1) 重复字符的 gram 不算(zz 去重); (2) 至少得命中 2 个不同 gram。
|
|
141
|
+
const qGrams = new Set(queryWords.flatMap((w) => ngrams(String(w).toLowerCase())));
|
|
142
|
+
const pGrams = new Set(ngrams(text.slice(0, 4000)).concat(ngrams(name)));
|
|
143
|
+
let overlap = 0;
|
|
144
|
+
for (const g of qGrams) if (pGrams.has(g)) overlap++;
|
|
145
|
+
const ratio = qGrams.size ? overlap / qGrams.size : 0;
|
|
146
|
+
return { exact, overlap, ratio };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 拆字符 2-gram(中英文都适用)—— 模糊匹配的最小单位。
|
|
150
|
+
* 先过 [\p{L}\p{N}] 再切,单一字符组成的 gram(如 zz) 没区分度,丢掉。 */
|
|
151
|
+
function ngrams(s) {
|
|
152
|
+
const out = [];
|
|
153
|
+
const clean = s.replace(/[^\p{L}\p{N}]+/gu, '');
|
|
154
|
+
for (let i = 0; i + 2 <= clean.length; i++) {
|
|
155
|
+
const g = clean.slice(i, i + 2);
|
|
156
|
+
if (g[0] === g[1]) continue; // zz / 11 这类无信息量
|
|
157
|
+
out.push(g);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 模糊门槛 + 最小 gram 数(见 rankPage 注释) */
|
|
163
|
+
export const FUZZY_MIN = 0.6;
|
|
164
|
+
// 查询词去重后的 gram 数必须≥4:否则 "zzzz不存在" 只剩 [不存,存在] 两个 gram,
|
|
165
|
+
// 两个都在任意中文页里 → ratio=100% 误命中(实测)。分母够大才拉得动比例。
|
|
166
|
+
export const FUZZY_MIN_GRAMS = 4;
|
|
167
|
+
|
|
168
|
+
/** 从 frontmatter 取 tags 列表。无 frontmatter / 无 tags → []。 */
|
|
169
|
+
export function tagsOf(body) {
|
|
170
|
+
const m = String(body || '').match(/^tags:\s*(.+)$/m);
|
|
171
|
+
if (!m) return [];
|
|
172
|
+
return m[1]
|
|
173
|
+
.replace(/^\[|\]$/g, '')
|
|
174
|
+
.split(',')
|
|
175
|
+
.map((t) => t.trim())
|
|
176
|
+
.filter(Boolean);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 端到端检索:把一组查询词打成一页的分数。
|
|
181
|
+
* 返回 null = 不相关(既不精确命中,模糊重叠也不够)。
|
|
182
|
+
*
|
|
183
|
+
* 权重(tags 最高,2026-09-16 用户定):
|
|
184
|
+
* tags 命中 —— 人工提炼的关键词,最可靠 → 权重 8/个
|
|
185
|
+
* 标题命中 —— 页名往往就是主题 → 权重 4/个
|
|
186
|
+
* 正文命中 —— 可能出现但可能只是提一句 → 权重 1/个(封顶 3)
|
|
187
|
+
*
|
|
188
|
+
* 为何 tags 优先:实测现有页的 tags 写着大量人工词组("静默失效"、
|
|
189
|
+
* "双副本"、"陈旧路径"),而 firstHitLine 一直把 tags 行当噪音跳过 ——
|
|
190
|
+
* 等于把最准的检索信号丢掉了。
|
|
191
|
+
*/
|
|
192
|
+
export function rankPage(body, pageName, queryWords) {
|
|
193
|
+
const { exact, overlap, ratio } = matchPage(body, pageName, queryWords);
|
|
194
|
+
if (exact.length) {
|
|
195
|
+
const tags = tagsOf(body).map((t) => t.toLowerCase());
|
|
196
|
+
const name = String(pageName).toLowerCase();
|
|
197
|
+
const text = String(body).toLowerCase();
|
|
198
|
+
let score = 10 + exact.length * 5;
|
|
199
|
+
const via = { tag: [], title: [], body: [] };
|
|
200
|
+
for (const w of exact) {
|
|
201
|
+
const q = String(w).toLowerCase();
|
|
202
|
+
if (tags.some((t) => t === q || t.includes(q) || q.includes(t))) {
|
|
203
|
+
score += 8; via.tag.push(w);
|
|
204
|
+
} else if (name.includes(q)) {
|
|
205
|
+
score += 4; via.title.push(w);
|
|
206
|
+
} else if (text.includes(q)) {
|
|
207
|
+
score += 1; via.body.push(w);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { kind: 'exact', matched: exact, score, overlap, ratio, via };
|
|
211
|
+
}
|
|
212
|
+
const qGramCount = new Set(queryWords.flatMap((w) => ngrams(String(w).toLowerCase()))).size;
|
|
213
|
+
if (qGramCount >= FUZZY_MIN_GRAMS && ratio >= FUZZY_MIN)
|
|
214
|
+
return { kind: 'fuzzy', matched: [], score: ratio * 10, overlap, ratio, via: { tag: [], title: [], body: [] } };
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 取页正文的摘要:优先 frontmatter 的 description,退化到首个非标题行。 */
|
|
219
|
+
export function digest(body, maxLen = 200) {
|
|
220
|
+
const lines = String(body || '').split('\n');
|
|
221
|
+
const fmEnd = lines[0] === '---' ? lines.indexOf('---', 1) : -1;
|
|
222
|
+
if (fmEnd > 0) {
|
|
223
|
+
const d = lines.slice(1, fmEnd).find((l) => /^description\s*:/.test(l));
|
|
224
|
+
if (d) return d.replace(/^description\s*:\s*/, '').trim().slice(0, maxLen);
|
|
225
|
+
}
|
|
226
|
+
// 无 description 时跳过整个 frontmatter 区 —— 否则会把 'title: X' 当正文。
|
|
227
|
+
const start = fmEnd > 0 ? fmEnd + 1 : 0;
|
|
228
|
+
const first = lines
|
|
229
|
+
.slice(start)
|
|
230
|
+
.find((l) => l.trim() && !l.startsWith('#') && !l.startsWith('---'));
|
|
231
|
+
return (first || '').trim().slice(0, maxLen);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 渲染"该读的页"段。无命中返回空串(不占 load 体积)。 */
|
|
235
|
+
export function renderRelevant(picked, why = '') {
|
|
236
|
+
if (!picked.length) return '';
|
|
237
|
+
const out = [`--- 相关页(据当前改动/任务自动带出)${why ? ` [${why}]` : ''} ---`];
|
|
238
|
+
for (const p of picked) {
|
|
239
|
+
out.push(`[[${p.name}]] (${p.score}) — ${digest(p.body)}`);
|
|
240
|
+
out.push(` 命中: ${p.hit.slice(0, 8).join(' ')}`);
|
|
241
|
+
}
|
|
242
|
+
out.push('(这些页与你当前在做的事相关;不必再 query)');
|
|
243
|
+
return out.join('\n');
|
|
244
|
+
}
|