@fanchao8609/agent_brain_sync 1.20.1 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/bin/abs.js +41 -1
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +21 -2
- package/src/brain/capture.js +175 -0
- package/src/brain/db-cmd.js +336 -0
- package/src/brain/db.js +583 -0
- package/src/brain/evolve.js +123 -0
- package/src/brain/gate.js +19 -0
- package/src/brain/init.js +135 -0
- package/src/brain/layers.js +112 -0
- package/src/brain/load.js +273 -0
- package/src/brain/maintain.js +199 -0
- package/src/brain/rules.js +98 -0
- package/src/brain/shape.js +102 -0
- package/src/brain/summon-cmd.js +62 -0
- package/src/brain/summon.js +303 -0
- package/src/install.js +20 -6
- package/src/note.js +31 -5
- package/src/store.js +36 -1104
- package/src/text.js +21 -1
- package/src/todo/task-cmd.js +274 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// src/brain/evolve.js — 知识自我进化:从图谱里看出「知识在往哪走」。
|
|
2
|
+
//
|
|
3
|
+
// 为何需要它(2026-11-02 用户定目标「可自我成长的知识库」):
|
|
4
|
+
// 图谱现在能存能查,但不会告诉人「哪里在变好、哪里在堆积、哪里该合并」。
|
|
5
|
+
// 光靠人定期回头看,就是靠记忆维护记忆 —— 必然漏。
|
|
6
|
+
//
|
|
7
|
+
// 四条判据,都只**报告**不判断(判断是内容决策,程序不替人做):
|
|
8
|
+
// 1. 被推翻的:哪些页已 superseded、被谁取代、有没有留下空链
|
|
9
|
+
// 2. 主题堆积:同一主题词下页数过多,说明该合并成规律页
|
|
10
|
+
// 3. 只进不出:有新页被建立但没人链它,等于没接进网络
|
|
11
|
+
// 4. stale:长期没改动、且没有任何入边的页 —— 考察是否该删
|
|
12
|
+
//
|
|
13
|
+
// 输出纪律:按类型折叠计数,同类超阈值只报条数 + 样例(见 read-side-output-must-not-scale)。
|
|
14
|
+
|
|
15
|
+
/** 收集进化信号。只读,不改任何东西。 */
|
|
16
|
+
export function evolveSignals(handle, { staleDays = 90, now = Date.now() } = {}) {
|
|
17
|
+
const { db } = handle;
|
|
18
|
+
const all = db.prepare("SELECT id, slug, project, layer, status, superseded_by, mtime FROM nodes WHERE is_ledger = 0").all();
|
|
19
|
+
const byId = new Map(all.map((r) => [r.id, r]));
|
|
20
|
+
const inbound = new Map();
|
|
21
|
+
for (const r of db.prepare('SELECT dst, count(*) c FROM edges WHERE dst > 0 GROUP BY dst').all()) inbound.set(r.dst, r.c);
|
|
22
|
+
const outbound = new Map();
|
|
23
|
+
for (const r of db.prepare('SELECT src, count(*) c FROM edges WHERE src > 0 GROUP BY src').all()) outbound.set(r.src, r.c);
|
|
24
|
+
|
|
25
|
+
// 1. 被推翻的
|
|
26
|
+
const superseded = all.filter((r) => r.status === 'superseded');
|
|
27
|
+
const danglingSupersede = superseded.filter((r) => {
|
|
28
|
+
if (!r.superseded_by) return true; // 标了推翻但没指向谁
|
|
29
|
+
return !all.some((x) => x.slug === r.superseded_by && x.project === r.project);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// 2. 主题堆积:按 tags 里的**业务标签**归类。
|
|
33
|
+
//
|
|
34
|
+
// 坑(2026-11-02 实测):tags 的第一项是**页面类型**(concept/source/session-log/entity),
|
|
35
|
+
// 拿它归类必然把 58 页 concept 报成「一组堆积」—— 那是类型不是主题,全是噪音。
|
|
36
|
+
// 类型词是固定的几个,直接按白名单跳过;主题看第二项起。
|
|
37
|
+
// 另:会话流水页不参与主题堆积判断。
|
|
38
|
+
// 它们的 tags 里带 `todo-archive`/`历史`,按主题归类会把 13 个归档快照
|
|
39
|
+
// 报成「该合并成规律页」—— 但归档快照本来就该各自独立(一天一文件是目录契约)。
|
|
40
|
+
// 判据看 dir:sessions/ 下的都是流水,不是知识页。
|
|
41
|
+
const TYPE_TAGS = new Set(['concept', 'source', 'synthesis', 'entity', 'session-log']);
|
|
42
|
+
const tagBuckets = new Map();
|
|
43
|
+
for (const row of db.prepare("SELECT tags, slug FROM nodes WHERE is_ledger = 0 AND dir != 'sessions' AND tags != ''").all()) {
|
|
44
|
+
const parts = String(row.tags).replace(/^\[|\]$/g, '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
45
|
+
const topic = parts.find((x) => !TYPE_TAGS.has(x.toLowerCase()));
|
|
46
|
+
if (!topic) continue;
|
|
47
|
+
if (!tagBuckets.has(topic)) tagBuckets.set(topic, []);
|
|
48
|
+
tagBuckets.get(topic).push(row.slug);
|
|
49
|
+
}
|
|
50
|
+
const piled = [...tagBuckets.entries()]
|
|
51
|
+
.filter(([, v]) => v.length >= 8) // 一个主题下 8 页以上算堆积
|
|
52
|
+
.sort((a, b) => b[1].length - a[1].length);
|
|
53
|
+
|
|
54
|
+
// 3. 只进不出:有入边但没有出边 —— 说明它是终点,不连回别处
|
|
55
|
+
const sinkOnly = all.filter((r) => (inbound.get(r.id) || 0) > 0 && !(outbound.get(r.id) || 0));
|
|
56
|
+
|
|
57
|
+
// 4. stale:长期没改 + 没人链
|
|
58
|
+
const staleMs = staleDays * 86400_000;
|
|
59
|
+
const stale = all.filter((r) => (now - (r.mtime || 0) > staleMs) && !(inbound.get(r.id) || 0));
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
total: all.length,
|
|
63
|
+
superseded, danglingSupersede,
|
|
64
|
+
piled, sinkOnly, stale,
|
|
65
|
+
byId, inbound, outbound,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 渲染成给人看的报告。按类型分组,同类超阈值只报样例。 */
|
|
70
|
+
export function renderEvolve(sig, { sample = 5, staleDays = 90 } = {}) {
|
|
71
|
+
const out = [];
|
|
72
|
+
const trunc = (arr, fmt) => {
|
|
73
|
+
const shown = arr.slice(0, sample).map(fmt);
|
|
74
|
+
if (arr.length > sample) shown.push(` …另有 ${arr.length - sample} 条`);
|
|
75
|
+
return shown;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
out.push(`📈 图谱进化体检 — ${sig.total} 页`);
|
|
79
|
+
out.push('');
|
|
80
|
+
|
|
81
|
+
// 好消息放前面:推翻说明知识在更新
|
|
82
|
+
if (sig.superseded.length) {
|
|
83
|
+
out.push(`✓ 已推翻 ${sig.superseded.length} 页(知识在更新,不是坏事)`);
|
|
84
|
+
out.push(...trunc(sig.superseded, (r) => ` ${r.slug} → ${r.superseded_by || '(未指向)'}`));
|
|
85
|
+
} else {
|
|
86
|
+
out.push('· 还没有被推翻的页 —— 要么知识很稳,要么没人回头修正');
|
|
87
|
+
}
|
|
88
|
+
out.push('');
|
|
89
|
+
|
|
90
|
+
if (sig.danglingSupersede.length) {
|
|
91
|
+
out.push(`⚠ ${sig.danglingSupersede.length} 页标了推翻但没指向新页(历史断了,看不出被谁取代)`);
|
|
92
|
+
out.push(...trunc(sig.danglingSupersede, (r) => ` ${r.slug}`));
|
|
93
|
+
out.push(' 修: abs supersede <旧页> --by <新页>');
|
|
94
|
+
out.push('');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (sig.piled.length) {
|
|
98
|
+
out.push(`⚠ 主题堆积 ${sig.piled.length} 组(同一主题下页数多,考虑合并成规律页)`);
|
|
99
|
+
out.push(...trunc(sig.piled, ([tag, list]) => ` ${tag}: ${list.length} 页`));
|
|
100
|
+
out.push('');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (sig.sinkOnly.length) {
|
|
104
|
+
out.push(`· 只被链、不链别人 ${sig.sinkOnly.length} 页(是终点,不回连——若它其实是枢纽,该补出链)`);
|
|
105
|
+
out.push(...trunc(sig.sinkOnly, (r) => ` ${r.slug} (入 ${sig.inbound.get(r.id)})`));
|
|
106
|
+
out.push('');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (sig.stale.length) {
|
|
110
|
+
out.push(`· ${staleDays} 天没动且无人链 ${sig.stale.length} 页(可能该删或该接进网络)`);
|
|
111
|
+
out.push(...trunc(sig.stale, (r) => {
|
|
112
|
+
const days = Math.round((Date.now() - r.mtime) / 86400_000);
|
|
113
|
+
return ` ${r.slug} (${days} 天前)`;
|
|
114
|
+
}));
|
|
115
|
+
out.push('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const issues = sig.danglingSupersede.length + sig.piled.length + sig.stale.length;
|
|
119
|
+
out.push(issues === 0
|
|
120
|
+
? '✓ 没有需要立刻处理的进化问题'
|
|
121
|
+
: `共 ${issues} 处待处理。这只是体检,改不改由你判断 —— 程序不替人做内容决策。`);
|
|
122
|
+
return out.join('\n');
|
|
123
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/brain/gate.js — 写入侧格式闸门注册。
|
|
2
|
+
// 从 store.js 拆出(2026-11-01):闸门是「哪个文件用哪套标准」的全局注册,
|
|
3
|
+
// 与建骨架无关。靠 import 副作用生效 —— 模块顶层即注册。
|
|
4
|
+
import { setFormatGate } from '../lock.js';
|
|
5
|
+
import { enforceBrainFormat, ensureStateMark } from '../todo.js';
|
|
6
|
+
import { BRAIN_SHAPE, legacyRenames } from './shape.js';
|
|
7
|
+
|
|
8
|
+
/** 写入侧格式闸门:lock.js 的 editFile 是所有写入的唯一收口,
|
|
9
|
+
* 这里把「哪个文件名用哪套标准」告诉它 —— 三个目标文件之外的写入一律放行。
|
|
10
|
+
* 非目标文件返回 null 表示不管。 */
|
|
11
|
+
setFormatGate((file, text, current) => {
|
|
12
|
+
const name = file.split('/').pop();
|
|
13
|
+
const spec = BRAIN_SHAPE[name];
|
|
14
|
+
if (!spec) return null;
|
|
15
|
+
const r = enforceBrainFormat(text, { ...spec, renames: legacyRenames() }, current);
|
|
16
|
+
return name === 'todo.md'
|
|
17
|
+
? r.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
|
|
18
|
+
: r.text;
|
|
19
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// src/brain/init.js — 建 .brain/ 骨架、结构体检、三个受管文件的模板。
|
|
2
|
+
// 从 store.js 拆出(2026-11-01):职责单一(只管骨架的生与补),与 load/task/rules 无关。
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { getUser } from '../userconfig.js';
|
|
6
|
+
import { editFile, SKIP } from '../lock.js';
|
|
7
|
+
import { todoTemplate, rebuildStructure } from '../todo.js';
|
|
8
|
+
|
|
9
|
+
const BRAIN_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
|
|
10
|
+
const BRAIN_FILES = ['index.md', 'log.md', 'todo.md'];
|
|
11
|
+
|
|
12
|
+
export async function cmdInit({ dir }) {
|
|
13
|
+
const root = resolveProjectDir(dir);
|
|
14
|
+
const brain = join(root, '.brain');
|
|
15
|
+
try {
|
|
16
|
+
await fs.access(brain);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (e && e.code !== 'ENOENT') throw e;
|
|
19
|
+
return createSkeleton(root, brain);
|
|
20
|
+
}
|
|
21
|
+
// 已存在 → 校验结构完整性;损坏则明确报错并指引 repair(不静默半初始化)
|
|
22
|
+
const report = await reportBrain({ dir: root });
|
|
23
|
+
if (report.ok) throw new Error(`已存在: ${brain} (abort)`);
|
|
24
|
+
throw new Error(
|
|
25
|
+
`已存在但结构不完整:\n ${report.problems.join('\n ')}\n` +
|
|
26
|
+
` → 轻则补齐: abs init --repair 重则重建: 删掉 ${brain} 后重跑 abs init`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function createSkeleton(root, brain) {
|
|
31
|
+
for (const d of BRAIN_DIRS) await fs.mkdir(join(brain, d), { recursive: true });
|
|
32
|
+
// 走 editFile 而不是 fs.writeFile:三个文件是受管表,写入必须过同一道闸门
|
|
33
|
+
// (2026-10-18:原来直写盘,是个绕过口)。模板本身合法,闸门只做归一。
|
|
34
|
+
for (const [f, tpl] of [['index.md', indexTemplate], ['log.md', logTemplate], ['todo.md', todoTemplate]]) {
|
|
35
|
+
await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
|
|
36
|
+
}
|
|
37
|
+
// 建完就提示设姓名 —— 否则用户一路到第一次 todo add 才撞墙,
|
|
38
|
+
// 中间 init/load 都不提,根本不知道该设。
|
|
39
|
+
const who = await getUser();
|
|
40
|
+
const hint = who ? '' : `\n ⚠ 尚未设置使用者姓名,写操作(todo add/log/note)会先报错。\n 请先设置: abs config set user <你的名字>`;
|
|
41
|
+
return `✓ 已建图谱 ${brain}\n (模板见 SKILL.md「.brain/ 怎么组织」)${hint}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 结构体检:6 目录 + 3 文件逐一核对。ok=false 时 problems 列出缺失项。 */
|
|
45
|
+
export async function reportBrain({ dir }) {
|
|
46
|
+
const root = resolveProjectDir(dir);
|
|
47
|
+
const brain = join(root, '.brain');
|
|
48
|
+
const problems = [];
|
|
49
|
+
for (const d of BRAIN_DIRS) {
|
|
50
|
+
try {
|
|
51
|
+
const st = await fs.stat(join(brain, d));
|
|
52
|
+
if (!st.isDirectory()) problems.push(`缺目录 ${d}/ (被文件占位)`);
|
|
53
|
+
} catch {
|
|
54
|
+
problems.push(`缺目录 ${d}/`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const f of BRAIN_FILES) {
|
|
58
|
+
try {
|
|
59
|
+
await fs.access(join(brain, f));
|
|
60
|
+
} catch {
|
|
61
|
+
problems.push(`缺文件 ${f}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { ok: problems.length === 0, problems, brain };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** init --repair: 只补缺失骨架,绝不覆盖已有文件。 */
|
|
68
|
+
export async function cmdRepair({ dir }) {
|
|
69
|
+
const root = resolveProjectDir(dir);
|
|
70
|
+
const brain = join(root, '.brain');
|
|
71
|
+
try {
|
|
72
|
+
await fs.access(brain);
|
|
73
|
+
} catch {
|
|
74
|
+
return createSkeleton(root, brain); // 整个图谱都没有 = 全新建
|
|
75
|
+
}
|
|
76
|
+
const fixed = [];
|
|
77
|
+
for (const d of BRAIN_DIRS) {
|
|
78
|
+
try {
|
|
79
|
+
const st = await fs.stat(join(brain, d));
|
|
80
|
+
if (!st.isDirectory()) {
|
|
81
|
+
await fs.rm(join(brain, d), { force: true });
|
|
82
|
+
await fs.mkdir(join(brain, d), { recursive: true });
|
|
83
|
+
fixed.push(`${d}/`);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
await fs.mkdir(join(brain, d), { recursive: true });
|
|
87
|
+
fixed.push(`${d}/`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const [f, tpl] of [['index.md', indexTemplate], ['log.md', logTemplate], ['todo.md', todoTemplate]]) {
|
|
91
|
+
try {
|
|
92
|
+
await fs.access(join(brain, f));
|
|
93
|
+
} catch {
|
|
94
|
+
// 同 createSkeleton:补齐也走 editFile 的口子(已存在则 cur 非 null,不覆盖)
|
|
95
|
+
await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
|
|
96
|
+
fixed.push(f);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!fixed.length) return `✓ 结构完整,无需修复: ${brain}`;
|
|
100
|
+
return `✓ 已补齐 ${fixed.join(', ')} → ${brain}\n (已有文件一律不覆盖)`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveProjectDir(dir) {
|
|
104
|
+
// 必须显式回退 cwd: resolve(undefined) 会抛 ERR_INVALID_ARG_TYPE,
|
|
105
|
+
// 并不像看上去那样「自动回退」。曾误删此分支为 resolve(dir),把 abs init/load/board
|
|
106
|
+
// 不带 --dir 全部变成裸栈崩溃。
|
|
107
|
+
return resolve(dir || process.cwd());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function indexTemplate() {
|
|
111
|
+
// 同 todoTemplate:由 rebuildStructure 生成,模板 = 重排结果,不会来回抖。
|
|
112
|
+
return rebuildStructure(
|
|
113
|
+
['# 🗂 Graph Index', '',
|
|
114
|
+
'本文件唯一入口。每新建/大改一页,同步在此分类下加一行 [[页面名]] — 一句话。', '',
|
|
115
|
+
'## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'].join('\n'),
|
|
116
|
+
{
|
|
117
|
+
h1: '# 🗂 Graph Index',
|
|
118
|
+
// 无 `## Roadmap`:它是 AI 自己写的方向总结,会被 load 反复读到并带偏后续会话
|
|
119
|
+
// (“看似是总结指引,其实是 AI 的总结指引” —— 2026-09-13 用户决策删除)。
|
|
120
|
+
order: ['## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
|
|
121
|
+
},
|
|
122
|
+
).text;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function logTemplate() {
|
|
126
|
+
// ★ 占位符必须是注释形式,不能是真条目形状(2026-10-05 实测):
|
|
127
|
+
// 原来写的是 `## [YYYY-MM-DD] ingest | 沉淀 <slug>` —— 看起来像条目,
|
|
128
|
+
// 但它不是真日期、也不是真 kind,条目形状闸门上线后**模板自己就非法**,
|
|
129
|
+
// 新条目插到它上面后它掉到第 4 行,直接把写入卡死。
|
|
130
|
+
// 用 HTML 注释保留「怎么写」的提示,同时不参与任何解析。
|
|
131
|
+
return [
|
|
132
|
+
'# 🗒 Activity Log', '',
|
|
133
|
+
'<!-- 每条一行:## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文(kind: note|dev|concept|ingest) -->', ''
|
|
134
|
+
].join('\n');
|
|
135
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/brain/layers.js — 七层知识分层:唯一的层级判据来源。
|
|
2
|
+
//
|
|
3
|
+
// 为何要这一层(2026-11-01 用户定):知识性质不同,检索权重就该不同。
|
|
4
|
+
// 铁律和随口一句话混在同一张图里、检索时一视同仁,是当前的真实缺陷。
|
|
5
|
+
// 分层的唯一技术作用 = 每层的衰减/权重不同,不是分类好看。
|
|
6
|
+
//
|
|
7
|
+
// 七层(用户原话):
|
|
8
|
+
// L1 铁律 —— 任何智能体不可逾越。物理位置 = index.md 的 `## Rules` 区,不是页。
|
|
9
|
+
// L2 规则 —— 执行规则。
|
|
10
|
+
// L3 工具 —— 工具使用经验。
|
|
11
|
+
// L4 认知 —— 踩坑、实用经验、总结、重点事件。(默认层)
|
|
12
|
+
// L5 会话 —— 会话内容、todo 记录、任务总结。物理位置 = sessions/ + todo.md。
|
|
13
|
+
// L6 断点 —— 双链词,即页面里 `[[链接]]` 的链接名。不是页,是词的索引。
|
|
14
|
+
// L7 混沌 —— 未分辨信息、大量琐碎会话。物理位置 = sources/。
|
|
15
|
+
//
|
|
16
|
+
// 设计约束(「稳定可靠」的三条硬指标):
|
|
17
|
+
// 1. 层归属唯一:每页有且只有一个 layer;缺省补 4;非法值报错,不静默当成 4。
|
|
18
|
+
// 2. 层不会漂:写入走 frontmatter,读取时校验,漂了能被 lint 查出来。
|
|
19
|
+
// 3. 降级不崩:缺字段/坏库/缺项目 → 按目录推层,继续能跑。
|
|
20
|
+
|
|
21
|
+
export const LAYERS = [
|
|
22
|
+
{ id: 1, key: 'law', name: '铁律', decay: 0, note: '永不衰减,每次检索必出' },
|
|
23
|
+
{ id: 2, key: 'rule', name: '规则', decay: 0.2, note: '执行规则' },
|
|
24
|
+
{ id: 3, key: 'tool', name: '工具', decay: 0.4, note: '工具使用经验' },
|
|
25
|
+
{ id: 4, key: 'cognition', name: '认知', decay: 0.6, note: '踩坑、经验、重点事件(默认层)' },
|
|
26
|
+
{ id: 5, key: 'session', name: '会话', decay: 0.75, note: '会话内容、todo、任务总结' },
|
|
27
|
+
{ id: 6, key: 'anchor', name: '断点', decay: 0.85, note: '双链词索引,不占页面' },
|
|
28
|
+
{ id: 7, key: 'chaos', name: '混沌', decay: 0.95, note: '未分辨的琐碎信息' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const LAYER_IDS = LAYERS.map((l) => l.id);
|
|
32
|
+
export const DEFAULT_LAYER = 4;
|
|
33
|
+
|
|
34
|
+
const BY_ID = new Map(LAYERS.map((l) => [l.id, l]));
|
|
35
|
+
|
|
36
|
+
/** 目录 → 层 的降级映射。只在无 layer 字段时用(用户定的自动迁移规则)。 */
|
|
37
|
+
export const DIR_LAYER = {
|
|
38
|
+
sessions: 5,
|
|
39
|
+
sources: 7,
|
|
40
|
+
concepts: 4,
|
|
41
|
+
entities: 4,
|
|
42
|
+
syntheses: 4,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** 层元信息。非法 id 返回 null(调用方据此报错,不静默兜底)。 */
|
|
46
|
+
export function layerOf(id) {
|
|
47
|
+
return BY_ID.get(Number(id)) || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 该层的衰减系数。L1 = 0 表示不衰减。 */
|
|
51
|
+
export function decayOf(id) {
|
|
52
|
+
const l = layerOf(id);
|
|
53
|
+
return l ? l.decay : BY_ID.get(DEFAULT_LAYER).decay;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 解析页面 fullmatch 里的 layer 字段。
|
|
57
|
+
* 返回 { layer, source, error }:
|
|
58
|
+
* source='frontmatter' 明确写了;'dir' 按目录推的;'default' 两者都没有。
|
|
59
|
+
* error 非空表示字段存在但非法 —— 调用方必须报出来,不能当正常。
|
|
60
|
+
*/
|
|
61
|
+
export function resolveLayer(body, dirName) {
|
|
62
|
+
const m = String(body || '').match(/^layer:\s*(.+)$/m);
|
|
63
|
+
if (m) {
|
|
64
|
+
const raw = m[1].trim();
|
|
65
|
+
const n = Number(raw);
|
|
66
|
+
if (Number.isInteger(n) && BY_ID.has(n)) return { layer: n, source: 'frontmatter', error: null };
|
|
67
|
+
return {
|
|
68
|
+
layer: DIR_LAYER[dirName] ?? DEFAULT_LAYER,
|
|
69
|
+
source: 'dir',
|
|
70
|
+
error: `layer 非法(读到 "${raw}",应为 1-7 的整数)`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const byDir = DIR_LAYER[dirName];
|
|
74
|
+
if (byDir) return { layer: byDir, source: 'dir', error: null };
|
|
75
|
+
return { layer: DEFAULT_LAYER, source: 'default', error: null };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 模板占位符 —— 它们出现在正文里是在**讨论语法**,不是真链接。
|
|
79
|
+
* 实测(2026-11-01):.brain 里 `[[作者]]` `[[页面名]]` `[[slug]]` 被当成真双链,
|
|
80
|
+
* 污染断点词表并被报成悬挂链接。排除它们是判据,不是特例。 */
|
|
81
|
+
const PLACEHOLDER = new Set(['作者', '页面名', 'slug', '页面', '页名', '名称', 'x', 'y', 'foo', 'bar']);
|
|
82
|
+
|
|
83
|
+
/** 提取页面里的双链词(L6 断点层的数据来源)。
|
|
84
|
+
* `[[x]]` / `[[x|别名]]` / `[[x#小节]]` 都取 x。
|
|
85
|
+
* 代码块与行内代码里的 `[[x]]` 是示例,不算。 */
|
|
86
|
+
export function anchorsOf(body) {
|
|
87
|
+
const out = new Set();
|
|
88
|
+
let text = String(body || '');
|
|
89
|
+
// 去掉围栏代码块与行内代码 —— 那里面的双链是文档示例
|
|
90
|
+
text = text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ');
|
|
91
|
+
for (const m of text.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)) {
|
|
92
|
+
const t = m[1].trim();
|
|
93
|
+
if (!t) continue;
|
|
94
|
+
if (PLACEHOLDER.has(t)) continue;
|
|
95
|
+
// 纯符号/纯数字的不是页名
|
|
96
|
+
if (!/[\p{L}\p{N}]/u.test(t)) continue;
|
|
97
|
+
out.add(t);
|
|
98
|
+
}
|
|
99
|
+
return [...out];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 校验一页的层是否与目录相符,供 lint 用。
|
|
103
|
+
* 只报「明确写了 layer 且与目录默认层冲突」的情况 —— 那是人有意为之的,
|
|
104
|
+
* 值得看一眼;纯按目录推的不报(否则每页都是噪音)。 */
|
|
105
|
+
export function layerConflict(body, dirName) {
|
|
106
|
+
const r = resolveLayer(body, dirName);
|
|
107
|
+
if (r.error) return r.error;
|
|
108
|
+
if (r.source !== 'frontmatter') return null;
|
|
109
|
+
const expect = DIR_LAYER[dirName];
|
|
110
|
+
if (!expect || expect === r.layer) return null;
|
|
111
|
+
return `layer: ${r.layer} 与目录 ${dirName}/(默认 ${expect})不符`;
|
|
112
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// src/brain/load.js — 开机读状态:board / status / load。都是只读呈现。
|
|
2
|
+
// 从 store.js 拆出(2026-11-01):与写命令分开,load 是开机第一入口,读起来要能一口气看完。
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { brainPath, requireBrain } from '../index.js';
|
|
5
|
+
import { getUser, placeholderWarn } from '../userconfig.js';
|
|
6
|
+
import { boardText, readTodo, collapseDone } from '../todo.js';
|
|
7
|
+
import { strandedFor } from '../wrapup.js';
|
|
8
|
+
import { keywords, recentFiles, topicStrength } from '../relevant.js';
|
|
9
|
+
import { readIfExists, recentLogLines } from '../text.js';
|
|
10
|
+
import { listPages } from '../lint.js';
|
|
11
|
+
import { SOURCES_MAX } from '../lint.js';
|
|
12
|
+
import { checkBrainShape } from './shape.js';
|
|
13
|
+
import { rulesSection } from './rules.js';
|
|
14
|
+
import { collapseIndex } from '../page.js';
|
|
15
|
+
|
|
16
|
+
// ---------- board: 看板 ----------
|
|
17
|
+
export async function cmdBoard({ dir, full }) {
|
|
18
|
+
const root = await requireBrain(dir || process.cwd());
|
|
19
|
+
return boardText(root, undefined, { full: !!full });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------- status: 定位报告 + 图谱概要 ----------
|
|
23
|
+
export async function cmdStatus({ dir }) {
|
|
24
|
+
const root = await requireBrain(dir || process.cwd());
|
|
25
|
+
const entries = await listBrainFiles(root);
|
|
26
|
+
return [
|
|
27
|
+
`📂 abs → 项目: ${root}`,
|
|
28
|
+
`图谱: ${brainPath(root)}`,
|
|
29
|
+
'',
|
|
30
|
+
entries.length ? entries.join('\n') : '(图谱为空)',
|
|
31
|
+
].join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function listBrainFiles(root) {
|
|
35
|
+
const out = [];
|
|
36
|
+
const dirs = ['concepts', 'entities', 'sources', 'syntheses', 'sessions'];
|
|
37
|
+
for (const d of dirs) {
|
|
38
|
+
const p = brainPath(root, d);
|
|
39
|
+
try {
|
|
40
|
+
const files = (await fs.readdir(p)).filter((f) => f.endsWith('.md'));
|
|
41
|
+
out.push(`${d}/: ${files.length} 页`);
|
|
42
|
+
} catch { out.push(`${d}/: 0 页`); }
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------- load: 开机读状态 ----------
|
|
48
|
+
export async function cmdLoad({ dir }) {
|
|
49
|
+
const root = await requireBrain(dir || process.cwd());
|
|
50
|
+
// 结构核对先跑:load 每回都要读这三个文件,顺手把它们形状摆正(缺分区)或提个醒(无头)。
|
|
51
|
+
// 正常时完全静默、零字节,不增加 load 体积(load 已被压缩到 ~1.8k token)。
|
|
52
|
+
const shape = await checkBrainShape(root).catch(() => ({ fixed: [], warn: [] }));
|
|
53
|
+
const todo = await readTodo(root);
|
|
54
|
+
const index = await readIfExists(brainPath(root, 'index.md'));
|
|
55
|
+
const log = await readIfExists(brainPath(root, 'log.md'));
|
|
56
|
+
const stranded = await strandedFor(root);
|
|
57
|
+
const sections = [
|
|
58
|
+
`📂 abs → 项目: ${root}`,
|
|
59
|
+
];
|
|
60
|
+
if (shape.fixed.length || shape.warn.length) {
|
|
61
|
+
const rows = [
|
|
62
|
+
...shape.fixed.map((a) => ` ✓ 已补: ${a}`),
|
|
63
|
+
...shape.warn.map((a) => ` ⚠ ${a}`),
|
|
64
|
+
];
|
|
65
|
+
// 那句提示只适用于「标题非标准」:其他 warn(如格式不合规)说这句会误导。
|
|
66
|
+
if (shape.warn.some((a) => a.includes('标题非标准'))) {
|
|
67
|
+
rows.push(' (无头文件不自动改:结构可能整体脱轨,请手工对齐 .brain/ 模板)');
|
|
68
|
+
}
|
|
69
|
+
sections.push('--- 文件形状核对 ---', ...rows, '');
|
|
70
|
+
}
|
|
71
|
+
// 未设姓名/姓名是占位名时开场就提醒 —— load 是开机第一屏,不在这里提,
|
|
72
|
+
// 用户要撞到第一次写操作才知道(init/load 一路沉默)。
|
|
73
|
+
// 占位名(历史遗留 tester/foo)必须提:配置里有值 ≠ 名字是对的。
|
|
74
|
+
// 一次调用拿两者(placeholderWarn 内部也要 getUser)—— 原先分两次读盘(2026-10-03 审查)。
|
|
75
|
+
const who = await getUser();
|
|
76
|
+
const ph = who ? await placeholderWarn() : null;
|
|
77
|
+
if (!who) {
|
|
78
|
+
sections.push(
|
|
79
|
+
'⚠ 尚未设置使用者姓名(写操作会先报错)',
|
|
80
|
+
'→ abs config set user <你的名字> (或临时: ABS_USER=<名字> abs ...)',
|
|
81
|
+
''
|
|
82
|
+
);
|
|
83
|
+
} else if (ph) {
|
|
84
|
+
sections.push(
|
|
85
|
+
`⚠ 当前作者名是占位名 "${ph}" —— 之后所有项目的 todo/log 都会标它。`,
|
|
86
|
+
'→ abs config set user <你的真名> (或临时: ABS_USER=<名字> abs ...)',
|
|
87
|
+
''
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
sections.push(
|
|
91
|
+
// Rules 单独成段且放在最前(仅次于项目行/滞留):它是硬规则,不是普通清单。
|
|
92
|
+
// 坑: 曾在 index 段内与页面清单平铺 —— AI 会当普通清单划过,而它每条都是付过代价的。
|
|
93
|
+
...(rulesSection(index) ? [rulesSection(index), ''] : []),
|
|
94
|
+
collapseIndex(index) || '(index.md 为空)',
|
|
95
|
+
'',
|
|
96
|
+
// 两区制:Todo(未完成,行首带状态标记)+ Done。
|
|
97
|
+
'--- Todo Board (todo.md) ---',
|
|
98
|
+
collapseDone(todo).text || '(todo.md 为空)',
|
|
99
|
+
'\n(Done 已按日期折叠计数;明细: abs todo --full)',
|
|
100
|
+
'',
|
|
101
|
+
'--- 最近动作 (log.md, 最新 5 条) ---',
|
|
102
|
+
recentLogLines(log, 5) || '(log.md 为空)',
|
|
103
|
+
);
|
|
104
|
+
if (stranded.length) {
|
|
105
|
+
const rows = stranded.map((t) => {
|
|
106
|
+
const bp = t.bp.length ? `\n ${t.bp.map((b) => (b.startsWith('[') ? `- ${b}` : `- [ ] ${b}`)).join('\n ')}` : '';
|
|
107
|
+
return ` - ${t.body}${bp}`;
|
|
108
|
+
});
|
|
109
|
+
// 插在项目行之后、其它内容之前
|
|
110
|
+
sections.splice(
|
|
111
|
+
1, 0,
|
|
112
|
+
'⏳ 上会话滞留(未 done,先对账)',
|
|
113
|
+
rows.join('\n'),
|
|
114
|
+
'→ 完成: abs todo done <id>;未完: abs todo note <id> --note 子项',
|
|
115
|
+
''
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
// 卡点提取(2026-09-16,定位 = 看板可见性):
|
|
119
|
+
// 从 todo 里抽出带 `阻塞:` / `等待:` 的断点行,集中展示。
|
|
120
|
+
// 为何需要:断点行在任务下方,一屏扫过去看不出"哪些事在等人/等外部"。
|
|
121
|
+
// 只提取不求全 —— 只有明确写了前缀的才被抽出,没写的不猜。
|
|
122
|
+
const blocks = extractBlocks(todo);
|
|
123
|
+
if (blocks.length) {
|
|
124
|
+
const at = sections.findIndex((s) => String(s).startsWith('--- Rules'));
|
|
125
|
+
sections.splice(at === -1 ? 1 : at, 0,
|
|
126
|
+
`⛔ 卡点 ${blocks.length} 条(在等外部条件)`,
|
|
127
|
+
...blocks.map((b) => ` - ${b.id}: ${b.why}`),
|
|
128
|
+
'');
|
|
129
|
+
}
|
|
130
|
+
// 相关页提示(2026-09-16):实测 abs_query 几乎不被调用(MCP 日志 task 234 / note 54 / load 53 vs query 2)。
|
|
131
|
+
// 根因:load 只列目录 → 模型觉得"我记过了" → 真需要时靠印象。模型不知道自己不知道什么。
|
|
132
|
+
// 解法(用户定):不替它查 —— 而是【把该查的词直接给出来】,降低发起查询的成本。
|
|
133
|
+
// 自动带出正文是错的方向:那会让 query 更没人用,且无法按需求变化调整词。
|
|
134
|
+
// 失败静默:这只是 additive 提示,出任何错都不应弄坏 load。
|
|
135
|
+
try {
|
|
136
|
+
const rel = await queryHintSection(root, todo);
|
|
137
|
+
if (rel) sections.push('', rel);
|
|
138
|
+
} catch { /* 提示失败不影响 load */ }
|
|
139
|
+
// sources 堆积提醒:与「滞留」同构 —— 放在每次开工必经的顶部,而不是等人跑 lint。
|
|
140
|
+
// 只数目录条目(不读文件),零成本。提炼仍手工:这里只负责送达,不替判断。
|
|
141
|
+
try {
|
|
142
|
+
const srcWarn = await sourcesCountSection(root);
|
|
143
|
+
if (srcWarn) {
|
|
144
|
+
const at = sections.findIndex((s) => String(s).startsWith('--- Rules'));
|
|
145
|
+
sections.splice(at === -1 ? 1 : at, 0, ...srcWarn);
|
|
146
|
+
}
|
|
147
|
+
} catch { /* 提醒失败不影响 load */ }
|
|
148
|
+
return sections.join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 从 todo 正文抽出"卡点"行,供 load 集中展示。
|
|
153
|
+
*
|
|
154
|
+
* 识别的前缀:`阻塞:` / `等待:` / `等 `(在断点/附属行里)。
|
|
155
|
+
* 为何只认前缀(不靠语义猜):没写就是没写,猜出来的卡点比不报更坑。
|
|
156
|
+
*
|
|
157
|
+
* @returns {{id:string, why:string}[]}
|
|
158
|
+
*/
|
|
159
|
+
export function extractBlocks(todoText) {
|
|
160
|
+
const out = [];
|
|
161
|
+
let curId = null;
|
|
162
|
+
for (const line of String(todoText || '').split('\n')) {
|
|
163
|
+
// 任务行(未完成):`- [ ] [状态] ID [[who]] — 描述`
|
|
164
|
+
const t = line.match(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(\S+)/);
|
|
165
|
+
if (t) { curId = t[1]; continue; }
|
|
166
|
+
// 完成行:不再跟踪
|
|
167
|
+
if (/^\s*-\s*\[x\]/.test(line)) { curId = null; continue; }
|
|
168
|
+
if (!curId) continue;
|
|
169
|
+
// 附属行(缩进子项)里找卡点前缀。
|
|
170
|
+
// 旧写法 `↳ 断点: 阻塞: …`(存量)也认 —— 保留一条前缀兼容,以免漏报历史卡点。
|
|
171
|
+
const m = line.match(/^\s*(?:↳\s*(?:断点|卡点)?[::]\s*)?(?:阻塞|等待|等)[::]\s*(.+)$/);
|
|
172
|
+
if (m && m[1].trim()) out.push({ id: curId, why: m[1].trim() });
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 开工提示:直接给出该查的几个词,降低发起 query 的成本。
|
|
179
|
+
*
|
|
180
|
+
* 为何不是"替它查"(2026-09-16 用户定):自动带出正文会让 query 更没人用,
|
|
181
|
+
* 且无法按需求变化调整词。正确做法是把词准备好,查询仍由调用方发。
|
|
182
|
+
*
|
|
183
|
+
* 词从两个环境事实推:活跃 todo 的关键词 + 最近改过的文件名。
|
|
184
|
+
*
|
|
185
|
+
* 挑词用【主题级强度】排序,不取词表前几个(2026-09-17 实测修正):
|
|
186
|
+
* 原实现 `pick = [...en.slice(0,2), ...zh.slice(0,1)]` 取的是最前面的词,
|
|
187
|
+
* 而 todo 行以 `<任务id> [[作者]]` 开头 → 任务 id 与用户名稳占前两位,
|
|
188
|
+
* 实测输出 `abs query queryhint-noise tester 进行`(强度 0/1/0,全是废词)。
|
|
189
|
+
* 现按 topicStrength(tag/页名命中数)降序取,强度 0 的一律不提示
|
|
190
|
+
* —— 命中不了任何页的词,建议去查它等于没建议。
|
|
191
|
+
* 失败静默:读页出错就当没有强词,退回不提示(不影响 load)。
|
|
192
|
+
*
|
|
193
|
+
* @param {string} root 项目根
|
|
194
|
+
* @param {string} todoText
|
|
195
|
+
* @param {{name:string,body:string}[]} [pages] 已读好的页(省一次磁盘扫描)
|
|
196
|
+
*/
|
|
197
|
+
async function queryHint(root, todoText, pages) {
|
|
198
|
+
const active = String(todoText || '')
|
|
199
|
+
.split('\n')
|
|
200
|
+
.filter((l) => /^\s*-\s*\[\s*\]/.test(l))
|
|
201
|
+
// 剔掉任务行的结构记号再分词:`- [ ] [状态] <id> [[作者]] —` 里只有「—」后面是真内容。
|
|
202
|
+
// 实测教训(2026-09-17):不剔的话 `<id>` 与 `[[作者]]` 会进入词表,
|
|
203
|
+
// 而作者名恰好有自己的 entities/<作者>.md 页 → 命中**页名** → 强度 1 过了门槛,
|
|
204
|
+
// 于是提示语变成「查一下你自己的名字」。建议查作者页不是“别重踩的经验”。
|
|
205
|
+
// 用 task 行自身的形状剥(与 idOfTaskLine 同源),不维护人名/任务名黑名单。
|
|
206
|
+
.map((l) => l.replace(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(?:\S+\s*)?/, '').replace(/\[\[[^\]]*\]\]/g, ' '))
|
|
207
|
+
.join(' ');
|
|
208
|
+
const files = await recentFiles(root, { n: 5 }).catch(() => []);
|
|
209
|
+
const src = [active, files.map((f) => f.name).join(' ')].filter(Boolean).join(' ');
|
|
210
|
+
const kws = keywords(src);
|
|
211
|
+
if (!kws.length) return '';
|
|
212
|
+
const all = pages || await listPages(brainPath(root)).catch(() => []);
|
|
213
|
+
const strength = topicStrength(all.map((p) => ({ name: p.slug, body: p.body })), kws);
|
|
214
|
+
// 英文词优先(中文 2-gram 单看无意义,只做补充);每组内按强度降序
|
|
215
|
+
const byStrength = (a, b) => (strength.get(b) || 0) - (strength.get(a) || 0) || a.localeCompare(b);
|
|
216
|
+
const en = kws.filter((k) => /^[a-z][a-z0-9_-]+$/.test(k)).sort(byStrength).filter((k) => strength.get(k) > 0);
|
|
217
|
+
const zh = kws.filter((k) => !/^[a-z][a-z0-9_-]+$/.test(k)).sort(byStrength).filter((k) => strength.get(k) > 0);
|
|
218
|
+
const pick = [...en.slice(0, 2), ...zh.slice(0, 1)].slice(0, 3);
|
|
219
|
+
if (!pick.length) return '';
|
|
220
|
+
const rows = [
|
|
221
|
+
'--- 开工前建议查一次(图谱里有相关经验,别重踩)---',
|
|
222
|
+
` abs query ${pick.join(' ')}`,
|
|
223
|
+
];
|
|
224
|
+
if (files.length) rows.push(` (据最近改动: ${files.slice(0, 3).map((f) => f.name).join(', ')})`);
|
|
225
|
+
return rows.join('\n');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 数 sources/ 下的 .md 文件数(只读目录,不解析内容)—— load 顶部提醒用。
|
|
229
|
+
* 坑: root 是【项目根】,图谱在 root/.brain/ 下 —— 必须走 brainPath,
|
|
230
|
+
* 直接用 join(root,'sources') 会 ENOENT 被 catch 吞成 0,成为又一个静默失效。 */
|
|
231
|
+
async function countSources(root) {
|
|
232
|
+
try {
|
|
233
|
+
const files = await fs.readdir(brainPath(root, 'sources'));
|
|
234
|
+
return files.filter((f) => f.endsWith('.md') && !f.startsWith('_')).length;
|
|
235
|
+
} catch { return 0; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** queryHint 的 load 端封装:返回提示文本(含标题行),无强词则空串。失败静默。 */
|
|
239
|
+
async function queryHintSection(root, todo) {
|
|
240
|
+
return queryHint(root, todo);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** sources 堆积提醒:返回 [标题行, ...内容行] 或 null。失败静默。 */
|
|
244
|
+
async function sourcesCountSection(root) {
|
|
245
|
+
const nsrc = await countSources(root);
|
|
246
|
+
if (nsrc <= SOURCES_MAX) return null;
|
|
247
|
+
return [
|
|
248
|
+
`♻ 待消化: sources/ 有 ${nsrc} 条 > ${SOURCES_MAX}(采集了没提炼)`,
|
|
249
|
+
'→ abs lint 看明细;提炼成 concepts/ 后删 source 并清引用',
|
|
250
|
+
'',
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------- show: 查看 index/todo/log(只读面) ----------
|
|
255
|
+
export async function cmdShow({ dir, view, full }) {
|
|
256
|
+
const v = String(view || '').toLowerCase();
|
|
257
|
+
if (!['todo', 'index', 'log'].includes(v)) {
|
|
258
|
+
return '用法: abs <todo|index|log> — todo=看板(原 board), index=Graph Index, log=操作流水';
|
|
259
|
+
}
|
|
260
|
+
const root = await requireBrain(dir || process.cwd());
|
|
261
|
+
const p = brainPath(root, `${v === 'todo' ? 'todo' : v}.md`);
|
|
262
|
+
// todo 走 readTodo(含老格式惰性迁移写回);index/log 直接读
|
|
263
|
+
let text;
|
|
264
|
+
try {
|
|
265
|
+
text = v === 'todo'
|
|
266
|
+
? await readTodo(root) // 迁移老格式 In Progress/Todo → B4
|
|
267
|
+
: (await fs.readFile(p, 'utf8')).trim();
|
|
268
|
+
} catch {
|
|
269
|
+
return `${v}.md 不存在于 ${brainPath(root)}。初始化/补齐: abs init --repair`;
|
|
270
|
+
}
|
|
271
|
+
if (!text) return `(${v}.md 为空)`;
|
|
272
|
+
return v === 'todo' ? boardText(root, text, { full: !!full }) : text;
|
|
273
|
+
}
|