@fanchao8609/agent_brain_sync 1.9.1 → 1.9.3
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 +2 -48
- package/hooks/abs.pi.ts +44 -11
- package/hooks/event.sh +25 -8
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +1 -4
- package/src/relevant.js +50 -2
- package/src/store.js +27 -6
- package/skill/abs-bug-hunter/SKILL.md +0 -179
- package/src/serve.js +0 -506
package/bin/abs.js
CHANGED
|
@@ -91,13 +91,10 @@ const FLAG_SPEC = {
|
|
|
91
91
|
'repair': { type: 'boolean' },
|
|
92
92
|
'no-mcp': { type: 'boolean' },
|
|
93
93
|
'no-skill': { type: 'boolean' },
|
|
94
|
-
// serve 用: --no-open 不自动开浏览器(open 命令仅 macOS)。坑: 曾漏声明 → serve 判
|
|
95
|
-
// opts.open !== false 恒真, --no-open 接不上。
|
|
96
|
-
'no-open': { type: 'boolean' },
|
|
97
94
|
};
|
|
98
95
|
|
|
99
96
|
/** 已被"规范键"接管的 raw flag:不再原样漏出(见 parseArgv 返回处的白名单注释)。 */
|
|
100
|
-
const KNOWN_RAW = new Set(['keep-days', 'dry-run', 'no-mcp', 'no-skill'
|
|
97
|
+
const KNOWN_RAW = new Set(['keep-days', 'dry-run', 'no-mcp', 'no-skill']);
|
|
101
98
|
|
|
102
99
|
function parseArgv(args) {
|
|
103
100
|
// 坑: parseArgs 会把**任何** `-` 开头的 token 当选项,连正文一起吃:
|
|
@@ -170,7 +167,6 @@ function parseArgv(args) {
|
|
|
170
167
|
dryRun: !!values['dry-run'],
|
|
171
168
|
mcp: !values['no-mcp'],
|
|
172
169
|
skill: !values['no-skill'],
|
|
173
|
-
open: !values['no-open'],
|
|
174
170
|
};
|
|
175
171
|
// 未声明的 `--unknown` 静默收下(旧行为:不进位置参数,也不报错)。
|
|
176
172
|
// 这条保留是因为 rejectExtra 只看位置参数 —— 把未知 flag 放进去会改成报错,
|
|
@@ -200,7 +196,6 @@ const usage = `abs — 跨会话记忆工具(.brain/ 图谱)
|
|
|
200
196
|
supersede <页名> [--by 页] 标经验已失效
|
|
201
197
|
lint 图谱体检 (死链/超限)
|
|
202
198
|
rule [add "一句话"] 硬规则读写
|
|
203
|
-
serve [--port N] [--no-open] 浏览 .brain/ (仅本机; 端口默认自动选)
|
|
204
199
|
install|uninstall [--agent <宿主>] [--no-mcp] [--no-skill]
|
|
205
200
|
update 升级并刷新 hook/skill
|
|
206
201
|
config [set user <名字>] 使用者姓名 (写操作需先设置)
|
|
@@ -267,21 +262,6 @@ const subUsage = {
|
|
|
267
262
|
' • 只删 abs 自己装的东西,保留你其它的 hook / MCP / 配置字段',
|
|
268
263
|
' • 配置文件无法解析时会跳过该文件(不覆盖)但仍清理 abs 的脚本与 skill',
|
|
269
264
|
].join('\n'),
|
|
270
|
-
serve: [
|
|
271
|
-
'abs serve — 把 .brain/ 挂成只读网页(左目录树 + 右正文 + [[双链]]跳转)',
|
|
272
|
-
'',
|
|
273
|
-
'用法:',
|
|
274
|
-
' abs serve [--port N] [--no-open] [--dir <项目根>]',
|
|
275
|
-
'',
|
|
276
|
-
'参数:',
|
|
277
|
-
' --port N 端口。默认 0 = 自动选空闲端口(多项目可同时开各的)',
|
|
278
|
-
' --no-open 不自动打开浏览器(open 仅 macOS)',
|
|
279
|
-
' --dir <路径> 服务别的项目的 .brain/',
|
|
280
|
-
'',
|
|
281
|
-
'说明:',
|
|
282
|
-
' • 只读、仅绑定 127.0.0.1,外部访问不了',
|
|
283
|
-
' • 主题/明暗切换在网页右上角;最后一页会记住',
|
|
284
|
-
].join('\n'),
|
|
285
265
|
todo: [
|
|
286
266
|
'abs todo — 任务看板(Todo / Done 两区;未完成都带行首状态标记)',
|
|
287
267
|
'',
|
|
@@ -393,7 +373,7 @@ async function main() {
|
|
|
393
373
|
const opts = parseArgv(rest);
|
|
394
374
|
if (RENAMED[cmd]) throw new Error(RENAMED[cmd]());
|
|
395
375
|
// 子命令级 --help: 有用法页的命令在此一处拦截(此前只在 install/uninstall 分支里判,
|
|
396
|
-
// 其余命令的 --help 被忽略 ——
|
|
376
|
+
// 其余命令的 --help 被忽略 —— 曾出现子命令 --help 直接执行动作、挂住终端)。
|
|
397
377
|
if (opts.help && subUsage[cmd] && !(cmd === 'todo' && opts._.length)) {
|
|
398
378
|
console.log(subUsage[cmd]);
|
|
399
379
|
} else {
|
|
@@ -427,32 +407,6 @@ async function main() {
|
|
|
427
407
|
rejectExtra(opts._, 'abs status');
|
|
428
408
|
console.log(await cmdStatus({ dir: opts.dir }));
|
|
429
409
|
break;
|
|
430
|
-
// abs serve —— 把 .brain/ 挂成只读网页(浏览器无法自己列目录,所以必须有服务端)
|
|
431
|
-
case 'serve': {
|
|
432
|
-
rejectExtra(opts._, 'abs serve');
|
|
433
|
-
const { serve } = await import('../src/serve.js');
|
|
434
|
-
const { requireBrain, brainPath } = await import('../src/index.js');
|
|
435
|
-
// 坑(2026-09-16 实测): requireBrain 返回的是【项目根】, .brain/ 在它下面 ——
|
|
436
|
-
// 直接把项目根当服务根会扫到 node_modules。必须走 brainPath()。
|
|
437
|
-
const root = brainPath(await requireBrain(opts.dir || process.cwd()));
|
|
438
|
-
// 默认 0 = 让系统挑空闲端口:固定 7777 第二个项目就起不来了。
|
|
439
|
-
// --port abc 曾静默变 NaN → server listen 报怪错;在此拦下。
|
|
440
|
-
const port = opts.port ? Number(opts.port) : 0;
|
|
441
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
442
|
-
throw new Error(`✗ --port 需为 0-65535 的整数(收到 "${opts.port}")。0 = 自动选空闲端口`);
|
|
443
|
-
}
|
|
444
|
-
// --no-open 显式声明在 FLAG_SPEC(type:boolean),parseArgv 归一化为 open:false。
|
|
445
|
-
// 坑(2026-09-16 审计#6): 曾判 opts.open !== false 但 FLAG_SPEC 无 open →
|
|
446
|
-
// 条件恒真且 --no-open 接不上(open 命令也仅 macOS)。
|
|
447
|
-
const { url, server } = await serve({ root, port });
|
|
448
|
-
console.log(`📖 abs serve → ${url}`);
|
|
449
|
-
console.log(` 根: ${root}`);
|
|
450
|
-
console.log(' (只读,仅本机可访;Ctrl+C 停止)');
|
|
451
|
-
if (opts.open) runCmd('open', [url]);
|
|
452
|
-
// 不断开进程:服务要活着才有用
|
|
453
|
-
await new Promise(() => {});
|
|
454
|
-
break;
|
|
455
|
-
}
|
|
456
410
|
// abs todo —— 无子命令=看板;带子命令=任务写操作
|
|
457
411
|
case 'todo': {
|
|
458
412
|
const [sub, id, ...rest2] = opts._;
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -115,16 +115,38 @@ async function loggedToday(brain: string): Promise<boolean> {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
/** 收尾注入的节流状态。
|
|
119
|
+
*
|
|
120
|
+
* 为何在**模块级**而不在 absPiHook() 里(2026-09-17 实测):扩展会被重复注册
|
|
121
|
+
* (session_start 11 分钟内触发 8 次),每次注册都新建一份闭包 → flag 不共享 →
|
|
122
|
+
* 实测同会话 5 分钟注入 6 次。模块级状态在同一进程内只有一份,注册多少次都共享。
|
|
123
|
+
*
|
|
124
|
+
* teardownNudged = 本会话已注入过(终态,session_start 才重置)
|
|
125
|
+
* teardownInFlight = 正在判定中(抢在 await 之前置位,堵住并发重入)
|
|
126
|
+
* agentEndSeen = 本会话已留过 seen 痕(可观测性,同样每会话一次)
|
|
127
|
+
*/
|
|
128
|
+
let teardownNudged = false
|
|
129
|
+
let teardownInFlight = false
|
|
130
|
+
let agentEndSeen = false
|
|
131
|
+
|
|
132
|
+
/** 会话边界重置节流(session_start 与注册时都调)。
|
|
133
|
+
* 不重置 → 同进程第二个会话继承 true 永久静默(2026-09-13 实测过的坑)。 */
|
|
134
|
+
function resetThrottle(): void {
|
|
135
|
+
teardownNudged = false
|
|
136
|
+
teardownInFlight = false
|
|
137
|
+
agentEndSeen = false
|
|
138
|
+
}
|
|
139
|
+
|
|
118
140
|
export default function absPiHook(pi: ExtensionAPI): void {
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// (2026-09-
|
|
122
|
-
|
|
123
|
-
|
|
141
|
+
// 收尾注入的节流状态。故意声明在**模块级**(不在被反复调用的工厂函数里),
|
|
142
|
+
// 因为**扩展会被重复注册**:实测 session_start 在 11 分钟内触发 8 次
|
|
143
|
+
// (2026-09-17 00:07:58 一口气 3 次)。注册几份就得到几份独立闭包,
|
|
144
|
+
// 各自的 flag 彼此看不见 → “每会话一次”变成“每实例一次” → 实测同会话 5 分钟注入 6 次。
|
|
145
|
+
// 模块级状态在同一进程内共享,注册多少次都只有一份。
|
|
146
|
+
resetThrottle()
|
|
124
147
|
|
|
125
148
|
pi.on("session_start", (event: any, _ctx: any) => {
|
|
126
|
-
|
|
127
|
-
agentEndSeen = false
|
|
149
|
+
resetThrottle()
|
|
128
150
|
return logHook("session_start").catch(() => {})
|
|
129
151
|
})
|
|
130
152
|
|
|
@@ -152,8 +174,18 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
152
174
|
})
|
|
153
175
|
|
|
154
176
|
// 收尾注入: 每个会话最多一次, 避免反复打扰。
|
|
177
|
+
//
|
|
178
|
+
// 坑(2026-09-17 实测定根因):原实现是「先检查 flag → 若干 await → 才置位」,
|
|
179
|
+
// 而 agent_end **每轮 run 结束都触发**(官方 docs/extensions.md:569:它不等于会话结束,
|
|
180
|
+
// Pi 之后还可能继续跑 follow-up)。并发的多次调用因此**全部先通过 `if (nudged) return`**,
|
|
181
|
+
// 之后才各自置位 → 一起注入。实测同会话 5 分钟内注入 6 次,全日志 55 次。
|
|
182
|
+
// 修法:进函数**第一个动作**就抢占(置位在任何 await 之前),失败再把标志放回。
|
|
183
|
+
// 这样“检查+设置”之间没有让出点,并发调用只有一个能赢。
|
|
155
184
|
pi.on("agent_end", async (event: any, ctx: any) => {
|
|
156
|
-
if (teardownNudged) return
|
|
185
|
+
if (teardownNudged || teardownInFlight) return
|
|
186
|
+
teardownInFlight = true
|
|
187
|
+
// 从这里往下任何提前 return 都必须解除 in-flight(否则本次会话永久不再提醒)。
|
|
188
|
+
const release = () => { teardownInFlight = false }
|
|
157
189
|
const cwd = (ctx && ctx.cwd) || process.cwd()
|
|
158
190
|
const brain = await findBrain(cwd)
|
|
159
191
|
// 可观测性: 本会话首次 agent_end 无条件留一行痕。
|
|
@@ -163,14 +195,15 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
163
195
|
// 带上 cwd 与命中的 brain —— 否则事后无法解释“为何这条 nudge 会出现”
|
|
164
196
|
await logHook(`agent_end:seen cwd=${cwd} brain=${brain || 'none'}`).catch(() => {})
|
|
165
197
|
}
|
|
166
|
-
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
167
|
-
if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
|
|
198
|
+
if (!brain) return release() // 无图谱=不在这项目沉淀, 不打扰
|
|
199
|
+
if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return release()
|
|
168
200
|
// 今日已收尾 → 只在**本会话还没真正干事**时才静默。
|
|
169
201
|
// 旧行为:今天 log 有一行就整天闭口 —— 于是收尾之后的产出全部没人提醒。
|
|
170
202
|
// 注意不能用 sessionNotes.length 当判据:用户每说一句话就会 push 一条,
|
|
171
203
|
// 那会让 nudge 每轮都触发(噪音)。判据保持「今天已收尾」但配合下面的笔记消费。
|
|
172
|
-
if (await loggedToday(brain)) return
|
|
204
|
+
if (await loggedToday(brain)) return release()
|
|
173
205
|
teardownNudged = true
|
|
206
|
+
teardownInFlight = false
|
|
174
207
|
await logHook("agent_end:teardown-nudge").catch(() => {})
|
|
175
208
|
// 素材交给 AI 后清空:同一会话再触发时不该重复喂旧料。
|
|
176
209
|
const notes = sessionNotes.splice(0, sessionNotes.length)
|
package/hooks/event.sh
CHANGED
|
@@ -31,19 +31,36 @@ fi
|
|
|
31
31
|
(
|
|
32
32
|
# 幂等: 同一 payload 指纹在 60s 内只落一行 (防重复触发)。
|
|
33
33
|
# mark 目录可经 ABS_MARK_DIR 覆盖(默认 /tmp)——测试注入沙盒目录隔离, 避免与真实/并发残留互扰。
|
|
34
|
+
#
|
|
35
|
+
# 坑(2026-09-17 实测定根因):原实现把 mark 名钉在 STAMP=$(date +%Y%m%d%H%M)(分钟级)上,
|
|
36
|
+
# 而文档/测试约定的是【60s 窗口】。两者不等价:两次调用只要**跨过分钟边界**,
|
|
37
|
+
# STAMP 不同 → mark 路径不同 → [ -e ] 不命中 → 各写一行,实际窗口最坏缩到 1 秒。
|
|
38
|
+
# 实测复现(同 payload、STAMP 从 2359 跳到 0000)→ 落 2 行,违反 60s 承诺。
|
|
39
|
+
# 也是 test/hook.test.js「同一 payload 60s 内幂等」偶发失败的真因(不是测试写得不好)。
|
|
40
|
+
#
|
|
41
|
+
# 修法:mark 名**只含指纹**(与时间无关),幂等判据改看**写在 mark 里的时间戳**。
|
|
42
|
+
# 为何不靠 find -newermt:BSD find(macOS) 不认 `@epoch` 格式(实测报
|
|
43
|
+
# `Can't parse date/time: @1789619554`)→ 守卫恒不命中 → 幂等彻底失效。
|
|
44
|
+
# 也不用 -mmin:只能到分钟级,正是原 bug 的同类误差。
|
|
45
|
+
# 把时间戳写进文件、用 shell 纯数字比 —— 不依赖任何平台工具的日期解析。
|
|
34
46
|
FINGER=$(printf '%s' "$PAYLOAD" | cksum | cut -d' ' -f1)
|
|
35
|
-
STAMP=$(date +%Y%m%d%H%M)
|
|
36
47
|
MARK_DIR="${ABS_MARK_DIR:-/tmp}"
|
|
37
48
|
mkdir -p "$MARK_DIR" 2>/dev/null
|
|
38
|
-
MARK="$MARK_DIR/abs-hook-${FINGER}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
|
|
49
|
+
MARK="$MARK_DIR/abs-hook-${FINGER}.mark"
|
|
50
|
+
NOW=$(date +%s)
|
|
51
|
+
PREV=$(cat "$MARK" 2>/dev/null)
|
|
52
|
+
case "$PREV" in ''|*[!0-9]*) PREV=0 ;; esac
|
|
53
|
+
# 60s 内已记过 → 幂等退出。窗口是真 60 秒,与分钟边界无关。
|
|
54
|
+
[ "$PREV" -gt 0 ] && [ "$((NOW - PREV))" -lt 60 ] && exit 0
|
|
55
|
+
printf '%s\n' "$NOW" > "$MARK" 2>/dev/null
|
|
56
|
+
# 清理: 只删**超龄**(>60s)的 mark。
|
|
57
|
+
# 不按分钟批量删 —— 那会在跨分钟时误删刚写的 mark(旧 bug 的帮凶)。
|
|
44
58
|
for old in "$MARK_DIR"/abs-hook-*.mark; do
|
|
45
59
|
[ -e "$old" ] || continue
|
|
46
|
-
|
|
60
|
+
[ "$old" = "$MARK" ] && continue
|
|
61
|
+
OV=$(cat "$old" 2>/dev/null)
|
|
62
|
+
case "$OV" in ''|*[!0-9]*) OV=0 ;; esac
|
|
63
|
+
[ "$OV" -gt 0 ] && [ "$((NOW - OV))" -lt 60 ] && continue
|
|
47
64
|
rm -f "$old" 2>/dev/null
|
|
48
65
|
done
|
|
49
66
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: abs-agent-brain-sync
|
|
3
|
-
description: abs (agent-brain-sync) 跨会话 AI 编码记忆与任务续接。开场续接状态(abs load/MCP abs_load),干活中任务/经验实时落盘(abs_task/abs_note)
|
|
3
|
+
description: abs (agent-brain-sync) 跨会话 AI 编码记忆与任务续接。开场续接状态(abs load/MCP abs_load),干活中任务/经验实时落盘(abs_task/abs_note),需要收尾时才走收尾循环。解决会话无状态:经验/进度/坑碎片化、重开失忆。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# abs — 跨会话记忆 (agent-brain-sync)
|
|
@@ -389,6 +389,3 @@ abs supersede <页名> --by <取代它的新页> # 不写 --by 也行 = 单纯
|
|
|
389
389
|
- 只读写 `.brain/` 与目标代码,不动全局配置(一次性接入除外)。
|
|
390
390
|
- 只写真实发生的事实;遵守容量纪律,宁缺毋滥。
|
|
391
391
|
- 双链/frontmatter/index 必须自洽 —— 坏链 = 掰断接力棒。
|
|
392
|
-
- **排查 bug 时挂载 `bug-hunter` skill**(随 abs 一并安装,安装名为 `abs-bug-hunter`,位于 ~/.pi/agent/skills/abs-bug-hunter/ 等宿主对应目录):
|
|
393
|
-
完整排查流程(列假设 → 复现 → 定位改动 → 打印 → 顺藤摸瓜 → 根因 → 验证)
|
|
394
|
-
与「给结论要明确」都在那份里。**排查方法不写在本文件**(两份维护必漂)。
|
package/src/relevant.js
CHANGED
|
@@ -159,6 +159,22 @@ function ngrams(s) {
|
|
|
159
159
|
return out;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** 是否走 2-gram 模糊兜底。
|
|
163
|
+
*
|
|
164
|
+
* 只对【含中文】的查询词生效(2026-09-17 实测根因)。
|
|
165
|
+
* 为何要这道门:字符 2-gram 是给中文设计的("并发写"→"互斥"),中文 gram 有区分度;
|
|
166
|
+
* 英文没有 —— "relevant" 的 7 个 gram(re,el,ev,va,an,nt) 在**任意**英文页里都存在,
|
|
167
|
+
* ratio 恒为 1.0,稳过 FUZZY_MIN=0.6 → 每页都命中。
|
|
168
|
+
* 实测(44 页图谱):query relevant → 30 命中全是 fuzzy score=10,而 "relevant" 在
|
|
169
|
+
* 页面里出现 0 次;query store → 42/44 命中。这些命中是巧合子串,不是相关。
|
|
170
|
+
* 收紧后同一图谱:relevant 30→0、store 42→16(剩下的 16 是真子串命中)。
|
|
171
|
+
* 反证该门不误伤:20 个真实中文查询(并发写/静默失效/陈旧路径…)实测 fuzzy 命中全为 0,
|
|
172
|
+
* 全走精确路径 —— 收紧后逐字不变。
|
|
173
|
+
*/
|
|
174
|
+
export function hasCJK(s) {
|
|
175
|
+
return /[\u4e00-\u9fa5]/.test(String(s || ''));
|
|
176
|
+
}
|
|
177
|
+
|
|
162
178
|
/** 模糊门槛 + 最小 gram 数(见 rankPage 注释) */
|
|
163
179
|
export const FUZZY_MIN = 0.6;
|
|
164
180
|
// 查询词去重后的 gram 数必须≥4:否则 "zzzz不存在" 只剩 [不存,存在] 两个 gram,
|
|
@@ -209,12 +225,44 @@ export function rankPage(body, pageName, queryWords) {
|
|
|
209
225
|
}
|
|
210
226
|
return { kind: 'exact', matched: exact, score, overlap, ratio, via };
|
|
211
227
|
}
|
|
212
|
-
const
|
|
213
|
-
|
|
228
|
+
const fuzzyWords = queryWords.filter(hasCJK);
|
|
229
|
+
const qGramCount = new Set(fuzzyWords.flatMap((w) => ngrams(String(w).toLowerCase()))).size;
|
|
230
|
+
if (fuzzyWords.length && qGramCount >= FUZZY_MIN_GRAMS && ratio >= FUZZY_MIN)
|
|
214
231
|
return { kind: 'fuzzy', matched: [], score: ratio * 10, overlap, ratio, via: { tag: [], title: [], body: [] } };
|
|
215
232
|
return null;
|
|
216
233
|
}
|
|
217
234
|
|
|
235
|
+
/**
|
|
236
|
+
* 给候选词按「主题级强度」打分,供 hint 挑词用。
|
|
237
|
+
*
|
|
238
|
+
* 为何需要(2026-09-17 实测):hint 原取词是 `keywords()` 保序 + `slice(0,2)` ——
|
|
239
|
+
* 拿的是**最前面**的两个词,没有任何质量信号。而 todo 行以
|
|
240
|
+
* `- [ ] [进行中] <任务id> [[作者]] — 描述` 开头,于是任务 id 和作者名
|
|
241
|
+
* 稳定占据前两位,提示语变成 `abs query queryhint-noise tester 进行`。
|
|
242
|
+
* 实测这三个词的强度全为 0~1(noise 0 命中 / 进行 1 命中),等于没提示。
|
|
243
|
+
*
|
|
244
|
+
* 尺子 = tag / 页名级命中数(rankPage 的 via.tag / via.title)。
|
|
245
|
+
* 为何不用「正文命中数」:正文子串命中在 44 页图谱上随口一词就有十几页
|
|
246
|
+
* (实测 store 16、覆盖 18),不区分好坏;tag/页名是人工提炼的主题信号。
|
|
247
|
+
* 实测分离度:hook 6 / todo 4 / lock 2 vs queryhint-noise 0 / 进行 0。
|
|
248
|
+
*
|
|
249
|
+
* @param {{name:string,body:string}[]} pages
|
|
250
|
+
* @param {string[]} words
|
|
251
|
+
* @returns {Map<string, number>} 词 → 主题级命中数
|
|
252
|
+
*/
|
|
253
|
+
export function topicStrength(pages, words) {
|
|
254
|
+
const out = new Map();
|
|
255
|
+
for (const w of words) {
|
|
256
|
+
let n = 0;
|
|
257
|
+
for (const p of pages) {
|
|
258
|
+
const r = rankPage(p.body, p.name, [w]);
|
|
259
|
+
if (r && (r.via.tag.length || r.via.title.length)) n++;
|
|
260
|
+
}
|
|
261
|
+
out.set(w, n);
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
218
266
|
/** 取页正文的摘要:优先 frontmatter 的 description,退化到首个非标题行。 */
|
|
219
267
|
export function digest(body, maxLen = 200) {
|
|
220
268
|
const lines = String(body || '').split('\n');
|
package/src/store.js
CHANGED
|
@@ -7,7 +7,7 @@ import { requireUser, atTag, getUser } from './userconfig.js';
|
|
|
7
7
|
import { stripStateMark, ensureStateMark, normalizeTodo, addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure } from './todo.js';
|
|
8
8
|
import { editFile, SKIP } from './lock.js';
|
|
9
9
|
import { appendWrapup, strandedFor } from './wrapup.js';
|
|
10
|
-
import { keywords, pickRelevant, renderRelevant, recentFiles, rankPage } from './relevant.js';
|
|
10
|
+
import { keywords, pickRelevant, renderRelevant, recentFiles, rankPage, topicStrength } from './relevant.js';
|
|
11
11
|
|
|
12
12
|
// ---------- init: 建 .brain/ 骨架 ----------
|
|
13
13
|
const BRAIN_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
|
|
@@ -400,19 +400,40 @@ export function extractBlocks(todoText) {
|
|
|
400
400
|
* 且无法按需求变化调整词。正确做法是把词准备好,查询仍由调用方发。
|
|
401
401
|
*
|
|
402
402
|
* 词从两个环境事实推:活跃 todo 的关键词 + 最近改过的文件名。
|
|
403
|
-
*
|
|
403
|
+
*
|
|
404
|
+
* 挑词用【主题级强度】排序,不取词表前几个(2026-09-17 实测修正):
|
|
405
|
+
* 原实现 `pick = [...en.slice(0,2), ...zh.slice(0,1)]` 取的是最前面的词,
|
|
406
|
+
* 而 todo 行以 `<任务id> [[作者]]` 开头 → 任务 id 与用户名稳占前两位,
|
|
407
|
+
* 实测输出 `abs query queryhint-noise tester 进行`(强度 0/1/0,全是废词)。
|
|
408
|
+
* 现按 topicStrength(tag/页名命中数)降序取,强度 0 的一律不提示
|
|
409
|
+
* —— 命中不了任何页的词,建议去查它等于没建议。
|
|
410
|
+
* 失败静默:读页出错就当没有强词,退回不提示(不影响 load)。
|
|
411
|
+
*
|
|
412
|
+
* @param {string} root 项目根
|
|
413
|
+
* @param {string} todoText
|
|
414
|
+
* @param {{name:string,body:string}[]} [pages] 已读好的页(省一次磁盘扫描)
|
|
404
415
|
*/
|
|
405
|
-
async function queryHint(root, todoText) {
|
|
416
|
+
async function queryHint(root, todoText, pages) {
|
|
406
417
|
const active = String(todoText || '')
|
|
407
418
|
.split('\n')
|
|
408
419
|
.filter((l) => /^\s*-\s*\[\s*\]/.test(l))
|
|
420
|
+
// 剔掉任务行的结构记号再分词:`- [ ] [状态] <id> [[作者]] —` 里只有「—」后面是真内容。
|
|
421
|
+
// 实测教训(2026-09-17):不剔的话 `<id>` 与 `[[作者]]` 会进入词表,
|
|
422
|
+
// 而作者名恰好有自己的 entities/<作者>.md 页 → 命中**页名** → 强度 1 过了门槛,
|
|
423
|
+
// 于是提示语变成「查一下你自己的名字」。建议查作者页不是“别重踩的经验”。
|
|
424
|
+
// 用 task 行自身的形状剥(与 idOfTaskLine 同源),不维护人名/任务名黑名单。
|
|
425
|
+
.map((l) => l.replace(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(?:\S+\s*)?/, '').replace(/\[\[[^\]]*\]\]/g, ' '))
|
|
409
426
|
.join(' ');
|
|
410
427
|
const files = await recentFiles(root, { n: 5 }).catch(() => []);
|
|
411
428
|
const src = [active, files.map((f) => f.name).join(' ')].filter(Boolean).join(' ');
|
|
412
429
|
const kws = keywords(src);
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
const
|
|
430
|
+
if (!kws.length) return '';
|
|
431
|
+
const all = pages || await listPages(brainPath(root)).catch(() => []);
|
|
432
|
+
const strength = topicStrength(all.map((p) => ({ name: p.slug, body: p.body })), kws);
|
|
433
|
+
// 英文词优先(中文 2-gram 单看无意义,只做补充);每组内按强度降序
|
|
434
|
+
const byStrength = (a, b) => (strength.get(b) || 0) - (strength.get(a) || 0) || a.localeCompare(b);
|
|
435
|
+
const en = kws.filter((k) => /^[a-z][a-z0-9_-]+$/.test(k)).sort(byStrength).filter((k) => strength.get(k) > 0);
|
|
436
|
+
const zh = kws.filter((k) => !/^[a-z][a-z0-9_-]+$/.test(k)).sort(byStrength).filter((k) => strength.get(k) > 0);
|
|
416
437
|
const pick = [...en.slice(0, 2), ...zh.slice(0, 1)].slice(0, 3);
|
|
417
438
|
if (!pick.length) return '';
|
|
418
439
|
const rows = [
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: abs-bug-hunter
|
|
3
|
-
description: 排查 bug 的流程纪律 —— 先列假设再动手,用数据定位,修根因。排查完把踩坑写进 .brain/。
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Bug 排查方法论
|
|
7
|
-
|
|
8
|
-
## 核心原则
|
|
9
|
-
|
|
10
|
-
**不凭空猜测,用数据说话。但数据不是终点 —— 先复现,再定位,再理解根因。**
|
|
11
|
-
|
|
12
|
-
最常见的失败:把"能复现"当成"已定位"。定位到出错的**位置** ≠ 找到**根因**。
|
|
13
|
-
改症状不改根因,换一批数据就复发。
|
|
14
|
-
|
|
15
|
-
## 第 -1 步:列假设(不能跳过)
|
|
16
|
-
|
|
17
|
-
**触发**:用户报了症状("打不开""报错了""不对"),但没给一手错误信息。
|
|
18
|
-
|
|
19
|
-
**这一步在"复现"之前**,因为拿不到报错时,"先复现"会逼你造假。
|
|
20
|
-
|
|
21
|
-
### 自造证据 —— 最毒的一类错误
|
|
22
|
-
|
|
23
|
-
**真实案例**:只有一个假设时,它要么被证实要么思路断掉 —— 于是自己造一个输入去测:
|
|
24
|
-
|
|
25
|
-
```
|
|
26
|
-
编了个接口名 app.init → curl 测出 404 → "我测出来的,是事实"
|
|
27
|
-
→ 推出"nginx 没配 PATH_INFO"
|
|
28
|
-
→ 用户纠正后,重跑同一实验、得到同一 404 → 坚信自己没错
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
**为什么最毒**:下游全程都是真的(404 真、命令真跑过)。唯一假的那环在**最上游**,
|
|
32
|
-
且已消失在过程里 → 所以"再验证一次"救不了,重跑同假输入得同真输出。
|
|
33
|
-
|
|
34
|
-
### 所以
|
|
35
|
-
|
|
36
|
-
1. **开局列 ≥2 个假设**(代码 / 配置 / 构建产物 / 缓存 / 第三方)。
|
|
37
|
-
只列一个 = 逼自己去证实它 = 造证据的动力。
|
|
38
|
-
2. **每个假设配反证条件**:什么现象出现就认它错。
|
|
39
|
-
反证条件里要写明**能从代码里读到的真实名字**,编造的自动不能用。
|
|
40
|
-
3. **卡住两轮就换假设**,不是在同一假设上加细节。
|
|
41
|
-
4. **领域外的先声明无取证能力**(构建产物在不在 / 缓存 / 第三方后台行为),
|
|
42
|
-
要用户提供事实,**不要脑补**。
|
|
43
|
-
|
|
44
|
-
**结论所依据的每个具体名字(接口/字段/报错文本/路径)必须指得出处(文件:行)。**
|
|
45
|
-
指不出的只能标"推测",**不得当证据用**。
|
|
46
|
-
|
|
47
|
-
> **为什么多假设能防造假**:单假设必须被证实,否则思路断 → 有动机造。
|
|
48
|
-
> 多假设下,"支持了 A" ≠ "就是 A"(B/C/D 还没排除)→ 假证据推不出结论,造它就没意义。
|
|
49
|
-
|
|
50
|
-
## 第 0 步:复现
|
|
51
|
-
|
|
52
|
-
没有稳定复现,后面全是猜。
|
|
53
|
-
|
|
54
|
-
- 复现的必要条件是什么?哪个输入/操作/环境触发?
|
|
55
|
-
- 100% 复现还是偶发?偶发先找触发模式(特定数据/时机/顺序)。
|
|
56
|
-
- 有现成错误栈吗?**错误信息 + 行号是最便宜的定位入口。**
|
|
57
|
-
|
|
58
|
-
**给 Bug 起个名字**(一句话描述 + 触发条件)。卡住时回来核对是否还在同一个 Bug 上。
|
|
59
|
-
|
|
60
|
-
## 第一步:定位范围 —— 改动了什么
|
|
61
|
-
|
|
62
|
-
Bug 不会凭空出现。先查最近的改动:
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
git diff HEAD~3 --stat # 改了哪些文件
|
|
66
|
-
git log -p -S '可疑字段/函数名' # 谁改过这段
|
|
67
|
-
git blame <file> -L <行号,行号> # 定位到具体某行
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
问自己:这次新增/修改了什么?涉及哪些文件?哪个最可能影响出问题的地方?
|
|
71
|
-
|
|
72
|
-
**别排查无关代码。**
|
|
73
|
-
|
|
74
|
-
## 第二步:让数据说话
|
|
75
|
-
|
|
76
|
-
不猜,打印出来看。
|
|
77
|
-
|
|
78
|
-
- **打印关键数据,不要只打印"到了这里"** —— 要打印实际的变量值、类型、入参出参。
|
|
79
|
-
- **二分法**:在可疑链路上隔几层插日志,先跑一遍看哪段有数据、哪段没有,
|
|
80
|
-
范围砍半再往里加。比一次打满所有层更快收敛。
|
|
81
|
-
- **带唯一前缀**(如 `[BUG-01]`):日志里 grep 一次看到全链路顺序。
|
|
82
|
-
- 怀疑并发/时序时,打时间戳与调用来源。
|
|
83
|
-
|
|
84
|
-
## 第三步:顺藤摸瓜
|
|
85
|
-
|
|
86
|
-
从用户操作出发,沿调用链一层层往下:入口 → 路由 → 中间件 → 业务层 → 数据层 → 存储。
|
|
87
|
-
|
|
88
|
-
**每一层打印关键数据,找到数据从正确变为错误的那一层 —— bug 就在那一层。**
|
|
89
|
-
|
|
90
|
-
## 第四步:找根因,不只修症状
|
|
91
|
-
|
|
92
|
-
修复前回答:
|
|
93
|
-
|
|
94
|
-
- 为什么数据在这一层变错了?**逻辑错误**(判断写反/取错字段)、
|
|
95
|
-
**类型错误**(null/数组/对象混用)、还是**数据本身脏**(上游写入时就错)?
|
|
96
|
-
- 根因若在上游,这里 patch 只是挡一下 —— 应该去修上游,或在入口统一兜底。
|
|
97
|
-
|
|
98
|
-
**修一层,不改所有调用点**:如果多个地方调用同一函数,只在出问题的调用点打补丁,
|
|
99
|
-
其他调用点照样坏。**在共享函数里修一次,是所有调用点的最小修复。**
|
|
100
|
-
|
|
101
|
-
## 第五步:验证修复
|
|
102
|
-
|
|
103
|
-
不要凭空验证。**先拿第 0 步的复现条件重跑,必须看到它失败** ——
|
|
104
|
-
没失败就说明你修好了但没复现过,或者复现条件记错了。
|
|
105
|
-
不先看到 fail,就无法区分"修好了"和"根本没坏过"。
|
|
106
|
-
改完再看它变 pass,三层都要过:
|
|
107
|
-
|
|
108
|
-
| 层 | 查什么 |
|
|
109
|
-
|---|---|
|
|
110
|
-
| **修好了** | 复现路径不再报错,输出正确 |
|
|
111
|
-
| **没修坏** | 相关正常路径仍正常(回归) |
|
|
112
|
-
| **边界还在** | 边缘输入(空值/超大值/并发/重复提交)没引入新洞 |
|
|
113
|
-
|
|
114
|
-
确认无误后再删调试日志。
|
|
115
|
-
|
|
116
|
-
## 排查完:写进 .brain/
|
|
117
|
-
|
|
118
|
-
**这一步是 `abs-` 前缀的意义 —— 排查的结论不写下来,下个会话会重踩同一个坑。**
|
|
119
|
-
|
|
120
|
-
```bash
|
|
121
|
-
abs note "一句话结论" --when "什么时候该看这条"
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
值得写的(**能从代码 grep 到的不写**):
|
|
125
|
-
|
|
126
|
-
| 写什么 | 例 |
|
|
127
|
-
|---|---|
|
|
128
|
-
| **假象的根因** | "报错位置是 A,真因在 B 的写入侧" |
|
|
129
|
-
| **判据** | "统计硬切率 >30% 即确诊,别去调 prompt" |
|
|
130
|
-
| **反直觉处** | "慢的不是读写,是 node 启动" |
|
|
131
|
-
| **排查路径** | "先查写入侧,别先怪模型" |
|
|
132
|
-
|
|
133
|
-
不值得写的:报错原文(日志里有)、修好的代码(git 里有)、通用常识。
|
|
134
|
-
|
|
135
|
-
**如果是反复踩的坑**,排查结束后提成硬规则:
|
|
136
|
-
|
|
137
|
-
```bash
|
|
138
|
-
abs rule add "靠提醒才能工作的功能,该删不该补"
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
## 常见陷阱
|
|
142
|
-
|
|
143
|
-
| 陷阱 | 正确做法 |
|
|
144
|
-
|------|----------|
|
|
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
|
-
## 收尾:结论要明确
|
|
167
|
-
|
|
168
|
-
**不许**:"可能是 X,也可能是 Y,建议排查一下" —— 这是把判断推回给用户。
|
|
169
|
-
|
|
170
|
-
**必须**给出:
|
|
171
|
-
|
|
172
|
-
1. **当前结论**(是什么 / 或"未定位")
|
|
173
|
-
2. **置信度与依据**(哪来的证据 / 还是只是推测)
|
|
174
|
-
3. **下一步具体动作**(跑什么、看什么、要用户提供什么)
|
|
175
|
-
|
|
176
|
-
"未定位"是合法输出,但必须配一句"**需要你提供 X**"(具体到要什么),
|
|
177
|
-
不能只说"无法确定"就结束。
|
|
178
|
-
|
|
179
|
-
> 与第 -1 步同一根因:**模糊化是逃避判断。** 要么给结论 + 依据,要么明确索取证据。
|
package/src/serve.js
DELETED
|
@@ -1,506 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* abs serve —— 把 .brain/ 挂成一个只读网页。
|
|
3
|
-
*
|
|
4
|
-
* 为什么需要 http 服务(而不是一个能双击打开的 html):
|
|
5
|
-
* 浏览器不允许 JS 列出本地目录,file:// 下 fetch 也被 CORS 禁。
|
|
6
|
-
* 要"自动列出文件夹里的 md",就必须有一个能读目录的东西 —— 服务端。
|
|
7
|
-
* 这是浏览器安全模型决定的,不是实现选择。
|
|
8
|
-
*
|
|
9
|
-
* 渲染与样式全部走 CDN(marked 转 md、highlight.js 高亮)——
|
|
10
|
-
* 不自己写解析器、不内嵌资源,页面本体只有布局和交互。
|
|
11
|
-
* CDN 用 lib.baomitu.com(360 源,国内可达)。
|
|
12
|
-
*
|
|
13
|
-
* 边界(刻意收窄):
|
|
14
|
-
* · 只绑 127.0.0.1 —— 不对外网开放
|
|
15
|
-
* · 只读 —— 没有任何写接口;图谱仍由 CLI 改
|
|
16
|
-
* · 只服务 .brain/ 内部 —— 路径穿越被拒
|
|
17
|
-
* · 零依赖 —— 只用 node 内置 http/fs;前端库由浏览器从 CDN 取
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { promises as fs } from 'node:fs';
|
|
21
|
-
import { join, normalize, extname, relative, sep } from 'node:path';
|
|
22
|
-
import http from 'node:http';
|
|
23
|
-
|
|
24
|
-
const MIME = {
|
|
25
|
-
'.md': 'text/markdown; charset=utf-8',
|
|
26
|
-
'.json': 'application/json; charset=utf-8',
|
|
27
|
-
'.html': 'text/html; charset=utf-8',
|
|
28
|
-
'.txt': 'text/plain; charset=utf-8',
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/** CDN 基址。换源只改这一行。 */
|
|
32
|
-
const CDN = 'https://lib.baomitu.com';
|
|
33
|
-
|
|
34
|
-
/** 递归收集 .brain/ 下的 md(相对 root 的路径)。 */
|
|
35
|
-
/**
|
|
36
|
-
* 组名展示映射(2026-09-16 用户定):
|
|
37
|
-
* · 根组 → 用【项目目录名】(.brain/ 的父目录),不看泛的"根"
|
|
38
|
-
* · 标准子目录 → 中文名,网页上比英文目录名直观
|
|
39
|
-
* 未列出的目录用原名(用户自建的目录不该被改名)。
|
|
40
|
-
*/
|
|
41
|
-
export const GROUP_NAMES = {
|
|
42
|
-
concepts: '概念', entities: '实体', syntheses: '综合',
|
|
43
|
-
sources: '来源', sessions: '会话',
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
/** 把内部组 key 映射成展示名。rootLabel = 根组的显示名。 */
|
|
47
|
-
export function displayGroup(key, rootLabel) {
|
|
48
|
-
if (key === ROOT_KEY) return rootLabel;
|
|
49
|
-
return GROUP_NAMES[key] || key;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** 内部约定:根组固定用这个 key 参与排序,展示时再换成目录名。 */
|
|
53
|
-
export const ROOT_KEY = '__root__';
|
|
54
|
-
|
|
55
|
-
export async function scanTree(root, { maxDepth = 3 } = {}) {
|
|
56
|
-
const groups = {};
|
|
57
|
-
async function walk(dir, depth) {
|
|
58
|
-
if (depth > maxDepth) return;
|
|
59
|
-
let ents;
|
|
60
|
-
try { ents = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
61
|
-
for (const e of ents) {
|
|
62
|
-
if (e.name.startsWith('.')) continue;
|
|
63
|
-
const p = join(dir, e.name);
|
|
64
|
-
if (e.isDirectory()) { await walk(p, depth + 1); continue; }
|
|
65
|
-
if (!e.name.endsWith('.md')) continue;
|
|
66
|
-
const parts = relative(root, p).split(sep);
|
|
67
|
-
const group = parts.length > 1 ? parts[0] : ROOT_KEY;
|
|
68
|
-
(groups[group] ||= []).push({
|
|
69
|
-
path: parts.join('/'),
|
|
70
|
-
name: e.name.replace(/\.md$/, ''),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
await walk(root, 0);
|
|
75
|
-
for (const g of Object.keys(groups)) groups[g].sort((a, b) => a.name.localeCompare(b.name));
|
|
76
|
-
|
|
77
|
-
// 顶部置顶的顺序:看板/日志/索引是每次开工先看的三个。
|
|
78
|
-
// 它们本来就在根目录,但会被其它组插在中间 —— 显式排序而不靠目录遍历顺序。
|
|
79
|
-
const ROOT_ORDER = ['index', 'todo', 'log'];
|
|
80
|
-
const ordered = {};
|
|
81
|
-
if (groups[ROOT_KEY]) {
|
|
82
|
-
groups[ROOT_KEY].sort((a, b) => {
|
|
83
|
-
const ia = ROOT_ORDER.indexOf(a.name), ib = ROOT_ORDER.indexOf(b.name);
|
|
84
|
-
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || a.name.localeCompare(b.name);
|
|
85
|
-
});
|
|
86
|
-
ordered[ROOT_KEY] = groups[ROOT_KEY];
|
|
87
|
-
}
|
|
88
|
-
// 知识页在前(常用的),流水在后
|
|
89
|
-
const DIR_ORDER = ['concepts', 'entities', 'syntheses', 'sources', 'sessions'];
|
|
90
|
-
for (const d of DIR_ORDER) if (groups[d]) ordered[d] = groups[d];
|
|
91
|
-
for (const g of Object.keys(groups)) if (!(g in ordered)) ordered[g] = groups[g];
|
|
92
|
-
return ordered;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** 解析请求路径 → .brain/ 内的绝对路径;越界返回 null。 */
|
|
96
|
-
export function safeResolve(root, urlPath) {
|
|
97
|
-
const clean = decodeURIComponent(String(urlPath).split('?')[0]);
|
|
98
|
-
const rel = normalize(clean).replace(/^(\.\.(\/|\\|$))+/, '');
|
|
99
|
-
const abs = join(root, rel);
|
|
100
|
-
const r = relative(root, abs);
|
|
101
|
-
if (r.startsWith('..') || r.includes(`..${sep}`)) return null;
|
|
102
|
-
return abs;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** 浏览器页面。布局与交互在此,渲染/高亮由 CDN 库负责。 */
|
|
106
|
-
export const PAGE = `<!DOCTYPE html>
|
|
107
|
-
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
108
|
-
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
109
|
-
<meta name="color-scheme" content="light dark">
|
|
110
|
-
<title>brain</title>
|
|
111
|
-
<link rel="stylesheet" href="${CDN}/highlight.js/11.4.0/styles/github.min.css" data-hl="light">
|
|
112
|
-
<link rel="stylesheet" href="${CDN}/highlight.js/11.4.0/styles/github-dark.min.css" data-hl="dark">
|
|
113
|
-
<style>
|
|
114
|
-
/* ============================================================
|
|
115
|
-
主题:5 套配色 × 2 档明暗 = 一套变量切换。
|
|
116
|
-
不引外部 CSS 表 —— 那些是整站样式,会和侧栏布局打架。
|
|
117
|
-
变量只覆盖颜色与字体,布局与结构不受影响。
|
|
118
|
-
============================================================ */
|
|
119
|
-
:root{
|
|
120
|
-
--fs:16px; --lh:1.75;
|
|
121
|
-
--radius:9px;
|
|
122
|
-
--serif:"Songti SC","SimSun",Georgia,serif;
|
|
123
|
-
--sans:-apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Source Han Sans SC","Noto Sans CJK SC","Segoe UI",sans-serif;
|
|
124
|
-
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/* --- 主题 1: github(中性,默认) --- */
|
|
128
|
-
[data-theme="github"]{
|
|
129
|
-
--bg:#fff;--fg:#1f2328;--dim:#656d76;--side:#f6f8fa;--line:#d8dee4;
|
|
130
|
-
--link:#0969da;--sel:#dbeafe;--code-bg:#f6f8fa;--mark:#fff3bf;--hl-line:#eaeef2;
|
|
131
|
-
--f-body:var(--sans);--f-head:var(--sans);
|
|
132
|
-
}
|
|
133
|
-
[data-theme="github"][data-mode="dark"]{
|
|
134
|
-
--bg:#0d1117;--fg:#e6edf3;--dim:#8b949e;--side:#161b22;--line:#30363d;
|
|
135
|
-
--link:#4493f8;--sel:#1f3352;--code-bg:#161b22;--mark:#4a3d1a;--hl-line:#21262d;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/* --- 主题 2: 阅读(衬线、宽行距、暖底,适合长文) --- */
|
|
139
|
-
[data-theme="read"]{
|
|
140
|
-
--bg:#fdfcf8;--fg:#2b2b2b;--dim:#8a8078;--side:#f6f2ea;--line:#e6dfd4;
|
|
141
|
-
--link:#b45309;--sel:#fde9c8;--code-bg:#f6f2ea;--mark:#ffe9a8;--hl-line:#efe9dd;
|
|
142
|
-
--fs:17px;--lh:1.9;
|
|
143
|
-
--f-body:var(--serif);--f-head:var(--serif);
|
|
144
|
-
}
|
|
145
|
-
[data-theme="read"][data-mode="dark"]{
|
|
146
|
-
--bg:#1a1815;--fg:#ddd6cc;--dim:#8a8078;--side:#221f1b;--line:#37322b;
|
|
147
|
-
--link:#e0a458;--sel:#3d3528;--code-bg:#221f1b;--mark:#4d3f1e;--hl-line:#2a2621;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/* --- 主题 3: 松(冷绿、紧凑、信息密度高) --- */
|
|
151
|
-
[data-theme="pine"]{
|
|
152
|
-
--bg:#ffffff;--fg:#24292f;--dim:#57606a;--side:#eef4f1;--line:#cfe0d9;
|
|
153
|
-
--link:#0f766e;--sel:#cceee7;--code-bg:#eef4f1;--mark:#d3f2c4;--hl-line:#e3ede9;
|
|
154
|
-
--fs:15.5px;--lh:1.68;
|
|
155
|
-
--f-body:var(--sans);--f-head:var(--sans);
|
|
156
|
-
}
|
|
157
|
-
[data-theme="pine"][data-mode="dark"]{
|
|
158
|
-
--bg:#0e1512;--fg:#d5e0db;--dim:#7d9189;--side:#141d19;--line:#26362f;
|
|
159
|
-
--link:#5eead4;--sel:#1b3a33;--code-bg:#141d19;--mark:#33471f;--hl-line:#1a2620;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/* --- 主题 4: 墨(黑白灰、极简、无彩色) --- */
|
|
163
|
-
[data-theme="ink"]{
|
|
164
|
-
--bg:#fff;--fg:#111;--dim:#888;--side:#fafafa;--line:#e0e0e0;
|
|
165
|
-
--link:#111;--sel:#ededed;--code-bg:#f7f7f7;--mark:#ffe97a;--hl-line:#f0f0f0;
|
|
166
|
-
--fs:16px;--lh:1.78;
|
|
167
|
-
--f-body:var(--sans);--f-head:var(--sans);
|
|
168
|
-
}
|
|
169
|
-
[data-theme="ink"][data-mode="dark"]{
|
|
170
|
-
--bg:#111;--fg:#eee;--dim:#8a8a8a;--side:#1a1a1a;--line:#2e2e2e;
|
|
171
|
-
--link:#fff;--sel:#2a2a2a;--code-bg:#1a1a1a;--mark:#5a5024;--hl-line:#242424;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/* --- 主题 5: 蓝调(深蓝底,低对比护眼) --- */
|
|
175
|
-
[data-theme="ocean"]{
|
|
176
|
-
--bg:#f4f7fb;--fg:#1e293b;--dim:#64748b;--side:#e8eef7;--line:#cbd8e8;
|
|
177
|
-
--link:#1d4ed8;--sel:#d5e3fb;--code-bg:#e8eef7;--mark:#cfe0ff;--hl-line:#dfe8f4;
|
|
178
|
-
--fs:16px;--lh:1.78;
|
|
179
|
-
--f-body:var(--sans);--f-head:var(--sans);
|
|
180
|
-
}
|
|
181
|
-
[data-theme="ocean"][data-mode="dark"]{
|
|
182
|
-
--bg:#0a1120;--fg:#dbe4f0;--dim:#7f8ea4;--side:#111c30;--line:#1f2f47;
|
|
183
|
-
--link:#7ea6ff;--sel:#1c2c4a;--code-bg:#111c30;--mark:#2f4573;--hl-line:#16233a;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/* 未指定主题 / 跟随系统时的兜底 = github */
|
|
187
|
-
html:not([data-theme]){ --bg:#fff;--fg:#1f2328;--dim:#656d76;--side:#f6f8fa;--line:#d8dee4;--link:#0969da;--sel:#dbeafe;--code-bg:#f6f8fa;--mark:#fff3bf;--f-body:var(--sans);--f-head:var(--sans) }
|
|
188
|
-
@media(prefers-color-scheme:dark){
|
|
189
|
-
html:not([data-theme]):not([data-mode="light"]){ --bg:#0d1117;--fg:#e6edf3;--dim:#8b949e;--side:#161b22;--line:#30363d;--link:#4493f8;--sel:#1f3352;--code-bg:#161b22;--mark:#4a3d1a }
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
*{box-sizing:border-box}
|
|
193
|
-
html,body{height:100%}
|
|
194
|
-
body{
|
|
195
|
-
margin:0;background:var(--bg);color:var(--fg);
|
|
196
|
-
font:var(--fs)/var(--lh) var(--f-body);
|
|
197
|
-
text-spacing-trim:space-first; /* 中西文自动间距,中文整齐的关键 */
|
|
198
|
-
-webkit-text-size-adjust:100%;
|
|
199
|
-
overflow-x:hidden;
|
|
200
|
-
transition:background .18s,color .18s;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/* ---- 侧栏 ---- */
|
|
204
|
-
aside{width:290px;flex:0 0 290px;background:var(--side);border-right:1px solid var(--line);
|
|
205
|
-
height:100vh;position:sticky;top:0;display:flex;flex-direction:column;
|
|
206
|
-
transition:transform .22s ease}
|
|
207
|
-
#wrap{display:flex;min-height:100%}
|
|
208
|
-
#hd{padding:12px 14px 10px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:6px}
|
|
209
|
-
#hd .brand{flex:1;font-size:12.5px;font-weight:700;letter-spacing:.1em;color:var(--dim)}
|
|
210
|
-
#hd button{border:1px solid var(--line);background:var(--bg);color:var(--dim);border-radius:6px;
|
|
211
|
-
width:30px;height:30px;line-height:1;cursor:pointer;font-size:14px;padding:0;flex:0 0 auto}
|
|
212
|
-
#hd button:hover{color:var(--fg);border-color:var(--link)}
|
|
213
|
-
#q{margin:10px 14px 0;padding:7px 10px;border:1px solid var(--line);border-radius:7px;
|
|
214
|
-
background:var(--bg);color:var(--fg);font-size:13px;font-family:inherit;width:calc(100% - 28px)}
|
|
215
|
-
#q:focus{outline:none;border-color:var(--link);box-shadow:0 0 0 3px var(--sel)}
|
|
216
|
-
#tree{overflow-y:auto;flex:1;padding:6px 0 24px}
|
|
217
|
-
.g{margin:10px 0 3px;padding:5px 14px;font-size:11.5px;color:var(--dim);font-weight:700;letter-spacing:.05em;
|
|
218
|
-
display:flex;align-items:center;gap:5px}
|
|
219
|
-
.g.fold{cursor:pointer;user-select:none}
|
|
220
|
-
.g.fold:hover{background:var(--sel);color:var(--fg)}
|
|
221
|
-
.g .cnt{margin-left:auto;opacity:.6;font-weight:500}
|
|
222
|
-
#tree a{display:block;padding:5px 14px 5px 22px;color:var(--fg);text-decoration:none;font-size:13.5px;
|
|
223
|
-
border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
224
|
-
#tree a:hover{background:var(--sel)}
|
|
225
|
-
#tree a.on{background:var(--sel);border-left-color:var(--link);color:var(--link);font-weight:600}
|
|
226
|
-
#mask{display:none}
|
|
227
|
-
|
|
228
|
-
/* ---- 正文 ---- */
|
|
229
|
-
main{flex:1;min-width:0;padding:34px 44px 96px;max-width:900px}
|
|
230
|
-
main h1,main h2{border-bottom:1px solid var(--line);padding-bottom:.32em;line-height:1.42;font-family:var(--f-head)}
|
|
231
|
-
main h1{font-size:1.75em;margin:.2em 0 .8em;font-weight:700}
|
|
232
|
-
main h2{font-size:1.34em;margin:1.7em 0 .7em;font-weight:700}
|
|
233
|
-
main h3{font-size:1.14em;margin:1.5em 0 .6em;font-weight:600;font-family:var(--f-head)}
|
|
234
|
-
main h4{font-size:1.02em;margin:1.3em 0 .5em;font-weight:600}
|
|
235
|
-
main p{margin:.85em 0}
|
|
236
|
-
main ul,main ol{padding-left:1.6em;margin:.85em 0}
|
|
237
|
-
main li{margin:.3em 0}
|
|
238
|
-
main table{border-collapse:collapse;margin:1.1em 0;display:block;overflow-x:auto;font-size:.93em}
|
|
239
|
-
main th,main td{border:1px solid var(--line);padding:7px 12px;line-height:1.55}
|
|
240
|
-
main th{background:var(--side);font-weight:600;white-space:nowrap}
|
|
241
|
-
main blockquote{margin:1em 0;padding:.1em 1em;border-left:.25em solid var(--line);color:var(--dim)}
|
|
242
|
-
main hr{border:none;border-top:1px solid var(--line);margin:1.8em 0}
|
|
243
|
-
main a{color:var(--link);text-decoration:none}
|
|
244
|
-
main a:hover{text-decoration:underline}
|
|
245
|
-
main code{font-family:var(--mono);font-size:.86em}
|
|
246
|
-
main :not(pre)>code{background:var(--code-bg);padding:.18em .42em;border-radius:5px;border:1px solid var(--line)}
|
|
247
|
-
main pre{border-radius:var(--radius);overflow-x:auto;border:1px solid var(--line);margin:1em 0}
|
|
248
|
-
main pre code{border:none;background:none;padding:0;font-size:.84em;line-height:1.62}
|
|
249
|
-
main img{max-width:100%;height:auto;border-radius:6px}
|
|
250
|
-
a.wl{cursor:pointer;border-bottom:1px dotted currentColor}
|
|
251
|
-
a.wl:hover{background:var(--mark)}
|
|
252
|
-
a.wl.miss{opacity:.4;text-decoration:line-through}
|
|
253
|
-
#empty{color:var(--dim);padding:80px 0;text-align:center;font-size:14px}
|
|
254
|
-
main>*:first-child{margin-top:0}
|
|
255
|
-
|
|
256
|
-
/* ---- 主题选择面板 ---- */
|
|
257
|
-
#tp{position:absolute;right:10px;top:50px;z-index:30;background:var(--bg);border:1px solid var(--line);
|
|
258
|
-
border-radius:10px;padding:10px;box-shadow:0 6px 24px rgba(0,0,0,.16);display:none;min-width:150px}
|
|
259
|
-
#tp.on{display:block}
|
|
260
|
-
#tp .t{font-size:11px;color:var(--dim);font-weight:700;letter-spacing:.05em;margin:0 0 6px 2px}
|
|
261
|
-
#tp .row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:13px}
|
|
262
|
-
#tp .row:hover{background:var(--sel)}
|
|
263
|
-
#tp .row.cur{background:var(--sel);font-weight:600}
|
|
264
|
-
#tp .sw{width:14px;height:14px;border-radius:4px;border:1px solid var(--line);flex:0 0 auto}
|
|
265
|
-
#hdr{position:relative}
|
|
266
|
-
|
|
267
|
-
/* ---- 手机:侧栏变抽屉 ---- */
|
|
268
|
-
@media(max-width:820px){
|
|
269
|
-
aside{position:fixed;left:0;top:0;z-index:20;width:min(84vw,320px);flex-basis:auto;
|
|
270
|
-
transform:translateX(-100%);box-shadow:2px 0 18px rgba(0,0,0,.2)}
|
|
271
|
-
body.nav-open aside{transform:translateX(0)}
|
|
272
|
-
body.nav-open #mask{display:block;position:fixed;inset:0;background:rgba(0,0,0,.44);z-index:10}
|
|
273
|
-
main{padding:16px 18px 90px;max-width:100%}
|
|
274
|
-
main h1{font-size:1.42em}main h2{font-size:1.2em}
|
|
275
|
-
:root{--fs:15.5px;--lh:1.8}
|
|
276
|
-
main table{font-size:.88em}
|
|
277
|
-
}
|
|
278
|
-
#fab{display:none}
|
|
279
|
-
@media(max-width:820px){
|
|
280
|
-
#fab{display:flex;position:fixed;left:14px;bottom:calc(16px + env(safe-area-inset-bottom));z-index:9;
|
|
281
|
-
width:46px;height:46px;border-radius:50%;border:1px solid var(--line);background:var(--side);color:var(--fg);
|
|
282
|
-
align-items:center;justify-content:center;font-size:19px;cursor:pointer;box-shadow:0 3px 14px rgba(0,0,0,.22)}
|
|
283
|
-
}
|
|
284
|
-
</style></head>
|
|
285
|
-
<body>
|
|
286
|
-
<div id="wrap">
|
|
287
|
-
<aside>
|
|
288
|
-
<div id="hd">
|
|
289
|
-
<span class="brand">BRAIN</span>
|
|
290
|
-
<button id="mode" title="明/暗">◐</button>
|
|
291
|
-
<button id="theme" title="主题">🎨</button>
|
|
292
|
-
<button id="close" title="收起" style="display:none">✕</button>
|
|
293
|
-
</div>
|
|
294
|
-
<div id="hdr">
|
|
295
|
-
<div id="tp"></div>
|
|
296
|
-
</div>
|
|
297
|
-
<input id="q" placeholder="筛选文件名…">
|
|
298
|
-
<div id="tree"></div>
|
|
299
|
-
</aside>
|
|
300
|
-
<div id="mask"></div>
|
|
301
|
-
<main id="main"><div id="empty">← 从左边选一个文件</div></main>
|
|
302
|
-
<button id="fab" title="目录">☰</button>
|
|
303
|
-
</div>
|
|
304
|
-
<script src="${CDN}/marked/4.0.2/marked.min.js"></script>
|
|
305
|
-
<script src="${CDN}/highlight.js/11.4.0/highlight.min.js"></script>
|
|
306
|
-
<script>
|
|
307
|
-
const $ = (s) => document.querySelector(s);
|
|
308
|
-
const FILES = {};
|
|
309
|
-
let TREE = {};
|
|
310
|
-
const isMobile = () => matchMedia('(max-width:820px)').matches;
|
|
311
|
-
|
|
312
|
-
/* 主题清单:id / 名称 / 预览色(给面板画色块) */
|
|
313
|
-
const THEMES = [
|
|
314
|
-
['github', 'GitHub', '#ffffff', '#0969da'],
|
|
315
|
-
['read', '阅读', '#fdfcf8', '#b45309'],
|
|
316
|
-
['pine', '松', '#eef4f1', '#0f766e'],
|
|
317
|
-
['ink', '墨', '#ffffff', '#111111'],
|
|
318
|
-
['ocean', '蓝调', '#f4f7fb', '#1d4ed8'],
|
|
319
|
-
];
|
|
320
|
-
|
|
321
|
-
/* --- 主题与明暗:存 localStorage --- */
|
|
322
|
-
function applyTheme(t) {
|
|
323
|
-
document.documentElement.dataset.theme = t;
|
|
324
|
-
localStorage.setItem('brain-theme', t);
|
|
325
|
-
renderThemePanel();
|
|
326
|
-
}
|
|
327
|
-
function applyMode(m) {
|
|
328
|
-
document.documentElement.dataset.mode = m;
|
|
329
|
-
localStorage.setItem('brain-mode', m);
|
|
330
|
-
// highlight.js 主题跟着切(深色主题用 github-dark)
|
|
331
|
-
document.querySelectorAll('link[data-hl]').forEach((l) => { l.disabled = l.dataset.hl !== m; });
|
|
332
|
-
}
|
|
333
|
-
function currentMode() {
|
|
334
|
-
return document.documentElement.dataset.mode ||
|
|
335
|
-
(matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light');
|
|
336
|
-
}
|
|
337
|
-
function renderThemePanel() {
|
|
338
|
-
const box = $('#tp');
|
|
339
|
-
const cur = document.documentElement.dataset.theme || 'github';
|
|
340
|
-
box.innerHTML = '<div class="t">主题</div>' + THEMES.map(([id, name, bg, acc]) =>
|
|
341
|
-
'<div class="row' + (id === cur ? ' cur' : '') + '" data-t="' + id + '">' +
|
|
342
|
-
'<span class="sw" style="background:' + bg + ';border-color:' + acc + '"></span>' + name + '</div>'
|
|
343
|
-
).join('');
|
|
344
|
-
box.querySelectorAll('.row').forEach((r) => {
|
|
345
|
-
r.onclick = () => { applyTheme(r.dataset.t); box.classList.remove('on'); };
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
(() => {
|
|
349
|
-
const t = localStorage.getItem('brain-theme') || 'github';
|
|
350
|
-
document.documentElement.dataset.theme = t;
|
|
351
|
-
const m = localStorage.getItem('brain-mode');
|
|
352
|
-
if (m) document.documentElement.dataset.mode = m;
|
|
353
|
-
applyMode(m || (matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light'));
|
|
354
|
-
$('#theme').onclick = (e) => { e.stopPropagation(); $('#tp').classList.toggle('on'); };
|
|
355
|
-
$('#mode').onclick = () => applyMode(currentMode() === 'dark' ? 'light' : 'dark');
|
|
356
|
-
document.addEventListener('click', (e) => {
|
|
357
|
-
if (!$('#tp').contains(e.target) && e.target !== $('#theme')) $('#tp').classList.remove('on');
|
|
358
|
-
});
|
|
359
|
-
renderThemePanel();
|
|
360
|
-
})();
|
|
361
|
-
|
|
362
|
-
/* --- 抽屉 --- */
|
|
363
|
-
function openNav(on) {
|
|
364
|
-
document.body.classList.toggle('nav-open', on);
|
|
365
|
-
$('#close').style.display = isMobile() && on ? '' : 'none';
|
|
366
|
-
}
|
|
367
|
-
$('#fab').onclick = () => openNav(true);
|
|
368
|
-
$('#close').onclick = () => openNav(false);
|
|
369
|
-
$('#mask').onclick = () => openNav(false);
|
|
370
|
-
matchMedia('(max-width:820px)').addEventListener('change', () => openNav(false));
|
|
371
|
-
|
|
372
|
-
/** [[双链]] 扩展 */
|
|
373
|
-
const wlExt = {
|
|
374
|
-
name: 'wl', level: 'inline',
|
|
375
|
-
start(src) { return src.indexOf('[['); },
|
|
376
|
-
tokenizer(src) {
|
|
377
|
-
const m = src.match(/^\\[\\[([^\\]\\n]+)\\]\\]/);
|
|
378
|
-
if (m) return { type: 'wl', raw: m[0], slug: m[1].trim() };
|
|
379
|
-
},
|
|
380
|
-
renderer(t) { return '<a class="wl" data-slug="' + t.slug + '">' + t.slug + '</a>'; },
|
|
381
|
-
};
|
|
382
|
-
marked.use({ extensions: [wlExt], mangle: false, headerIds: false });
|
|
383
|
-
|
|
384
|
-
function renderTree(filter) {
|
|
385
|
-
const box = $('#tree'); box.innerHTML = '';
|
|
386
|
-
let n = 0, idx = 0;
|
|
387
|
-
for (const [g, arr] of Object.entries(TREE)) {
|
|
388
|
-
const hit = arr.filter((f) => !filter || f.name.toLowerCase().includes(filter));
|
|
389
|
-
if (!hit.length) continue;
|
|
390
|
-
// 第一个组(项目名)常看:不折叠;其他文件夹默认收起
|
|
391
|
-
const collapsible = idx > 0;
|
|
392
|
-
const open = !!filter || !collapsible;
|
|
393
|
-
const h = document.createElement('div');
|
|
394
|
-
h.className = 'g' + (collapsible ? ' fold' : '');
|
|
395
|
-
h.innerHTML = (collapsible ? '<span class="ar">' + (open ? '▾' : '▸') + '</span>' : '') +
|
|
396
|
-
'<span>' + g + '</span><span class="cnt">' + hit.length + '</span>';
|
|
397
|
-
const body = document.createElement('div');
|
|
398
|
-
body.style.display = open ? '' : 'none';
|
|
399
|
-
if (collapsible) h.onclick = () => {
|
|
400
|
-
const on = body.style.display === 'none';
|
|
401
|
-
body.style.display = on ? '' : 'none';
|
|
402
|
-
h.querySelector('.ar').textContent = on ? '▾' : '▸';
|
|
403
|
-
};
|
|
404
|
-
box.appendChild(h); box.appendChild(body); idx++;
|
|
405
|
-
for (const f of hit) {
|
|
406
|
-
FILES[f.name] = f.path; FILES[f.path] = f.path;
|
|
407
|
-
const a = document.createElement('a');
|
|
408
|
-
a.textContent = f.name; a.dataset.path = f.path; a.title = f.path;
|
|
409
|
-
a.onclick = () => load(f.path, a);
|
|
410
|
-
body.appendChild(a); n++;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
if (!n) box.innerHTML = '<div style="padding:16px;color:var(--dim);font-size:13px">无匹配</div>';
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
async function load(path, el) {
|
|
417
|
-
document.querySelectorAll('#tree a.on').forEach((a) => a.classList.remove('on'));
|
|
418
|
-
if (el) el.classList.add('on');
|
|
419
|
-
const text = await (await fetch('/raw/' + path)).text();
|
|
420
|
-
const main = $('#main');
|
|
421
|
-
main.innerHTML = marked.parse(text);
|
|
422
|
-
main.querySelectorAll('pre code').forEach((b) => { try { hljs.highlightElement(b); } catch {} });
|
|
423
|
-
main.querySelectorAll('a.wl').forEach((a) => {
|
|
424
|
-
a.onclick = () => {
|
|
425
|
-
const s = a.dataset.slug;
|
|
426
|
-
const p = FILES[s] || FILES[s.split('/').pop()];
|
|
427
|
-
if (p) {
|
|
428
|
-
const link = [...document.querySelectorAll('#tree a')].find((x) => x.dataset.path === p);
|
|
429
|
-
load(p, link);
|
|
430
|
-
} else { a.classList.add('miss'); a.title = '图谱里没有这一页'; }
|
|
431
|
-
};
|
|
432
|
-
});
|
|
433
|
-
document.title = path.split('/').pop() + ' · brain';
|
|
434
|
-
localStorage.setItem('brain-last', path);
|
|
435
|
-
if (isMobile()) openNav(false);
|
|
436
|
-
window.scrollTo(0, 0);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
(async () => {
|
|
440
|
-
const meta = await (await fetch('/files.json')).json();
|
|
441
|
-
TREE = meta.groups || meta;
|
|
442
|
-
document.querySelector('#hd .brand').textContent = (meta.rootLabel || 'BRAIN').toUpperCase();
|
|
443
|
-
renderTree('');
|
|
444
|
-
$('#q').oninput = (e) => renderTree(e.target.value.trim().toLowerCase());
|
|
445
|
-
if (!isMobile()) {
|
|
446
|
-
const last = localStorage.getItem('brain-last');
|
|
447
|
-
const target = last && [...document.querySelectorAll('#tree a')].find((x) => x.dataset.path === last)
|
|
448
|
-
? last : Object.values(TREE)[0]?.[0]?.path;
|
|
449
|
-
if (target) {
|
|
450
|
-
const link = [...document.querySelectorAll('#tree a')].find((x) => x.dataset.path === target);
|
|
451
|
-
load(target, link);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
})();
|
|
455
|
-
</script></body></html>`;
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* 起服务。
|
|
463
|
-
* @param {{root:string, port?:number, host?:string}} cfg root = .brain/ 绝对路径
|
|
464
|
-
* port = 0(默认)让系统挑空闲端口,多个项目可同时开。
|
|
465
|
-
*/
|
|
466
|
-
export async function serve({ root, port = 0, host = '127.0.0.1' }) {
|
|
467
|
-
const server = http.createServer(async (req, res) => {
|
|
468
|
-
try {
|
|
469
|
-
const url = req.url || '/';
|
|
470
|
-
if (url === '/' || url === '/index.html') {
|
|
471
|
-
res.writeHead(200, { 'Content-Type': MIME['.html'] });
|
|
472
|
-
return res.end(PAGE);
|
|
473
|
-
}
|
|
474
|
-
if (url.startsWith('/files.json')) {
|
|
475
|
-
const tree = await scanTree(root);
|
|
476
|
-
// 把内部组 key 换成展示名,并按展示名重排。
|
|
477
|
-
// 根组用项目目录名(.brain/ 的父目录)—— "根" 太泛,看不出是哪个项目。
|
|
478
|
-
const { basename, dirname } = await import('node:path');
|
|
479
|
-
const projName = basename(dirname(root)) || '项目';
|
|
480
|
-
const out = {};
|
|
481
|
-
for (const [k, arr] of Object.entries(tree)) out[displayGroup(k, projName)] = arr;
|
|
482
|
-
res.writeHead(200, { 'Content-Type': MIME['.json'] });
|
|
483
|
-
return res.end(JSON.stringify({ rootLabel: projName, groups: out }));
|
|
484
|
-
}
|
|
485
|
-
if (url.startsWith('/raw/')) {
|
|
486
|
-
const abs = safeResolve(root, url.slice('/raw'.length));
|
|
487
|
-
if (!abs) { res.writeHead(403); return res.end('forbidden'); }
|
|
488
|
-
const body = await fs.readFile(abs).catch(() => null);
|
|
489
|
-
if (body === null) { res.writeHead(404); return res.end('not found'); }
|
|
490
|
-
res.writeHead(200, { 'Content-Type': MIME[extname(abs)] || MIME['.txt'] });
|
|
491
|
-
return res.end(body);
|
|
492
|
-
}
|
|
493
|
-
res.writeHead(404);
|
|
494
|
-
res.end('not found');
|
|
495
|
-
} catch (e) {
|
|
496
|
-
res.writeHead(500);
|
|
497
|
-
res.end(String(e && e.message ? e.message : e));
|
|
498
|
-
}
|
|
499
|
-
});
|
|
500
|
-
await new Promise((resolve, reject) => {
|
|
501
|
-
server.once('error', reject);
|
|
502
|
-
server.listen(port, host, resolve);
|
|
503
|
-
});
|
|
504
|
-
const actual = server.address().port;
|
|
505
|
-
return { server, port: actual, url: `http://${host}:${actual}/` };
|
|
506
|
-
}
|