@fanchao8609/agent_brain_sync 1.15.3 → 1.16.2
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 +1 -1
- package/bin/abs.js +8 -11
- package/bin/mcp.js +7 -7
- package/hooks/abs.pi.ts +121 -4
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +1 -1
- package/src/lint.js +6 -7
- package/src/note.js +275 -0
- package/src/page.js +263 -0
- package/src/store.js +67 -552
- package/src/text.js +37 -0
- package/src/todo.js +23 -4
package/src/page.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// src/page.js — 知识页的生命周期:id 冻结 / 状态机 / 推翻 / 待确认队列 / 引用反查。
|
|
2
|
+
//
|
|
3
|
+
// 从 store.js 拆出(2026-10-05):原单文件 1509 行 / 18 职责,读改都吃力。
|
|
4
|
+
// 拆法照 rpiv-todo 的规模(单文件 ≤300 行)。本文件只放"对单页的操作",
|
|
5
|
+
// 不含加载/看板/任务那几块(那些留在 store.js)。
|
|
6
|
+
//
|
|
7
|
+
// 依赖方向:page.js ← store.js(不可反向)。store.js 里的 cmdSupersede /
|
|
8
|
+
// cmdReview / cmdResolve 会 re-export 本文件的实现,保持既有调用面不变。
|
|
9
|
+
import { promises as fs } from 'node:fs';
|
|
10
|
+
import { join, resolve, dirname } from 'node:path';
|
|
11
|
+
import { requireBrain, brainPath, BRAIN_DIR } from './index.js';
|
|
12
|
+
import { editFile, SKIP } from './lock.js';
|
|
13
|
+
// PAGE_DIRS / listPages 早先就在 lint.js(页面清单遍历是 lint 的职责),此处复用而非重写。
|
|
14
|
+
import { PAGE_DIRS, listPages } from './lint.js';
|
|
15
|
+
|
|
16
|
+
// ---------- page id: 改名不改引用 ----------
|
|
17
|
+
// 问题:`.brain` 内部引用靠 [[slug]],而 slug 就是文件名 —— 改一次文件名,
|
|
18
|
+
// 所有指向它的链接静默变成 DEAD-LINK,只能靠 lint 事后抓。
|
|
19
|
+
// 解法(最小代价):页面 frontmatter 写一行 `id:`,建页时冻结。
|
|
20
|
+
// • 不发明新编号:id 默认等于建页时的 slug(人可读、可手写、无需迁移)
|
|
21
|
+
// • 旧页无 id → 回退用 slug,因此存量 31 页零迁移
|
|
22
|
+
// • 改名后 slug 变而 id 不变 → lint 报 ID-DRIFT,提示改成谁
|
|
23
|
+
// 为什么不上内容哈希/uuid:哈希一改内容就变(比文件名还不稳定),
|
|
24
|
+
// uuid 不可读不可手写且要全量迁移。slug 就是最合适的 id,只要不再跟文件名跑。
|
|
25
|
+
const ID_RE = /^id:\s*(.+)$/m;
|
|
26
|
+
|
|
27
|
+
/** 读页面 id;无 id 行则回退 slug(存量页零迁移)。 */
|
|
28
|
+
export function idOfPage(body, slug) {
|
|
29
|
+
const m = String(body || '').match(ID_RE);
|
|
30
|
+
return m ? m[1].trim() : slug;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 给存量页补 id(只在缺时写),落 frontmatter。返回 'added' | 'exists' | 'no-fm'。 */
|
|
34
|
+
export async function backfillPageId(full, slug) {
|
|
35
|
+
const res = await editFile(full, (cur) => {
|
|
36
|
+
if (!cur || !cur.startsWith('---\n')) return SKIP;
|
|
37
|
+
if (ID_RE.test(cur.split('\n---')[0])) return SKIP; // 已有 id 不动
|
|
38
|
+
const end = cur.indexOf('\n---', 3);
|
|
39
|
+
if (end === -1) return SKIP;
|
|
40
|
+
return { text: `${cur.slice(0, end)}\nid: ${slug}${cur.slice(end)}` };
|
|
41
|
+
});
|
|
42
|
+
return res === SKIP ? 'exists' : 'added';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------- page status: 经验/知识页的生命周期 ----------
|
|
46
|
+
// 问题:经验写进去就永远躺在那里 —— 推翻时删不掉(skill 里写着"人工内容一律不覆盖",
|
|
47
|
+
// AI 不敢删)、读的时候又看不见(load 只给分区计数)→ 旧经验持续骗下一个会话。
|
|
48
|
+
// 解法:给已有的 `status:` 字段(字段本来就存在,20 页在用)定死三个值:
|
|
49
|
+
// active 当前有效(缺字段的默认值 —— 存量 22 页零迁移)
|
|
50
|
+
// superseded 已被推翻,别再依据它 —— 配 superseded-by 指向取代它的页
|
|
51
|
+
// draft 待核实(abs note 新落的经验就是这个)
|
|
52
|
+
// 关键:推翻 = 改一行 frontmatter,**不删文件不丢历史** —— AI 敢做,人也能反悔。
|
|
53
|
+
// 为什么不用新字段/新目录:字段已存在且有存量值,重命名会另起一套双轨(同 OPTS-DOUBLE-KEYS 之病)。
|
|
54
|
+
export const PAGE_STATUS = ['active', 'superseded', 'draft'];
|
|
55
|
+
const STATUS_RE = /^status:\s*(\S+)\s*$/m;
|
|
56
|
+
const SUPERSEDED_BY_RE = /^superseded-by:\s*(.+)$/m;
|
|
57
|
+
|
|
58
|
+
/** 读页面 status;无字段或是未知值时当 active(存量页零迁移)。 */
|
|
59
|
+
export function statusOfPage(body, frontmatter) {
|
|
60
|
+
const fm = frontmatter !== undefined
|
|
61
|
+
? frontmatter
|
|
62
|
+
: (String(body || '').match(/^---\n([\s\S]*?)\n---/) || ['', ''])[1];
|
|
63
|
+
const m = String(fm).match(STATUS_RE);
|
|
64
|
+
const v = m ? m[1].trim() : '';
|
|
65
|
+
return PAGE_STATUS.includes(v) ? v : 'active';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 读 superseded-by(只在 status=superseded 时有意义)。无则空串。 */
|
|
69
|
+
export function supersededByOf(body) {
|
|
70
|
+
const m = String(body || '').match(SUPERSEDED_BY_RE);
|
|
71
|
+
return m ? m[1].trim() : '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------- supersede: 标记一条经验被推翻(回退的写入端) ----------
|
|
75
|
+
// 为什么是标记而不是删除:
|
|
76
|
+
// ① 删除后下一个会话会重新踩同一个坑并重新记一遍(历史本身是资产)
|
|
77
|
+
// ② AI 不敢删(人工内容不覆盖),但敢改一行 frontmatter
|
|
78
|
+
// ③ 反悔只需把 status 改回 active
|
|
79
|
+
export async function cmdSupersede({ dir, refs, by }) {
|
|
80
|
+
const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
|
|
81
|
+
if (!list.length) return '用法: abs supersede <页名或id> [更多…] [--by <取代它的页>] — 标记经验已失效(不删文件)';
|
|
82
|
+
let root;
|
|
83
|
+
try {
|
|
84
|
+
root = await requireBrain(dir || process.cwd());
|
|
85
|
+
} catch {
|
|
86
|
+
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
87
|
+
}
|
|
88
|
+
const byRef = String(by || '').trim();
|
|
89
|
+
// 取代者必须先存在 —— 否则写下一个永远悬空的引用(lint 会报,不如现在拒)。
|
|
90
|
+
if (byRef) {
|
|
91
|
+
const target = await resolvePage(root, byRef);
|
|
92
|
+
if (!target) return `✗ --by ${byRef}: 图谱里没有这页(先用 abs resolve 确认页名)`;
|
|
93
|
+
}
|
|
94
|
+
const out = [];
|
|
95
|
+
// 取代者 slug 在循环外解析一次(锁内 mutator 不能 await)
|
|
96
|
+
const bySlug = byRef ? (await resolvePage(root, byRef)).slug : '';
|
|
97
|
+
for (const r of list) {
|
|
98
|
+
const hit = await resolvePage(root, r);
|
|
99
|
+
if (!hit) { out.push(`✗ ${r}: 未找到(试 abs query <词> 或 abs index 看清单)`); continue; }
|
|
100
|
+
const res = await editFile(hit.full, (cur) => {
|
|
101
|
+
if (!cur || !cur.startsWith('---\n')) return SKIP;
|
|
102
|
+
const end = cur.indexOf('\n---', 3);
|
|
103
|
+
if (end === -1) return SKIP;
|
|
104
|
+
let fm = cur.slice(0, end);
|
|
105
|
+
// 幂等:已是 superseded 且 superseded-by 一致 → 不写盘
|
|
106
|
+
const curSt = statusOfPage('', fm);
|
|
107
|
+
const curBy = supersededByOf(cur);
|
|
108
|
+
if (curSt === 'superseded' && curBy === bySlug) return SKIP;
|
|
109
|
+
// 去掉旧的 superseded-by(不论换不换取代者,旧值都作废)
|
|
110
|
+
fm = fm.replace(/\nsuperseded-by:.*(?=\n|$)/g, '');
|
|
111
|
+
fm = STATUS_RE.test(fm)
|
|
112
|
+
? fm.replace(STATUS_RE, 'status: superseded')
|
|
113
|
+
: `${fm}\nstatus: superseded`;
|
|
114
|
+
// superseded-by 用 slug(不是 id):人直接能按名找页,lint 能直接比对文件名
|
|
115
|
+
if (bySlug) fm = `${fm}\nsuperseded-by: ${bySlug}`;
|
|
116
|
+
return { text: fm + cur.slice(end) };
|
|
117
|
+
});
|
|
118
|
+
out.push(res === SKIP
|
|
119
|
+
? `= ${hit.slug}: 已是 superseded(无变化)`
|
|
120
|
+
: `✓ ${hit.slug} → superseded${byRef ? ` (被 [[${byRef}]] 取代)` : ''}`);
|
|
121
|
+
}
|
|
122
|
+
return out.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 把 id(或 slug)解析为页面路径。命中返回 {slug, dir, full, id},否则 null。 */
|
|
126
|
+
export async function resolvePage(root, idOrSlug) {
|
|
127
|
+
const want = String(idOrSlug || '').trim();
|
|
128
|
+
if (!want) return null;
|
|
129
|
+
const vault = brainPath(root);
|
|
130
|
+
for (const d of PAGE_DIRS) {
|
|
131
|
+
const p = join(vault, d);
|
|
132
|
+
let files;
|
|
133
|
+
try { files = await fs.readdir(p); } catch { continue; }
|
|
134
|
+
for (const f of files) {
|
|
135
|
+
if (!f.endsWith('.md') || f.startsWith('_')) continue;
|
|
136
|
+
const slug = f.replace(/\.md$/, '');
|
|
137
|
+
const full = join(p, f);
|
|
138
|
+
// slug 直接命中就够快(绝大多数调用走这条),不命中才去读 frontmatter 比 id
|
|
139
|
+
if (slug === want) return { slug, dir: d, full, id: want };
|
|
140
|
+
const body = await fs.readFile(full, 'utf8').catch(() => '');
|
|
141
|
+
const id = idOfPage(body, slug);
|
|
142
|
+
if (id === want) return { slug, dir: d, full, id };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------- review: 待确认页队列(draft → active/superseded) ----------
|
|
149
|
+
// 为何需要:abs note 落的是 status: draft(未经核实),但之前没有「确认」这一步 ——
|
|
150
|
+
// draft 只是标签,没人管,经验就永远停在「待核实」状态,从不正式化。
|
|
151
|
+
// 借鉴 TencentDB 的 review/route 治理环节:提取后必经审查,防止脏知识进入正式图谱。
|
|
152
|
+
// 本命令只做「把 draft 显式升为 active 或否决为 superseded」,不替人判断内容好坏。
|
|
153
|
+
// 动作收口在一处(editFile 锁内),并发安全同 supersede。
|
|
154
|
+
export async function cmdReview({ dir, refs, action }) {
|
|
155
|
+
let root;
|
|
156
|
+
try {
|
|
157
|
+
root = await requireBrain(dir || process.cwd());
|
|
158
|
+
} catch {
|
|
159
|
+
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
160
|
+
}
|
|
161
|
+
const act = String(action || '').toLowerCase();
|
|
162
|
+
if (act && !['accept', 'reject'].includes(act)) {
|
|
163
|
+
return '用法: abs review [--accept <页名…>] [--reject <页名…>] — 无参数列出全部 draft 页';
|
|
164
|
+
}
|
|
165
|
+
// 无动作 → 列出所有 draft 页(待确认队列)
|
|
166
|
+
if (!act) {
|
|
167
|
+
const pages = await listPages(brainPath(root));
|
|
168
|
+
// 只扫经验/知识目录(concepts/sources)。entities 是人页、sessions 是日志,
|
|
169
|
+
// 它们不是「待核实的经验」,不该进 review 队列(拉进来会把人页/日志当经验误确认)。
|
|
170
|
+
const REVIEW_DIRS = ['concepts', 'sources'];
|
|
171
|
+
const drafts = pages.filter((p) => REVIEW_DIRS.includes(p.dir) && statusOfPage(p.body) === 'draft');
|
|
172
|
+
if (!drafts.length) return '✓ 没有待确认的 draft 页。';
|
|
173
|
+
const lines = drafts.map((p) => {
|
|
174
|
+
const t = p.body.match(/^#\s*(.+)$/m);
|
|
175
|
+
const title = t ? t[1].trim() : p.slug;
|
|
176
|
+
return ` [draft] ${p.slug} — ${title}`;
|
|
177
|
+
});
|
|
178
|
+
return [
|
|
179
|
+
`待确认 draft 页 ${drafts.length} 条:`,
|
|
180
|
+
...lines,
|
|
181
|
+
'',
|
|
182
|
+
'确认: abs review --accept <页名> [更多…] 否决: abs review --reject <页名> [更多…]',
|
|
183
|
+
].join('\n');
|
|
184
|
+
}
|
|
185
|
+
// 有动作 → 对每个 ref 改 status
|
|
186
|
+
const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
|
|
187
|
+
if (!list.length) return `✗ --${act} 需要至少一个页名。用法: abs review --${act} <页名…>`;
|
|
188
|
+
const target = act === 'accept' ? 'active' : 'superseded';
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const r of list) {
|
|
191
|
+
const hit = await resolvePage(root, r);
|
|
192
|
+
if (!hit) { out.push(`✗ ${r}: 未找到(试 abs review 看清单)`); continue; }
|
|
193
|
+
const res = await editFile(hit.full, (cur) => {
|
|
194
|
+
if (!cur || !cur.startsWith('---\n')) return SKIP;
|
|
195
|
+
const end = cur.indexOf('\n---', 3);
|
|
196
|
+
if (end === -1) return SKIP;
|
|
197
|
+
let fm = cur.slice(0, end);
|
|
198
|
+
const curSt = statusOfPage('', fm);
|
|
199
|
+
// 幂等:已是目标状态 → 不写盘
|
|
200
|
+
if (curSt === target) return SKIP;
|
|
201
|
+
fm = STATUS_RE.test(fm)
|
|
202
|
+
? fm.replace(STATUS_RE, `status: ${target}`)
|
|
203
|
+
: `${fm}\nstatus: ${target}`;
|
|
204
|
+
return { text: fm + cur.slice(end) };
|
|
205
|
+
});
|
|
206
|
+
out.push(res === SKIP
|
|
207
|
+
? `= ${hit.slug}: 已是 ${target}(无变化)`
|
|
208
|
+
: `✓ ${hit.slug} → ${target}`);
|
|
209
|
+
}
|
|
210
|
+
return out.join('\n');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------- resolve: id/slug → 页面路径(引用的反查端) ----------
|
|
214
|
+
// 配合 frontmatter 的 id: 使用。页改名后 id 不变,靠本命令仍能找回来。
|
|
215
|
+
export async function cmdResolve({ dir, refs }) {
|
|
216
|
+
const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
|
|
217
|
+
if (!list.length) return '用法: abs resolve <id-or-slug> [更多…] — 按 id/页面名反查路径';
|
|
218
|
+
let root;
|
|
219
|
+
try {
|
|
220
|
+
root = await requireBrain(dir || process.cwd());
|
|
221
|
+
} catch {
|
|
222
|
+
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
223
|
+
}
|
|
224
|
+
const lines = [];
|
|
225
|
+
for (const r of list) {
|
|
226
|
+
const hit = await resolvePage(root, r);
|
|
227
|
+
lines.push(hit
|
|
228
|
+
? `✓ ${r} → ${BRAIN_DIR}/${hit.dir}/${hit.slug}.md${hit.id !== hit.slug ? ` (id=${hit.id})` : ''}`
|
|
229
|
+
: `✗ ${r}: 未找到(试 abs index 看完整清单,或 abs query <词> 全文搜)`);
|
|
230
|
+
}
|
|
231
|
+
return lines.join('\n');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function collapseIndex(text) {
|
|
235
|
+
const s = String(text || '').trim();
|
|
236
|
+
if (!s) return '';
|
|
237
|
+
const out = [];
|
|
238
|
+
let mode = null; // null=逐行透传(文件头);字符串=当前在计数的分区名
|
|
239
|
+
let n = 0; // mode 非 null 时的清单行计数
|
|
240
|
+
const flush = () => {
|
|
241
|
+
if (mode !== null) out.push(n ? `## ${mode}(${n} 页)` : `## ${mode}`);
|
|
242
|
+
mode = null;
|
|
243
|
+
n = 0;
|
|
244
|
+
};
|
|
245
|
+
let skipping = false; // 跳过 Rules 正文(已在上方单独成段)
|
|
246
|
+
for (const l of s.split('\n')) {
|
|
247
|
+
const m = l.match(/^##\s+(.+?)\s*$/);
|
|
248
|
+
if (m) {
|
|
249
|
+
flush();
|
|
250
|
+
const name = m[1].trim();
|
|
251
|
+
skipping = /Rules?|规则/i.test(name);
|
|
252
|
+
if (skipping) continue;
|
|
253
|
+
mode = name; // 页面清单分区:只计数
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (skipping) continue;
|
|
257
|
+
if (mode === null) out.push(l); // 透传区(含文件头 H1)
|
|
258
|
+
else if (l.trim().startsWith('-')) n++;
|
|
259
|
+
}
|
|
260
|
+
flush();
|
|
261
|
+
// 文件头与首个分区之间可能因跳过 Rules 而留下多余空行
|
|
262
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
263
|
+
}
|