@fanchao8609/agent_brain_sync 1.7.9 → 1.8.1
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 +3 -1
- package/bin/abs.js +20 -3
- package/bin/mcp.js +98 -18
- package/hooks/abs.opencode.ts +20 -3
- package/hooks/abs.pi.ts +8 -1
- package/package.json +1 -1
- package/skill/SKILL.md +33 -4
- package/src/store.js +227 -6
package/README.md
CHANGED
|
@@ -69,7 +69,9 @@ abs todo done TASK-1
|
|
|
69
69
|
|
|
70
70
|
abs note "一句话经验" --tags 坑,docker # 经验暂存 → sources/
|
|
71
71
|
abs log "完成 X" # 记一行流水;abs log 无参 = 查看
|
|
72
|
-
abs query <词> # 检索图谱(多词 OR
|
|
72
|
+
abs query <词> # 检索图谱(多词 OR);已被推翻的经验默认隐藏(--all 可看)
|
|
73
|
+
abs resolve <页名或id> # 反查页面路径(页改名后 id 不变)
|
|
74
|
+
abs supersede <页名> [--by <新页>] # 标记经验已失效(不删文件,保留历史;query 默认不再返回)
|
|
73
75
|
abs status # 当前项目 + 图谱概要
|
|
74
76
|
abs lint # 体检:死链/孤岛/超尺寸/堆积
|
|
75
77
|
abs rule # 列出 index.md 的 ## Rules 硬规则
|
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, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive } from '../src/store.js';
|
|
5
|
+
import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede } from '../src/store.js';
|
|
6
6
|
import { setUser, getUser, userConfigPath } from '../src/userconfig.js';
|
|
7
7
|
import { runInstall, runUninstall } from '../src/install.js';
|
|
8
8
|
import { readFileSync } from 'node:fs';
|
|
@@ -74,6 +74,8 @@ const FLAG_SPEC = {
|
|
|
74
74
|
'payload': { type: 'string' },
|
|
75
75
|
'tags': { type: 'string' },
|
|
76
76
|
'state': { type: 'string' },
|
|
77
|
+
'by': { type: 'string' },
|
|
78
|
+
'all': { type: 'boolean' },
|
|
77
79
|
'keep-days': { type: 'string' },
|
|
78
80
|
'help': { type: 'boolean' },
|
|
79
81
|
'dry-run': { type: 'boolean' },
|
|
@@ -144,6 +146,8 @@ function parseArgv(args) {
|
|
|
144
146
|
note: values.note,
|
|
145
147
|
as: values.as,
|
|
146
148
|
state: values.state,
|
|
149
|
+
by: values.by,
|
|
150
|
+
all: !!values.all,
|
|
147
151
|
payload: values.payload,
|
|
148
152
|
tags: values.tags,
|
|
149
153
|
help: !!values.help,
|
|
@@ -183,7 +187,12 @@ const usage = `abs — agent-brain-sync 记忆工具
|
|
|
183
187
|
abs note "经验一句话" [--tags 坑,docker] 经验实时暂存 → sources/
|
|
184
188
|
|
|
185
189
|
维护:
|
|
186
|
-
abs query <词1> [词2 …]
|
|
190
|
+
abs query <词1> [词2 …] [--all]
|
|
191
|
+
检索 .brain/ 知识页 (多词 OR);superseded 默认隐藏
|
|
192
|
+
abs resolve <id-or-slug> [更多…]
|
|
193
|
+
按 id/页面名反查路径 (页改名后 id 不变,仍能找回)
|
|
194
|
+
abs supersede <页名> [--by <取代它的页>]
|
|
195
|
+
标记一条经验已失效 (不删文件;query/load 默认不再展示)
|
|
187
196
|
abs todo archive [--keep-days N] [--dry-run]
|
|
188
197
|
归档 Done 区旧日期组 → sessions/<日期>-todo归档.md
|
|
189
198
|
(默认保留近 3 天; 任一天有未完成则整天不归档)
|
|
@@ -400,7 +409,15 @@ async function main() {
|
|
|
400
409
|
break;
|
|
401
410
|
}
|
|
402
411
|
case 'query': {
|
|
403
|
-
console.log(await cmdQuery({ dir: opts.dir, terms: opts._ }));
|
|
412
|
+
console.log(await cmdQuery({ dir: opts.dir, terms: opts._, includeSuperseded: opts.all }));
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case 'supersede': {
|
|
416
|
+
console.log(await cmdSupersede({ dir: opts.dir, refs: opts._, by: opts.by }));
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case 'resolve': {
|
|
420
|
+
console.log(await cmdResolve({ dir: opts.dir, refs: opts._ }));
|
|
404
421
|
break;
|
|
405
422
|
}
|
|
406
423
|
case 'lint': {
|
package/bin/mcp.js
CHANGED
|
@@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs';
|
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { findBrainRoot, absLogDir } from '../src/index.js';
|
|
15
|
-
import { cmdBoard, cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdWrapup, cmdRule } from '../src/store.js';
|
|
15
|
+
import { cmdBoard, cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdWrapup, cmdRule, cmdResolve, cmdSupersede } from '../src/store.js';
|
|
16
16
|
|
|
17
17
|
// ---------- 技术日志: MCP 请求跟踪(调试用, 与图谱 log.md 完全分开) ----------
|
|
18
18
|
// 落 ~/.abs/log/mcp.log: 每次工具调用一行 [时间] tool cwd 参数摘要 → 耗时/结果摘要。
|
|
@@ -66,7 +66,12 @@ function tool(name, description, schema, handler) {
|
|
|
66
66
|
server.tool(name, description, schema, withTrace(name, handler));
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// 工具面(narrow on purpose —
|
|
69
|
+
// 工具面(narrow on purpose — 只暴露读/查/记状态,不做深提炼)。
|
|
70
|
+
// 分级标注(借 OpenContext 的 P0/P1/P2 惯例):P0=每次开机必调;P1=干活中按需;P2=诊断/收尾。
|
|
71
|
+
// 后来人(和 AI)照这个顺序调,别一上来就 lint。
|
|
72
|
+
// P0 abs_resolve_project / abs_load
|
|
73
|
+
// P1 abs_board / abs_task / abs_query / abs_note / abs_resolve
|
|
74
|
+
// P2 abs_status / abs_rule / abs_lint / abs_wrapup
|
|
70
75
|
tool(
|
|
71
76
|
'abs_resolve_project',
|
|
72
77
|
'定位当前项目的 .brain 图谱根(只认给定目录本身的 .brain/,不向上搜索)。多项目隔离的唯一入口。',
|
|
@@ -74,7 +79,10 @@ tool(
|
|
|
74
79
|
async ({ cwd }) => {
|
|
75
80
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
76
81
|
if (!root) {
|
|
77
|
-
return
|
|
82
|
+
return err(`目录 ${cwd} 下没有 .brain/ 图谱(不向上搜索)。`, {
|
|
83
|
+
code: 'NO_BRAIN',
|
|
84
|
+
fallback: '先在该目录运行: abs init',
|
|
85
|
+
});
|
|
78
86
|
}
|
|
79
87
|
return { content: [{ type: 'text', text: root }] };
|
|
80
88
|
}
|
|
@@ -86,7 +94,7 @@ tool(
|
|
|
86
94
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
87
95
|
async ({ cwd }) => {
|
|
88
96
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
89
|
-
if (!root) return
|
|
97
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
90
98
|
return { content: [{ type: 'text', text: await cmdBoard({ dir: root }) }] };
|
|
91
99
|
}
|
|
92
100
|
);
|
|
@@ -97,7 +105,7 @@ tool(
|
|
|
97
105
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
98
106
|
async ({ cwd }) => {
|
|
99
107
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
100
|
-
if (!root) return
|
|
108
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
101
109
|
return { content: [{ type: 'text', text: await cmdLoad({ dir: root }) }] };
|
|
102
110
|
}
|
|
103
111
|
);
|
|
@@ -113,20 +121,59 @@ tool(
|
|
|
113
121
|
},
|
|
114
122
|
async ({ action, id, cwd, note }) => {
|
|
115
123
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
116
|
-
if (!root) return
|
|
124
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
117
125
|
// add 是 start 的别名(与 CLI `abs todo add` 对齐)
|
|
118
126
|
const act = action === 'add' ? 'start' : action;
|
|
119
127
|
return { content: [{ type: 'text', text: await cmdTask({ dir: root, action: act, id, note }) }] };
|
|
120
128
|
}
|
|
121
129
|
);
|
|
122
130
|
|
|
131
|
+
tool(
|
|
132
|
+
'abs_resolve',
|
|
133
|
+
'按页面 id(或页面名 slug)反查文件路径。页改名后 id 不变,引用请用返回的路径/id。',
|
|
134
|
+
{
|
|
135
|
+
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
136
|
+
refs: z.array(z.string()).min(1).describe('页面 id 或页面名,如 "file-write-locking"'),
|
|
137
|
+
},
|
|
138
|
+
async ({ cwd, refs }) => {
|
|
139
|
+
const root = await findBrainRoot(cwd || process.cwd());
|
|
140
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
141
|
+
const text = await cmdResolve({ dir: root, refs });
|
|
142
|
+
// 全部未命中才算错 —— 部分命中时整体标 isError 会让 Agent 以为调用失败,
|
|
143
|
+
// 把已经拿到的正确路径丢掉(实测:1 中 1 不中 → 整个结果被当错)。
|
|
144
|
+
if (/✗/.test(text) && !/✓/.test(text)) {
|
|
145
|
+
return err(text, { code: 'NOT_FOUND', fallback: 'abs query <词> 全文搜,或直接读 index.md' });
|
|
146
|
+
}
|
|
147
|
+
return { content: [{ type: 'text', text }] };
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
tool(
|
|
152
|
+
'abs_supersede',
|
|
153
|
+
'标记一条经验/知识页已失效(被推翻),不删文件、保留历史。之后 abs_query 默认不再返回它。核实后发现仍有效可手动把 status 改回 active。',
|
|
154
|
+
{
|
|
155
|
+
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
156
|
+
refs: z.array(z.string()).min(1).describe('页名或 id,如 "old-approach"'),
|
|
157
|
+
by: z.string().optional().describe('取代它的页名(可选;给了则必须已存在)'),
|
|
158
|
+
},
|
|
159
|
+
async ({ cwd, refs, by }) => {
|
|
160
|
+
const root = await findBrainRoot(cwd || process.cwd());
|
|
161
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
162
|
+
const text = await cmdSupersede({ dir: root, refs, by });
|
|
163
|
+
if (/✗/.test(text) && !/✓/.test(text)) {
|
|
164
|
+
return err(text, { code: 'NOT_FOUND', fallback: '用 abs_query <词> 找到正确的页名后重试' });
|
|
165
|
+
}
|
|
166
|
+
return { content: [{ type: 'text', text }] };
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
|
|
123
170
|
tool(
|
|
124
171
|
'abs_status',
|
|
125
172
|
'当前项目 + 图谱概要(各类页数)。',
|
|
126
173
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
127
174
|
async ({ cwd }) => {
|
|
128
175
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
129
|
-
if (!root) return
|
|
176
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
130
177
|
return { content: [{ type: 'text', text: await cmdStatus({ dir: root }) }] };
|
|
131
178
|
}
|
|
132
179
|
);
|
|
@@ -134,11 +181,20 @@ tool(
|
|
|
134
181
|
tool(
|
|
135
182
|
'abs_query',
|
|
136
183
|
'检索当前项目 .brain/ 知识页(多词 OR):以前踩过什么坑、哪页记了 X。',
|
|
137
|
-
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)'), terms: z.array(z.string()).min(1).describe('检索词') },
|
|
138
|
-
async ({ cwd, terms }) => {
|
|
184
|
+
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)'), terms: z.array(z.string()).min(1).describe('检索词'), include_superseded: z.boolean().optional().describe('true=连已标记失效的经验一起返回(默认隐藏)') },
|
|
185
|
+
async ({ cwd, terms, include_superseded }) => {
|
|
139
186
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
140
|
-
if (!root) return
|
|
141
|
-
|
|
187
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
188
|
+
const text = await cmdQuery({ dir: root, terms, includeSuperseded: include_superseded });
|
|
189
|
+
// 无命中时给逃生口:query 是全文匹配,猜的词不对就换词/看全貌,别以为图谱是空的。
|
|
190
|
+
// 收口在这里(不散在 store.js)—— CLI 侧是一行提示,MCP 侧需要可自救的结构。
|
|
191
|
+
if (/无命中/.test(text)) {
|
|
192
|
+
return err(text, {
|
|
193
|
+
code: 'NO_MATCH',
|
|
194
|
+
fallback: '换更具体的词重试(支持多个词 OR);或读 .brain/index.md 看完整清单',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return { content: [{ type: 'text', text }] };
|
|
142
198
|
}
|
|
143
199
|
);
|
|
144
200
|
|
|
@@ -152,7 +208,7 @@ tool(
|
|
|
152
208
|
},
|
|
153
209
|
async ({ cwd, action, text }) => {
|
|
154
210
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
155
|
-
if (!root) return
|
|
211
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
156
212
|
return { content: [{ type: 'text', text: await cmdRule({ dir: root, action: action || 'list', text }) }] };
|
|
157
213
|
}
|
|
158
214
|
);
|
|
@@ -163,8 +219,14 @@ tool(
|
|
|
163
219
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
164
220
|
async ({ cwd }) => {
|
|
165
221
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
166
|
-
if (!root) return
|
|
167
|
-
|
|
222
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
223
|
+
const text = await cmdLint({ dir: root });
|
|
224
|
+
// lint 报的是「哪不对」,得补一句「下一步怎么办」,否则 Agent 只能把问题复述给用户。
|
|
225
|
+
const fallback = /无问题|0 个问题|健康/.test(text)
|
|
226
|
+
? null
|
|
227
|
+
: '按条目里的文件名/H1 直接改;死链可删或改指向;index 漏列用 abs_rule 同区手工补一行';
|
|
228
|
+
if (!fallback) return { content: [{ type: 'text', text }] };
|
|
229
|
+
return err(text, { code: 'LINT_ISSUES', fallback });
|
|
168
230
|
}
|
|
169
231
|
);
|
|
170
232
|
|
|
@@ -178,7 +240,7 @@ tool(
|
|
|
178
240
|
},
|
|
179
241
|
async ({ cwd, text, tags }) => {
|
|
180
242
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
181
|
-
if (!root) return
|
|
243
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
182
244
|
return { content: [{ type: 'text', text: await cmdNote({ dir: root, text, tags }) }] };
|
|
183
245
|
}
|
|
184
246
|
);
|
|
@@ -189,13 +251,31 @@ tool(
|
|
|
189
251
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)') },
|
|
190
252
|
async ({ cwd }) => {
|
|
191
253
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
192
|
-
if (!root) return
|
|
254
|
+
if (!root) return errNoBrain(cwd || process.cwd());
|
|
193
255
|
return { content: [{ type: 'text', text: await cmdWrapup({ dir: root }) }] };
|
|
194
256
|
}
|
|
195
257
|
);
|
|
196
258
|
|
|
197
|
-
|
|
198
|
-
|
|
259
|
+
// 错误带逃生口:Agent 收到错要能自救,而不是回来问人。
|
|
260
|
+
// 借 OpenContext 的做法——错误里直接给「退一步怎么做」,且带结构化 code 供程序判分支。
|
|
261
|
+
// 判据:凡 isError 的返回,调用方读完应知道下一步跑什么命令。
|
|
262
|
+
function err(msg, { code, fallback } = {}) {
|
|
263
|
+
const body = [
|
|
264
|
+
code ? `[${code}] ${msg}` : msg,
|
|
265
|
+
fallback ? `→ ${fallback}` : null,
|
|
266
|
+
].filter(Boolean).join('\n');
|
|
267
|
+
const res = { content: [{ type: 'text', text: body }], isError: true };
|
|
268
|
+
// 结构化字段:MCP 客户端可读 structuredContent 判分支(code=NO_BRAIN 等)
|
|
269
|
+
if (code) res.structuredContent = { error: code, message: msg, fallback: fallback || null };
|
|
270
|
+
return res;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 无图谱专用:所有工具共用同一条自救路径,收口在这里(改一处即全改)。 */
|
|
274
|
+
function errNoBrain(cwd) {
|
|
275
|
+
return err(`目录 ${cwd} 下没有 .brain/ 图谱(不向上搜索)。`, {
|
|
276
|
+
code: 'NO_BRAIN',
|
|
277
|
+
fallback: `先在该目录运行: abs init (或在含图谱的目录重调本工具)`,
|
|
278
|
+
});
|
|
199
279
|
}
|
|
200
280
|
|
|
201
281
|
await server.connect(new StdioServerTransport());
|
package/hooks/abs.opencode.ts
CHANGED
|
@@ -4,17 +4,24 @@
|
|
|
4
4
|
* 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
|
|
5
5
|
* Opencode 事件: session.created / session.idle / session.deleted (idle = 每轮结束, 对应 pi 的 agent_end)。
|
|
6
6
|
* 收尾注入: session.idle 且本会话真改过文件 + .brain 今日无记录 → promptAsync 推 agent 走收尾循环。
|
|
7
|
+
*
|
|
8
|
+
* 登记注入 (2026-09-15 用户定): 写文件 = 任务开始的机械信号。
|
|
9
|
+
* 旧设计只在 idle(轮末) 提醒收尾 —— 那时用户已想结束, AI 只想收尾不想登记。
|
|
10
|
+
* 故 tool.execute.after 检测到**项目文件**写入即注入登记提示(不等 idle):
|
|
11
|
+
* · 频繁没关系 —— 每次写都提醒(用户明确要求)
|
|
12
|
+
* · 判据 = 整个项目, 但**排除 .brain/ 自身**(写图谱是记录行为, 不是任务)
|
|
13
|
+
* · 文案明说「纯讨论可跳过」—— 不逼 AI 造任务
|
|
7
14
|
*/
|
|
8
15
|
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
9
16
|
import { homedir } from "node:os"
|
|
10
17
|
import { join, dirname } from "node:path"
|
|
11
|
-
|
|
12
18
|
const server = async ({ client, directory }) => {
|
|
13
19
|
// 本会话是否真改过文件 (由 tool.execute.after 观测)
|
|
14
20
|
let wroteFiles = false
|
|
15
21
|
let nudged = false
|
|
16
22
|
let idleSeen = false
|
|
17
|
-
|
|
23
|
+
// 当前 session id(由事件里拿)—— 收尾注入需要它找到会话。
|
|
24
|
+
let currentSessionID = ""
|
|
18
25
|
async function logHook(evt) {
|
|
19
26
|
try {
|
|
20
27
|
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
@@ -45,6 +52,7 @@ const server = async ({ client, directory }) => {
|
|
|
45
52
|
} catch { return false }
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
|
|
48
56
|
const TEARDOWN_MSG =
|
|
49
57
|
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\n" +
|
|
50
58
|
"1) 跑 abs load 看 Todo 还有哪些未完成;\n" +
|
|
@@ -56,19 +64,23 @@ const server = async ({ client, directory }) => {
|
|
|
56
64
|
// bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
|
|
57
65
|
const READONLY_CMD = /^\s*(ls|cat|grep|rg|find|head|tail|wc|git\s+(status|log|diff|show|branch)|pwd|which|echo|node\s+-v|npm\s+(ls|view)|curl)\b/
|
|
58
66
|
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
59
70
|
return {
|
|
60
71
|
// 观测真实写操作: write/edit/patch 类工具成功即标记。
|
|
61
72
|
// 坑: 曾漏掉 bash —— opencode 里很多修改是经 bash(heredoc/sed) 完成的, 纯 bash 会话
|
|
62
73
|
// 永远不置位 wroteFiles, 于是收尾提醒静默不发。与 pi 侧 WRITE_TOOLS 保持一致。
|
|
63
74
|
"tool.execute.after": async ({ tool, args }) => {
|
|
64
75
|
const t = String(tool || "").toLowerCase()
|
|
76
|
+
const a = args || {}
|
|
65
77
|
if (["write", "edit", "patch", "multiedit", "apply_patch"].includes(t)) {
|
|
66
78
|
wroteFiles = true
|
|
67
79
|
return
|
|
68
80
|
}
|
|
69
81
|
// bash 里只有非只读命令算改文件(ls/cat/git status 之类不算)
|
|
70
82
|
if (t === "bash") {
|
|
71
|
-
const cmd = String(
|
|
83
|
+
const cmd = String(a.command || a.cmd || "")
|
|
72
84
|
if (cmd && !READONLY_CMD.test(cmd)) wroteFiles = true
|
|
73
85
|
}
|
|
74
86
|
},
|
|
@@ -78,9 +90,14 @@ const server = async ({ client, directory }) => {
|
|
|
78
90
|
if (!type) return
|
|
79
91
|
if (type === "session.created" || type === "session.deleted") {
|
|
80
92
|
await logHook(type)
|
|
93
|
+
// session.created / idle 等事件带 sessionID —— 存下来供 tool.execute.after 用
|
|
94
|
+
const sid = event.properties && event.properties.sessionID
|
|
95
|
+
if (sid) currentSessionID = String(sid)
|
|
81
96
|
return
|
|
82
97
|
}
|
|
83
98
|
if (type !== "session.idle") return // idle = 每轮结束, 不记日志(太吵), 只做收尾判定
|
|
99
|
+
const sid0 = event.properties && event.properties.sessionID
|
|
100
|
+
if (sid0) currentSessionID = String(sid0)
|
|
84
101
|
const cwd = directory || process.cwd()
|
|
85
102
|
const brain = await findBrain(cwd)
|
|
86
103
|
// 可观测性: 首次 idle 无条件留一行痕(否则无法区分“事件没触发”与“被守卫拦下”)
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* - before_agent_start (用户发话 = 天然话题边界) → 记用户原话
|
|
13
13
|
* - turn_end (每轮结束) → 记改过哪些文件
|
|
14
14
|
* 累积在**内存**(下方 sessionNotes), 收尾时拼进注入提示 —— **不落盘**。
|
|
15
|
+
*
|
|
16
|
+
* 已删除(2026-09-15):曾加过一个「写文件即任务开始 → 每轮注入登记提醒」的功能。
|
|
17
|
+
* 已发布到 1.8.0 后用户实报「pi 里一直报 Follow-up」,判 C 整个删掉。
|
|
18
|
+
* 教训:每次改文件就往对话里插一句话,本身就是设计错误,不是频率问题 ——
|
|
19
|
+
* 改守卫(从每轮一次改成每会话一次)治不了它。别再加回来。
|
|
15
20
|
*/
|
|
16
21
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
17
22
|
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
@@ -109,6 +114,7 @@ async function loggedToday(brain: string): Promise<boolean> {
|
|
|
109
114
|
} catch { return false }
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
|
|
112
118
|
export default function absPiHook(pi: ExtensionAPI): void {
|
|
113
119
|
// 收尾注入的节流状态。声明在**外层** + 在 session_start 里重置:
|
|
114
120
|
// 否则同一进程的第二个会话会继承上一个会话的 true,永久不再提醒
|
|
@@ -116,7 +122,7 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
116
122
|
let teardownNudged = false
|
|
117
123
|
let agentEndSeen = false
|
|
118
124
|
|
|
119
|
-
pi.on("session_start", () => {
|
|
125
|
+
pi.on("session_start", (event: any, _ctx: any) => {
|
|
120
126
|
teardownNudged = false
|
|
121
127
|
agentEndSeen = false
|
|
122
128
|
return logHook("session_start").catch(() => {})
|
|
@@ -144,6 +150,7 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
144
150
|
}
|
|
145
151
|
if (files.size) addNote("tool", [...files].join(", "))
|
|
146
152
|
})
|
|
153
|
+
|
|
147
154
|
// 收尾注入: 每个会话最多一次, 避免反复打扰。
|
|
148
155
|
pi.on("agent_end", async (event: any, ctx: any) => {
|
|
149
156
|
if (teardownNudged) return
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -20,6 +20,7 @@ abs todo # 看板(Done 折成计数;明细 abs todo --fu
|
|
|
20
20
|
abs index / abs log # 完整 index.md / log.md
|
|
21
21
|
abs status # 当前项目 + 图谱概要
|
|
22
22
|
abs query <词1> [词2 …] # 检索 .brain/ 知识页(多词 OR)
|
|
23
|
+
abs resolve <页名或id> # 反查页面路径(改名后 id 不变)
|
|
23
24
|
abs lint # 图谱体检(死链/悬挂/超限/堆积/未提炼/Rules 超限)
|
|
24
25
|
|
|
25
26
|
# 写
|
|
@@ -170,9 +171,13 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
|
|
|
170
171
|
- 快照里的任务现在真做完了 → `abs todo done <id>`(done 后下次 load 滞留自动消失);
|
|
171
172
|
- 还没做完 → `abs todo note <id> --note "接到哪/改到哪个文件"` 补断点(别空手续接)。
|
|
172
173
|
滞留没清完就不算接上了状态——这是「任务做完没进 Done」的根治动作。
|
|
173
|
-
3.
|
|
174
|
+
3. **读命中页**:`abs query <词>` 检索(多词 OR)→ 拿到页名后读那几个文件。
|
|
175
|
+
```bash
|
|
176
|
+
abs query <词> # 全文检索,只回命中几页
|
|
177
|
+
abs resolve <页名或id> # 反查文件路径(页改过名时用 id 能找回)
|
|
178
|
+
```
|
|
174
179
|
> ⚠️ **绝不通读 `.brain/`**(29 页就约 11 万 token,全读塞满窗口)。
|
|
175
|
-
> 要状态→`abs load`;要主题→`abs query
|
|
180
|
+
> 要状态→`abs load`;要主题→`abs query <词>`;要完整页清单→`abs index`(或直接读 `.brain/index.md`);要某页→`abs resolve` 拿到路径后**只读那一页**。
|
|
176
181
|
> 汇总多文件时用 `ctx_execute` 类工具在沙箱里处理,**只打印结论**。
|
|
177
182
|
4. **续 todo**:默认续 todo 分支 → 把顶部未完成项当当前任务开做。
|
|
178
183
|
5. **登记新任务**:有明确新任务而 todo 没有 → `abs todo add <id> --note 做什么` 再动工。
|
|
@@ -241,6 +246,9 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
|
|
|
241
246
|
|
|
242
247
|
1. **暂存线索**:`abs note`(或建 `sources/YYYY-MM-DD-slug.md`)记做了什么、改哪些文件、验证命令。
|
|
243
248
|
2. **抽规律**:值得留的 → `concepts/<kebab-slug>.md`:触发场景/❌表现/🛠根因+解法+验证。挂双链。
|
|
249
|
+
**同时检查:旧页里有没有被本次推翻的说法?** 有 → `abs supersede <旧页> --by <新页>`
|
|
250
|
+
(别删页;删了会让下个会话重踩同一个坑、重新记一遍)。核实过的新页把 `status` 改成 `active`
|
|
251
|
+
(`abs note` 落的页默认是 `draft`)。
|
|
244
252
|
3. **沉淀实体**:碰了重要未记录的事物 → `entities/<TitleCase>.md`。
|
|
245
253
|
4. **对账 todo**:做完的归位 Done(标结语+日期),做一半的补断点,卡住的改 `[滞留中]`。
|
|
246
254
|
5. **综合(可选)**:推进了选型/取舍 → `syntheses/`。
|
|
@@ -253,10 +261,28 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
|
|
|
253
261
|
|
|
254
262
|
## 知识页格式
|
|
255
263
|
|
|
256
|
-
统一 frontmatter:`tags / author / updated / status
|
|
264
|
+
统一 frontmatter:`tags / author / updated / status`。
|
|
257
265
|
`tags` 首标签 ∈ `entity|concept|source|synthesis|session-log`。`author` 与 `entities/<name>.md` 同名。
|
|
258
266
|
|
|
267
|
+
**`status` 三个值(生命周期,别写别的):**
|
|
268
|
+
|
|
269
|
+
| 值 | 含义 | 读取侧行为 |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| `active` | 当前有效(**缺字段默认就是它**,存量页无需改) | 正常展示 |
|
|
272
|
+
| `draft` | 待核实(`abs note` 新落的经验默认这个) | 展示但标注 `[draft 未核实]` |
|
|
273
|
+
| `superseded` | **已被推翻,别再依据它** | `query` 默认隐藏(计数据告知) |
|
|
274
|
+
|
|
275
|
+
**推翻一条经验**(不要删文件!):
|
|
276
|
+
```bash
|
|
277
|
+
abs supersede <页名> --by <取代它的新页> # 不写 --by 也行 = 单纯弃用,无替代
|
|
278
|
+
```
|
|
279
|
+
→ 把 `status` 改成 `superseded` 并写 `superseded-by`。**历史必须留**:删了会让下个会话重踩同一个坑、重新记一遍。
|
|
280
|
+
→ 核实后发现仍有效:把 `status` 改回 `active`(一行,可反悔)。
|
|
281
|
+
→ 拒写悬空引用:`--by` 指向不存在的页会直接拒绝。
|
|
282
|
+
|
|
259
283
|
- **每页必须有 `## 关联连接`**,用 `[[页面名]]` 链相关页 —— 严禁孤岛页。
|
|
284
|
+
**链路解释写在 `—` 后面**(如 `[[hook-throttle-alignment]] — 节流判据要对齐「真收尾」`),
|
|
285
|
+
这样 AI 不点开就知道该不该跟进;只写链点不写解释,等于没链。
|
|
260
286
|
- **命名即链接**:`[[Docker]]` → `entities/Docker.md`;`[[docker-prisma-429]]` → `concepts/`。不建别名层。
|
|
261
287
|
- **知识冲突**:不静默覆盖,加 `## 知识冲突` 两版都留、标来源时间,交人工裁决。
|
|
262
288
|
- 概念页骨架:`触发场景 / ❌表现(贴报错) / 🛠解法(根因+修复+验证命令) / 关联连接`。
|
|
@@ -265,9 +291,12 @@ Done 区由 `abs wrapup` 在会话结束时自动把「超 3 天且整天都已
|
|
|
265
291
|
|
|
266
292
|
- `abs query <词>` 检索(多词 OR)→ 读命中页 → 答用 `[[页面名]]` 标来源。
|
|
267
293
|
**代码问题(符号在哪/谁调用)不在 .brain,直接读源码**;.brain 只答"踩过什么坑/上次做到哪"。
|
|
268
|
-
|
|
294
|
+
已被推翻的经验默认不返回(`--all` 可看)—— 但若你确实需要拿旧结论对照,记得它已被推翻。
|
|
295
|
+
- `abs lint` 体检:死链/孤岛/**悬挂页**/缺 frontmatter/模板残留/超尺寸/sources 堆积/**超龄未提炼**/index 漏列/Rules 超限/**取代者悬空**/**draft 超龄**。
|
|
269
296
|
- `NO-INBOUND`:有出边但无人 `[[链接]]` 到你 = 挂在图上没人接(孤岛检查只抓"零出零入")。
|
|
270
297
|
- `SOURCE-UNDISTILLED`:source 超 7 天仍未链到任何 concept = 暂存了没归位。
|
|
298
|
+
- `SUPERSEDED-DANGLING`:`superseded-by` 指向的页不存在(删页后忘同步)。
|
|
299
|
+
- `DRAFT-STALE`:concepts 等长期停在 `draft` = 既没核实也没推翻,核实/推翻后各改一行。
|
|
271
300
|
|
|
272
301
|
## 三层分工
|
|
273
302
|
|
package/src/store.js
CHANGED
|
@@ -430,11 +430,164 @@ async function readFileOrNull(p) {
|
|
|
430
430
|
* 最大的单体膨胀源(Done 已折叠,见 collapseDone)。
|
|
431
431
|
*
|
|
432
432
|
* 续接真正需要的只是「有哪些分区、各多少页」(据此知道去哪找),不需要每页写了什么 ——
|
|
433
|
-
* 要那个用 `abs index
|
|
433
|
+
* 要那个用 `abs index`(完整原文件)或按词 `abs query`。
|
|
434
434
|
*
|
|
435
435
|
* `## Rules` 也已不在本函数输出——它由 rulesSection 在**上方**单独成段(需要正文);
|
|
436
436
|
* 这里再原样吐一遍 = 同一段硬规则在首屏出现两次(2026-09-13 实测发现)。
|
|
437
437
|
* 曾另有 `## Roadmap`:AI 自己写的方向总结,会被反复读到并带偏会话,已删。 */
|
|
438
|
+
// ---------- page id: 改名不改引用 ----------
|
|
439
|
+
// 问题:`.brain` 内部引用靠 [[slug]],而 slug 就是文件名 —— 改一次文件名,
|
|
440
|
+
// 所有指向它的链接静默变成 DEAD-LINK,只能靠 lint 事后抓。
|
|
441
|
+
// 解法(最小代价):页面 frontmatter 写一行 `id:`,建页时冻结。
|
|
442
|
+
// • 不发明新编号:id 默认等于建页时的 slug(人可读、可手写、无需迁移)
|
|
443
|
+
// • 旧页无 id → 回退用 slug,因此存量 31 页零迁移
|
|
444
|
+
// • 改名后 slug 变而 id 不变 → lint 报 ID-DRIFT,提示改成谁
|
|
445
|
+
// 为什么不上内容哈希/uuid:哈希一改内容就变(比文件名还不稳定),
|
|
446
|
+
// uuid 不可读不可手写且要全量迁移。slug 就是最合适的 id,只要不再跟文件名跑。
|
|
447
|
+
const ID_RE = /^id:\s*(.+)$/m;
|
|
448
|
+
|
|
449
|
+
/** 读页面 id;无 id 行则回退 slug(存量页零迁移)。 */
|
|
450
|
+
export function idOfPage(body, slug) {
|
|
451
|
+
const m = String(body || '').match(ID_RE);
|
|
452
|
+
return m ? m[1].trim() : slug;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** 给存量页补 id(只在缺时写),落 frontmatter。返回 'added' | 'exists' | 'no-fm'。 */
|
|
456
|
+
export async function backfillPageId(full, slug) {
|
|
457
|
+
const res = await editFile(full, (cur) => {
|
|
458
|
+
if (!cur || !cur.startsWith('---\n')) return SKIP;
|
|
459
|
+
if (ID_RE.test(cur.split('\n---')[0])) return SKIP; // 已有 id 不动
|
|
460
|
+
const end = cur.indexOf('\n---', 3);
|
|
461
|
+
if (end === -1) return SKIP;
|
|
462
|
+
return { text: `${cur.slice(0, end)}\nid: ${slug}${cur.slice(end)}` };
|
|
463
|
+
});
|
|
464
|
+
return res === SKIP ? 'exists' : 'added';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------- page status: 经验/知识页的生命周期 ----------
|
|
468
|
+
// 问题:经验写进去就永远躺在那里 —— 推翻时删不掉(skill 里写着"人工内容一律不覆盖",
|
|
469
|
+
// AI 不敢删)、读的时候又看不见(load 只给分区计数)→ 旧经验持续骗下一个会话。
|
|
470
|
+
// 解法:给已有的 `status:` 字段(字段本来就存在,20 页在用)定死三个值:
|
|
471
|
+
// active 当前有效(缺字段的默认值 —— 存量 22 页零迁移)
|
|
472
|
+
// superseded 已被推翻,别再依据它 —— 配 superseded-by 指向取代它的页
|
|
473
|
+
// draft 待核实(abs note 新落的经验就是这个)
|
|
474
|
+
// 关键:推翻 = 改一行 frontmatter,**不删文件不丢历史** —— AI 敢做,人也能反悔。
|
|
475
|
+
// 为什么不用新字段/新目录:字段已存在且有存量值,重命名会另起一套双轨(同 OPTS-DOUBLE-KEYS 之病)。
|
|
476
|
+
export const PAGE_STATUS = ['active', 'superseded', 'draft'];
|
|
477
|
+
const STATUS_RE = /^status:\s*(\S+)\s*$/m;
|
|
478
|
+
const SUPERSEDED_BY_RE = /^superseded-by:\s*(.+)$/m;
|
|
479
|
+
|
|
480
|
+
/** 读页面 status;无字段或是未知值时当 active(存量页零迁移)。 */
|
|
481
|
+
export function statusOfPage(body, frontmatter) {
|
|
482
|
+
const fm = frontmatter !== undefined
|
|
483
|
+
? frontmatter
|
|
484
|
+
: (String(body || '').match(/^---\n([\s\S]*?)\n---/) || ['', ''])[1];
|
|
485
|
+
const m = String(fm).match(STATUS_RE);
|
|
486
|
+
const v = m ? m[1].trim() : '';
|
|
487
|
+
return PAGE_STATUS.includes(v) ? v : 'active';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** 读 superseded-by(只在 status=superseded 时有意义)。无则空串。 */
|
|
491
|
+
export function supersededByOf(body) {
|
|
492
|
+
const m = String(body || '').match(SUPERSEDED_BY_RE);
|
|
493
|
+
return m ? m[1].trim() : '';
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ---------- supersede: 标记一条经验被推翻(回退的写入端) ----------
|
|
497
|
+
// 为什么是标记而不是删除:
|
|
498
|
+
// ① 删除后下一个会话会重新踩同一个坑并重新记一遍(历史本身是资产)
|
|
499
|
+
// ② AI 不敢删(人工内容不覆盖),但敢改一行 frontmatter
|
|
500
|
+
// ③ 反悔只需把 status 改回 active
|
|
501
|
+
export async function cmdSupersede({ dir, refs, by }) {
|
|
502
|
+
const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
|
|
503
|
+
if (!list.length) return '用法: abs supersede <页名或id> [更多…] [--by <取代它的页>] — 标记经验已失效(不删文件)';
|
|
504
|
+
let root;
|
|
505
|
+
try {
|
|
506
|
+
root = await requireBrain(dir || process.cwd());
|
|
507
|
+
} catch {
|
|
508
|
+
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
509
|
+
}
|
|
510
|
+
const byRef = String(by || '').trim();
|
|
511
|
+
// 取代者必须先存在 —— 否则写下一个永远悬空的引用(lint 会报,不如现在拒)。
|
|
512
|
+
if (byRef) {
|
|
513
|
+
const target = await resolvePage(root, byRef);
|
|
514
|
+
if (!target) return `✗ --by ${byRef}: 图谱里没有这页(先用 abs resolve 确认页名)`;
|
|
515
|
+
}
|
|
516
|
+
const out = [];
|
|
517
|
+
// 取代者 slug 在循环外解析一次(锁内 mutator 不能 await)
|
|
518
|
+
const bySlug = byRef ? (await resolvePage(root, byRef)).slug : '';
|
|
519
|
+
for (const r of list) {
|
|
520
|
+
const hit = await resolvePage(root, r);
|
|
521
|
+
if (!hit) { out.push(`✗ ${r}: 未找到(试 abs query <词> 或 abs index 看清单)`); continue; }
|
|
522
|
+
const res = await editFile(hit.full, (cur) => {
|
|
523
|
+
if (!cur || !cur.startsWith('---\n')) return SKIP;
|
|
524
|
+
const end = cur.indexOf('\n---', 3);
|
|
525
|
+
if (end === -1) return SKIP;
|
|
526
|
+
let fm = cur.slice(0, end);
|
|
527
|
+
// 幂等:已是 superseded 且 superseded-by 一致 → 不写盘
|
|
528
|
+
const curSt = statusOfPage('', fm);
|
|
529
|
+
const curBy = supersededByOf(cur);
|
|
530
|
+
if (curSt === 'superseded' && curBy === bySlug) return SKIP;
|
|
531
|
+
// 去掉旧的 superseded-by(不论换不换取代者,旧值都作废)
|
|
532
|
+
fm = fm.replace(/\nsuperseded-by:.*(?=\n|$)/g, '');
|
|
533
|
+
fm = STATUS_RE.test(fm)
|
|
534
|
+
? fm.replace(STATUS_RE, 'status: superseded')
|
|
535
|
+
: `${fm}\nstatus: superseded`;
|
|
536
|
+
// superseded-by 用 slug(不是 id):人直接能按名找页,lint 能直接比对文件名
|
|
537
|
+
if (bySlug) fm = `${fm}\nsuperseded-by: ${bySlug}`;
|
|
538
|
+
return { text: fm + cur.slice(end) };
|
|
539
|
+
});
|
|
540
|
+
out.push(res === SKIP
|
|
541
|
+
? `= ${hit.slug}: 已是 superseded(无变化)`
|
|
542
|
+
: `✓ ${hit.slug} → superseded${byRef ? ` (被 [[${byRef}]] 取代)` : ''}`);
|
|
543
|
+
}
|
|
544
|
+
return out.join('\n');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** 把 id(或 slug)解析为页面路径。命中返回 {slug, dir, full, id},否则 null。 */
|
|
548
|
+
export async function resolvePage(root, idOrSlug) {
|
|
549
|
+
const want = String(idOrSlug || '').trim();
|
|
550
|
+
if (!want) return null;
|
|
551
|
+
const vault = brainPath(root);
|
|
552
|
+
for (const d of PAGE_DIRS) {
|
|
553
|
+
const p = join(vault, d);
|
|
554
|
+
let files;
|
|
555
|
+
try { files = await fs.readdir(p); } catch { continue; }
|
|
556
|
+
for (const f of files) {
|
|
557
|
+
if (!f.endsWith('.md') || f.startsWith('_')) continue;
|
|
558
|
+
const slug = f.replace(/\.md$/, '');
|
|
559
|
+
const full = join(p, f);
|
|
560
|
+
// slug 直接命中就够快(绝大多数调用走这条),不命中才去读 frontmatter 比 id
|
|
561
|
+
if (slug === want) return { slug, dir: d, full, id: want };
|
|
562
|
+
const body = await fs.readFile(full, 'utf8').catch(() => '');
|
|
563
|
+
const id = idOfPage(body, slug);
|
|
564
|
+
if (id === want) return { slug, dir: d, full, id };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------- resolve: id/slug → 页面路径(引用的反查端) ----------
|
|
571
|
+
// 配合 frontmatter 的 id: 使用。页改名后 id 不变,靠本命令仍能找回来。
|
|
572
|
+
export async function cmdResolve({ dir, refs }) {
|
|
573
|
+
const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
|
|
574
|
+
if (!list.length) return '用法: abs resolve <id-or-slug> [更多…] — 按 id/页面名反查路径';
|
|
575
|
+
let root;
|
|
576
|
+
try {
|
|
577
|
+
root = await requireBrain(dir || process.cwd());
|
|
578
|
+
} catch {
|
|
579
|
+
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
580
|
+
}
|
|
581
|
+
const lines = [];
|
|
582
|
+
for (const r of list) {
|
|
583
|
+
const hit = await resolvePage(root, r);
|
|
584
|
+
lines.push(hit
|
|
585
|
+
? `✓ ${r} → ${BRAIN_DIR}/${hit.dir}/${hit.slug}.md${hit.id !== hit.slug ? ` (id=${hit.id})` : ''}`
|
|
586
|
+
: `✗ ${r}: 未找到(试 abs index 看完整清单,或 abs query <词> 全文搜)`);
|
|
587
|
+
}
|
|
588
|
+
return lines.join('\n');
|
|
589
|
+
}
|
|
590
|
+
|
|
438
591
|
export function collapseIndex(text) {
|
|
439
592
|
const s = String(text || '').trim();
|
|
440
593
|
if (!s) return '';
|
|
@@ -794,7 +947,7 @@ export async function cmdShow({ dir, view, full }) {
|
|
|
794
947
|
// ---------- query: 检索知识图谱(多词 OR,扫全 .md 页) ----------
|
|
795
948
|
const KNOWN_SLUG_HINT = /模板残留|\[\[slug\]\]/;
|
|
796
949
|
|
|
797
|
-
export async function cmdQuery({ dir, terms }) {
|
|
950
|
+
export async function cmdQuery({ dir, terms, includeSuperseded }) {
|
|
798
951
|
const words = (terms || []).map((w) => String(w).trim()).filter(Boolean);
|
|
799
952
|
if (!words.length) {
|
|
800
953
|
return '用法: abs query <词1> [词2 …] — 多词 OR 检索 .brain/ 全部知识页';
|
|
@@ -824,20 +977,42 @@ export async function cmdQuery({ dir, terms }) {
|
|
|
824
977
|
// 作者从本页 frontmatter 读(权威来源)。不在 index 行里重复 ——
|
|
825
978
|
// index 行是覆盖式更新的,作者会从"创建者"漂成"最后改的人"。
|
|
826
979
|
const au = body.match(/^author:\s*(.+)$/m);
|
|
980
|
+
const st = statusOfPage(body);
|
|
981
|
+
// 被推翻的经验默认不出现在检索结果里 —— 它的存在意义是"别再用它",
|
|
982
|
+
// 而不是回答"我上次怎么解決 X"(那会拿到一个已知错误的答案)。
|
|
983
|
+
// 但它不是静默消失:计数里告知还有几条被隐藏(带 --all 能看)。
|
|
984
|
+
if (st === 'superseded' && !includeSuperseded) {
|
|
985
|
+
hits.push({ hidden: true, slug: f.replace(/\.md$/, '') });
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
827
988
|
hits.push({
|
|
828
989
|
full, slug: f.replace(/\.md$/, ''), matched,
|
|
829
990
|
author: au ? au[1].trim() : '',
|
|
991
|
+
id: idOfPage(body, f.replace(/\.md$/, '')),
|
|
992
|
+
status: st,
|
|
993
|
+
supersededBy: supersededByOf(body),
|
|
830
994
|
snippet: firstHitLine(body, words),
|
|
831
995
|
});
|
|
832
996
|
}
|
|
833
997
|
}
|
|
834
998
|
}
|
|
835
|
-
|
|
836
|
-
const
|
|
999
|
+
const hidden = hits.filter((h) => h.hidden).length;
|
|
1000
|
+
const shown = hits.filter((h) => !h.hidden);
|
|
1001
|
+
if (!shown.length) {
|
|
1002
|
+
const extra = hidden ? `(另外 ${hidden} 页已标记 superseded,用 abs query ${words.join(' ')} --all 查看)` : '';
|
|
1003
|
+
return `query [${words.join(', ')}]: 无命中。${extra}用 abs lint 看图谱健康;首次使用先 abs init。`;
|
|
1004
|
+
}
|
|
1005
|
+
const lines = shown.map((h) => {
|
|
837
1006
|
const by = h.author ? ` @${h.author}` : '';
|
|
838
|
-
|
|
1007
|
+
// id 只在≠slug 时显示 —— 相同时再印一遗就是纯噪音(绝大多数页)。
|
|
1008
|
+
// 目的:让 AI 拿到一个改名也不漂的引用句柄(abs resolve <id> 能反查回来)。
|
|
1009
|
+
const id = h.id && h.id !== h.slug ? ` [id: ${h.id}]` : '';
|
|
1010
|
+
// draft = 未经核实。不拦使用,但必须让 AI 知道这是它自己没验证过的。
|
|
1011
|
+
const st = h.status === 'draft' ? ' [draft 未核实]' : '';
|
|
1012
|
+
return `📄 ${h.slug}${by}${id}${st} (命中: ${h.matched.join(', ')})\n ${h.snippet}`;
|
|
839
1013
|
});
|
|
840
|
-
|
|
1014
|
+
const tail = hidden ? [``, `(${hidden} 页 superseded 已隐藏;--all 可看)`] : [];
|
|
1015
|
+
return [`query [${words.join(', ')}] → ${shown.length} 页:`, '', ...lines, ...tail].join('\n');
|
|
841
1016
|
}
|
|
842
1017
|
|
|
843
1018
|
function firstHitLine(body, words) {
|
|
@@ -883,6 +1058,7 @@ export async function cmdNote({ dir, text, tags }) {
|
|
|
883
1058
|
const body = [
|
|
884
1059
|
'---',
|
|
885
1060
|
`tags: [${fmTags}]`,
|
|
1061
|
+
`id: ${file.replace(/\.md$/, '')}`,
|
|
886
1062
|
`author: ${who}`,
|
|
887
1063
|
`updated: ${today()}`,
|
|
888
1064
|
'status: draft',
|
|
@@ -924,6 +1100,7 @@ export async function ensurePersonPage(root, name) {
|
|
|
924
1100
|
const body = [
|
|
925
1101
|
'---',
|
|
926
1102
|
'tags: [entity, person]',
|
|
1103
|
+
`id: ${nm}`,
|
|
927
1104
|
`author: ${nm}`,
|
|
928
1105
|
`updated: ${today()}`,
|
|
929
1106
|
'status: draft',
|
|
@@ -1002,6 +1179,19 @@ export async function cmdLint({ dir }) {
|
|
|
1002
1179
|
|
|
1003
1180
|
for (const pg of pages) {
|
|
1004
1181
|
if (!pg.hasFrontmatter) issues.push(`NO-FRONTMATTER: ${pg.rel}`);
|
|
1182
|
+
// ID-DRIFT: 页面写了 id 但已跟文件名(slug)不一致 = 改过名或改过 id。
|
|
1183
|
+
// 不是错误(id 就是用来固定身份的),但必须提示:[[slug]] 形式的引用指向的是**文件名**,
|
|
1184
|
+
// 改名后旧引用全变 DEAD-LINK;本检查让「静默断链」变成「一条可执行的提示」。
|
|
1185
|
+
// 优先看「有没有别的页 id 指向旧名」→ 那才是真正的改名现场。
|
|
1186
|
+
if (pg.hasFrontmatter) {
|
|
1187
|
+
const id = idOfPage(pg.body, pg.slug);
|
|
1188
|
+
// 反向查:有别的页声明 id = 本页 slug,说明本页是从那个 id 改名过来的。
|
|
1189
|
+
// 这种才是「改名没同步引用」的真信号;单纯 id≠slug 也可能只是手写的 id。
|
|
1190
|
+
if (id !== pg.slug) {
|
|
1191
|
+
issues.push(`ID-DRIFT: ${pg.rel} (frontmatter id=${id} ≠ 文件名 ${pg.slug};` +
|
|
1192
|
+
`引用请用 [[${id}]] 或改回文件名)`);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1005
1195
|
for (const ln of pg.links) {
|
|
1006
1196
|
// 两类都不是真链接,只报 TEMPLATE-LINK(且不短路就会再报一次 DEAD-LINK,同一条报两遍):
|
|
1007
1197
|
// ① 模板占位: `[[页面名]]` / `[[slug]]` / `[[Name]]` —— 模板没填
|
|
@@ -1064,6 +1254,32 @@ export async function cmdLint({ dir }) {
|
|
|
1064
1254
|
}
|
|
1065
1255
|
}
|
|
1066
1256
|
|
|
1257
|
+
// SUPERSEDED-DANGLING: superseded 页声明的取代者也必须存在。
|
|
1258
|
+
// 它跟 DEAD-LINK 同性质(指向不存在的页),但后果更重:
|
|
1259
|
+
// 读者被引导去找一个不存在的"新版本",比单纯断链更容易让人以为"没新页就是没替代"。
|
|
1260
|
+
for (const pg of pages) {
|
|
1261
|
+
if (pg.status !== 'superseded') continue;
|
|
1262
|
+
if (!pg.supersededBy) continue; // 无取代者也是合法状态(就是弃用,没替代)
|
|
1263
|
+
const by = pg.supersededBy.replace(/^\[\[|\]\]$/g, '').trim();
|
|
1264
|
+
if (!names.has(by)) {
|
|
1265
|
+
issues.push(`SUPERSEDED-DANGLING: ${pg.rel} (superseded-by: ${by} —— 该页不存在,删页后未同步)`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// DRAFT-STALE: draft 停太久 = 既没核实也没被推翻,实质是写了没人看的堆积。
|
|
1270
|
+
// 不报 sources(它们由 SOURCE-UNDISTILLED 管),只报 concepts/entities/syntheses ——
|
|
1271
|
+
// 那些页是"应该已经被确认过"的长期资产,长期 draft 说明核实环节缺位。
|
|
1272
|
+
for (const pg of pages) {
|
|
1273
|
+
if (pg.status !== 'draft') continue;
|
|
1274
|
+
if (pg.dir === 'sources') continue;
|
|
1275
|
+
let ageMs = 0;
|
|
1276
|
+
try { ageMs = Date.now() - (await fs.stat(join(root, pg.rel))).mtimeMs; } catch { continue; }
|
|
1277
|
+
if (ageMs > DRAFT_STALE_DAYS * 86400 * 1000) {
|
|
1278
|
+
const days = Math.floor(ageMs / 86400000);
|
|
1279
|
+
issues.push(`DRAFT-STALE: ${pg.rel}(已 ${days} 天停在 draft;核实后改 status: active,推翻则 abs supersede)`);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1067
1283
|
// Rules 区:它的价值在“少而重”,且不被折叠(load 每次都全量读)。
|
|
1068
1284
|
// 无上限增长 = 把 load 又撑回去(同 Done / index 清单的膨胀根因)。
|
|
1069
1285
|
{
|
|
@@ -1144,6 +1360,9 @@ const PAGE_MAX_BYTES = 8 * 1024;
|
|
|
1144
1360
|
// source 页超龄未提炼的天数阈值(SOURCE-UNDISTILLED)。
|
|
1145
1361
|
// 7 天 = 跨过至少一个完整工作周还没人提炼,基本等于被遗忘。
|
|
1146
1362
|
const SOURCE_STALE_DAYS = 7;
|
|
1363
|
+
// draft 超龄阈值:新落的经验(abs note)默认 draft,指的是"还没核实过"。
|
|
1364
|
+
// 长期停在 draft = 既没被核实也没被推翻,属"写了没人看"的堆积 —— 不自动改,只报。
|
|
1365
|
+
const DRAFT_STALE_DAYS = 14;
|
|
1147
1366
|
|
|
1148
1367
|
async function listPages(vault) {
|
|
1149
1368
|
let indexText = '';
|
|
@@ -1178,6 +1397,8 @@ async function listPages(vault) {
|
|
|
1178
1397
|
lines: body.split('\n').length,
|
|
1179
1398
|
bytes: Buffer.byteLength(body, 'utf8'),
|
|
1180
1399
|
indexed: indexText.includes(`[[${f.replace(/\.md$/, '')}]]`),
|
|
1400
|
+
status: statusOfPage(body, fm ? fm[1] : ''),
|
|
1401
|
+
supersededBy: supersededByOf(body),
|
|
1181
1402
|
});
|
|
1182
1403
|
}
|
|
1183
1404
|
}
|