@fanchao8609/agent_brain_sync 1.20.1 → 1.21.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 +6 -0
- package/bin/abs.js +39 -0
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +21 -2
- package/src/brain/capture.js +170 -0
- package/src/brain/db-cmd.js +244 -0
- package/src/brain/db.js +406 -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 +236 -0
- package/src/note.js +15 -0
- package/src/store.js +36 -1104
- package/src/text.js +21 -1
- package/src/todo/task-cmd.js +274 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// src/brain/maintain.js — 维护类命令:滞留快照、Done 归档、收尾体检。
|
|
2
|
+
// 从 store.js 拆出(2026-11-01):都属"活干完了怎么收",与看板读写分开。
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { brainPath, requireBrain, absLogDir } from '../index.js';
|
|
6
|
+
import { editFile, SKIP } from '../lock.js';
|
|
7
|
+
import { appendWrapup } from '../wrapup.js';
|
|
8
|
+
import { readIfExists } from '../text.js';
|
|
9
|
+
import { getUser } from '../userconfig.js';
|
|
10
|
+
import { today, localStamp, archiveDoneInText, upsertArchiveSection, TODO_MAX_LINES } from '../todo.js';
|
|
11
|
+
export async function cmdWrapup({ dir }) {
|
|
12
|
+
const root = await requireBrain(dir || process.cwd());
|
|
13
|
+
const snap = await appendWrapup(root);
|
|
14
|
+
// 顺手做 Done 归档。**不引入 cron/定时器**:Stop hook 已经会在会话结束时调
|
|
15
|
+
// `abs wrapup`,直接复用这个触发点(零新基础设施、零新失败面)。
|
|
16
|
+
// 只对「超过保留天数 且 整天都已完成」的日期组动手,平时无动作。
|
|
17
|
+
// 本函数 stdout 会被 hook 追写到 ~/.abs/log/hooks.log → 归档动作自动留痕。
|
|
18
|
+
let arch = '';
|
|
19
|
+
try {
|
|
20
|
+
const a = await cmdTodoArchive({ dir: root, keepDays: 3 });
|
|
21
|
+
if (a.startsWith('✓')) arch = '\n' + a;
|
|
22
|
+
} catch { /* 归档失败不影响快照本身 */ }
|
|
23
|
+
return snap + arch;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `abs todo archive` —— 把 Done 区里「超过保留天数 且 整天都已完成」的日期组迁到归档页。
|
|
28
|
+
* 规则见 `archiveDoneInText`(① 只留近 N 天 ② 任一天有未完成则整天不归档 ③ 归档成一个文件
|
|
29
|
+
* 并在 Done 区尾部 `### 归档` 记 `- [[slug]] 完成任务 N 条`)。
|
|
30
|
+
* 幂等:同日重跑 = 追写到同一归档页 + 就地更新标记行。
|
|
31
|
+
*/
|
|
32
|
+
export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {}) {
|
|
33
|
+
let root;
|
|
34
|
+
try { root = await requireBrain(dir || process.cwd()); } catch { return '未找到 .brain/ 图谱。先在项目根运行: abs init'; }
|
|
35
|
+
// 坑(2026-10-05 实测):原写法 `Math.max(1, Number(keepDays) || 3)` 有两个问题 ——
|
|
36
|
+
// ① JS falsy 陷阱:`0 || 3` 得 3,于是 `--keep-days 0` 被当成"没传值",静默变成
|
|
37
|
+
// 保留 3 天(用户以为归档了全部,实际一条没动,且输出还理直气壮说"保留近 3 天")。
|
|
38
|
+
// ② `Math.max(1, ...)` 会静默把 0 抬成 1 —— 即使修好 ①,用户也不知道自己被改了。
|
|
39
|
+
// 改为:显式校验 + 明确报错。宁可让用户改参数,也不静默改写他的意图。
|
|
40
|
+
const rawDays = Number(keepDays);
|
|
41
|
+
if (!Number.isFinite(rawDays) || rawDays < 1 || !Number.isInteger(rawDays)) {
|
|
42
|
+
return `✗ --keep-days 需为 ≥1 的整数(收到 "${keepDays}")\n`
|
|
43
|
+
+ ' 下限 1 天是为了不误删今天刚完成的记录;要清空 Done 请先确认后手动编辑 todo.md。';
|
|
44
|
+
}
|
|
45
|
+
const days = rawDays;
|
|
46
|
+
const todoP = brainPath(root, 'todo.md');
|
|
47
|
+
const raw = await fs.readFile(todoP, 'utf8').catch(() => '');
|
|
48
|
+
if (!raw.trim()) return '(todo.md 为空)';
|
|
49
|
+
|
|
50
|
+
const plan = archiveDoneInText(raw, { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
|
|
51
|
+
const why = plan.skipped.length
|
|
52
|
+
? `\n 跳过: ${plan.skipped.map((s) => `${s.date}(${s.reason})`).join(';')}`
|
|
53
|
+
: '';
|
|
54
|
+
if (!plan.archived.length) return `(无可归档:保留近 ${days} 天)${why}`;
|
|
55
|
+
const brief = plan.archived.map((g) => `${g.date}(${g.count})`).join(' ');
|
|
56
|
+
if (dryRun) {
|
|
57
|
+
return `[dry-run] 将归档 ${plan.archived.length} 天 / ${plan.count} 条(每天一个文件)\n ${brief}${why}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 1) 归档目标 = 当天的会话快照文件(log-<日期>.md),写进其「任务归档」段。
|
|
61
|
+
// 为何合一(2026-09-15 用户定): 归档与会话快照是**同一天的记录**,分两个文件查着要开两处。
|
|
62
|
+
// 边界: ① 文件不存在→建只有归档段的页(那天可能没写快照)
|
|
63
|
+
// ② 文件已存在(含 AI 手写快照)→ 只替换归档段,段外逐字保留
|
|
64
|
+
// (硬规则「已存在的人工内容一律不覆盖」由 upsertArchiveSection 保证)。
|
|
65
|
+
const sessDir = brainPath(root, 'sessions');
|
|
66
|
+
for (const g of plan.archived) {
|
|
67
|
+
const pageP = join(sessDir, `${g.slug}.md`);
|
|
68
|
+
let page = null;
|
|
69
|
+
try { page = await fs.readFile(pageP, 'utf8'); } catch { /* 首次 */ }
|
|
70
|
+
const nextPage = upsertArchiveSection(page, g);
|
|
71
|
+
const tmp = join(sessDir, `.${g.slug}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
|
|
72
|
+
await fs.writeFile(tmp, nextPage, 'utf8');
|
|
73
|
+
await fs.rename(tmp, pageP);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2) todo.md:锁内重算(拿最新内容,避免与并发 done 互相覆盖)
|
|
77
|
+
await editFile(todoP, (cur) => {
|
|
78
|
+
// 坑(2026-10-05 实测抓到): 这里漏传 maxLines → 锁内重算的 archived 为空 →
|
|
79
|
+
// 返回 SKIP → 磁盘上的 todo.md 一个字没动(而 sessions/ 归档页已经写了,
|
|
80
|
+
// 两边不一致:"说归档了但看板没变")。**同参数的每个调用点都要一起改**。
|
|
81
|
+
const p2 = archiveDoneInText(cur ?? '', { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
|
|
82
|
+
return p2.archived.length ? { text: p2.text } : SKIP;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// 3) index 登记(每个日期页一行,幂等)
|
|
86
|
+
// 坑(2026-09-15 修): 原先把归档页登记到 `## Sources` 区 —— 但它们在 sessions/ 里,
|
|
87
|
+
// 旧数据实测就是错位的(09-10/09-12 两条曾键在 Sources 段,人工才发现)。现改登 `## Sessions`。
|
|
88
|
+
const iP = brainPath(root, 'index.md');
|
|
89
|
+
await editFile(iP, (index) => {
|
|
90
|
+
if (!index) return SKIP;
|
|
91
|
+
const missing = plan.archived.filter((g) => !index.includes(`[[${g.slug}]]`));
|
|
92
|
+
if (!missing.length) return SKIP;
|
|
93
|
+
const sIdx = index.indexOf('## Sessions');
|
|
94
|
+
if (sIdx === -1) return SKIP;
|
|
95
|
+
const after = index.indexOf('\n## ', sIdx + 1);
|
|
96
|
+
const add = missing.map((g) => `- [[${g.slug}]] — ${g.date}(会话快照 + 任务归档)`).join('\n');
|
|
97
|
+
const next = after === -1
|
|
98
|
+
? `${index.replace(/\s*$/, '')}\n${add}\n`
|
|
99
|
+
: index.slice(0, after) + `\n${add}` + index.slice(after);
|
|
100
|
+
return { text: next };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const files = plan.archived.map((g) => `sessions/${g.slug}.md`).join('\n ');
|
|
104
|
+
return `✓ 已归档 ${plan.archived.length} 天 / ${plan.count} 条\n ${brief}\n → ${files}${why}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Stop hook 收尾注入判定 —— CC/Codex 的"主动推"。
|
|
109
|
+
* 与 pi 插件四条件一致: ①本会话真改过文件 ②有 .brain ③log.md 今日无条目 ④每会话一次。
|
|
110
|
+
* 输出给 shell: `push:<json>` = 注入, 其它 = 放行。JSON 走 stdout 回宿主的 decision:block。
|
|
111
|
+
* 所有异常一律放行(`{}`), 绝不因判定失败而卡住用户会话。
|
|
112
|
+
*/
|
|
113
|
+
export async function cmdTeardownCheck({ dir, payload }) {
|
|
114
|
+
try {
|
|
115
|
+
let ev = {};
|
|
116
|
+
try { ev = JSON.parse(payload || '{}'); } catch { ev = {}; }
|
|
117
|
+
|
|
118
|
+
// 条件①: 本会话真改过文件。CC 的 Stop payload 不带工具足迹,
|
|
119
|
+
// 故以 transcript_path 为准; 取不到就退化为"本项目今日是否已有产物"判断。
|
|
120
|
+
const transcript = ev.transcript_path || ev.transcriptPath;
|
|
121
|
+
if (transcript) {
|
|
122
|
+
const txt = await readIfExists(transcript);
|
|
123
|
+
const wrote = /"(Write|Edit|MultiEdit|NotebookEdit)"/.test(txt);
|
|
124
|
+
if (!wrote) return '{}'; // 纯只读会话不打扰
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 条件②: 本项目有 .brain
|
|
128
|
+
const dir0 = dir || ev.cwd || process.cwd();
|
|
129
|
+
let root;
|
|
130
|
+
try { root = await requireBrain(dir0); } catch { return '{}'; }
|
|
131
|
+
|
|
132
|
+
// 条件③: log.md 今日尚无【工作成果】条目(条目头 '## [YYYY-MM-DD HH:MM] [[name]] dev |')。
|
|
133
|
+
// 坑(2026-09-13 实测, pi 侧先修): `abs note` 也写 log.md(kind=note),
|
|
134
|
+
// 旧判据只看「今天有没有行」会把「沉淀了一条经验」误判成「今天已收尾」→ 整天不再提醒。
|
|
135
|
+
// 三宿主(pi/CC/opencode)判据必须一致 —— 见 [[hook-throttle-alignment]]。
|
|
136
|
+
const logTxt = await readIfExists(brainPath(root, 'log.md'));
|
|
137
|
+
const stamp = localStamp(); // 'YYYY-MM-DD HH:MM'
|
|
138
|
+
const day = stamp.slice(0, 10);
|
|
139
|
+
if (new RegExp('^## \\[' + day + ' \\d{2}:\\d{2}\\] (?:\\[\\[[^\\]]+\\]\\] )?dev \\|', 'm').test(logTxt)) return '{}';
|
|
140
|
+
|
|
141
|
+
// 条件④: 每会话一次。
|
|
142
|
+
// 双保险, 因为官方 stop_hook_active 有已知
|
|
143
|
+
// 不传播 bug(claude-code#54360): 同一 turn 内重复 fire 时它仍是 false。
|
|
144
|
+
// ① 官方契约: stop_hook_active=true = 本次 Stop 已是注入后的产物, 绝不再推(否则死循环)
|
|
145
|
+
if (ev.stop_hook_active === true) return '{}';
|
|
146
|
+
// ② 己方节流: 以 session_id 落 mark。缺 id 时退化为按项目+日期节流,
|
|
147
|
+
// 绝不"无节流"——否则一旦宿主张不到 id, decision:block 就会无限自激。
|
|
148
|
+
const sid = String(ev.session_id || ev.sessionId || '').replace(/[^\w-]/g, '');
|
|
149
|
+
// 兼底 key 只用【项目名】而非 full path。
|
|
150
|
+
// 坑(2026-09-16 实测): 原用 root.replace(/[^\w]/g,'_') 会把 cwd 的 tmp 路径
|
|
151
|
+
// (/var/folders/..._tmpXXXX) 也编进去 —— 而每个会话的 tmp 目录都不同,
|
|
152
|
+
// 于是"按项目+日期节流"根本没生效, 每次都新 key → ~/.abs/log/ 堆了 361 个 mark。
|
|
153
|
+
const projKey = root.split('/').filter(Boolean).pop() || 'unknown';
|
|
154
|
+
const key = sid || 'nosession-' + day + '-' + projKey;
|
|
155
|
+
const mark = join(absLogDir(), `teardown-${key}.mark`);
|
|
156
|
+
try {
|
|
157
|
+
await fs.access(mark);
|
|
158
|
+
return '{}'; // 本会话(或本项目今日)已推过
|
|
159
|
+
} catch { /* 未推过 */ }
|
|
160
|
+
await fs.mkdir(dirname(mark), { recursive: true });
|
|
161
|
+
await fs.writeFile(mark, stamp).catch(() => {});
|
|
162
|
+
// mark 只增不减会堆成垃圾(实测 361 个)。只清【带旧日期】的 mark ——
|
|
163
|
+
// 节流只在当天有意义(判据是 day), 旧日期 mark 不可能再命中;
|
|
164
|
+
// 无日期的会话 mark(teardown-<sid>.mark)保留, 它靠自身存在与否判重。
|
|
165
|
+
try {
|
|
166
|
+
for (const f of await fs.readdir(absLogDir())) {
|
|
167
|
+
const m = f.match(/^teardown-.*-(\d{4}-\d{2}-\d{2})-.*\.mark$/);
|
|
168
|
+
if (m && m[1] !== day) await fs.unlink(join(absLogDir(), f)).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
} catch { /* 清理失败不影响主流程 */ }
|
|
171
|
+
|
|
172
|
+
// 会话总结托底:算出「还有几场会话没总结」。
|
|
173
|
+
// 只给条数不给内容 —— 守「读取侧输出不得随规模增长」;要看明细人自己跑 abs summon。
|
|
174
|
+
// 计算失败一律吞掉:托底提示不能因为它自己算不出来就不提醒。
|
|
175
|
+
let pendingSessions = 0;
|
|
176
|
+
try {
|
|
177
|
+
const { scanProject } = await import('./summon.js');
|
|
178
|
+
const r = await scanProject(root);
|
|
179
|
+
pendingSessions = (r.fresh?.length || 0) + (r.changed?.length || 0);
|
|
180
|
+
} catch { /* 会话目录不存在 / 别的宿主 → 没有这项提示 */ }
|
|
181
|
+
|
|
182
|
+
const msg = [
|
|
183
|
+
// 未设姓名时把设置指令插到第0条 —— 否则后续 todo add/log/note 全会被守卫拦下。
|
|
184
|
+
(await getUser() ? [] : [
|
|
185
|
+
'0) 本机尚未设置使用者姓名 —— 先跑 abs config set user <你的名字>,否则 todo/log/note 都会被拦下;',
|
|
186
|
+
]),
|
|
187
|
+
'[abs] 本会话改过文件,.brain/ 今日无记录。',
|
|
188
|
+
'这条是信息不是命令:该沉淀就沉淀,没有可沉淀的直接回一句「无可沉淀」,不用凑。',
|
|
189
|
+
'需要时:abs todo / abs todo done <id> / abs capture --layer N "..." / abs log "..."',
|
|
190
|
+
...(pendingSessions ? [`另有 ${pendingSessions} 场历史会话从未总结(明细: abs summon)`] : []),
|
|
191
|
+
].join('\n');
|
|
192
|
+
|
|
193
|
+
// Claude Code Stop hook 契约: {"decision":"block","reason":"..."} = 阻止结束并把 reason 回灌给 agent
|
|
194
|
+
return 'push:' + JSON.stringify({ decision: 'block', reason: msg });
|
|
195
|
+
} catch {
|
|
196
|
+
return '{}'; // 永不阻塞宿主
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// src/brain/rules.js — index.md 的 `## Rules` 区:读写与呈现。
|
|
2
|
+
// 从 store.js 拆出(2026-11-01):Rules 有自己的一条独立规则链(长度门槛、禁链接),
|
|
3
|
+
// 与建骨架/看板/任务无关。
|
|
4
|
+
import { brainPath, requireBrain } from '../index.js';
|
|
5
|
+
import { editFile, SKIP } from '../lock.js';
|
|
6
|
+
import { readIfExists } from '../text.js';
|
|
7
|
+
|
|
8
|
+
/** index.md 的 `## Rules` 区名与上限。 */
|
|
9
|
+
export const RULES_HEADING = '## Rules';
|
|
10
|
+
export const RULES_MAX = 30; // 超过就 lint 报:它属于“被读到才有价值”的区,不能无界增长
|
|
11
|
+
/** 单条上限(中文按字算)。2026-10-05 从 cmdRule 内联数字抽出:lint 也要查同一个值 ——
|
|
12
|
+
* 曾经只在「abs rule add」写入时校验,手工编辑 index.md 完全绕过,而 lint 只查条数不查长度
|
|
13
|
+
* → 89 字的条目静默通过,同一内容用 add 写反而被拒(两个口子标准不一致)。 */
|
|
14
|
+
export const RULE_BODY_MAX = 42;
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** 提取 index.md 里的 Rules 区条目(不含标题)。返回 { items:[行], body, found }。 */
|
|
18
|
+
export function readRules(indexText) {
|
|
19
|
+
const lines = String(indexText || '').split('\n');
|
|
20
|
+
const i = lines.findIndex((l) => l.trim() === RULES_HEADING);
|
|
21
|
+
if (i === -1) return { items: [], body: '', found: false };
|
|
22
|
+
const rest = lines.slice(i + 1);
|
|
23
|
+
const j = rest.findIndex((l) => /^##\s/.test(l));
|
|
24
|
+
const body = rest.slice(0, j === -1 ? rest.length : j);
|
|
25
|
+
return { items: body.filter((l) => l.trim().startsWith('- ')), body: body.join('\n').trim(), found: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** load 里 Rules 的呈现:带独立段头,条目原样(不折、不截)。无条目则不占字节。 */
|
|
29
|
+
export function rulesSection(indexText) {
|
|
30
|
+
const { items, body, found } = readRules(indexText);
|
|
31
|
+
if (!found || !body) return '';
|
|
32
|
+
// 有引言句(> 开头)时一并带上,它解释了这个区是干什么的。
|
|
33
|
+
const intro = body.split('\n').filter((l) => l.trim().startsWith('>')).join('\n');
|
|
34
|
+
return [
|
|
35
|
+
`--- Rules (硬规则,先读, index.md) — ${items.length} 条 ---`,
|
|
36
|
+
intro,
|
|
37
|
+
...items,
|
|
38
|
+
].filter((x) => x !== '').join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `abs rule` —— 读写 index.md 的 Rules 区。
|
|
43
|
+
* 无参 = 列出(只给规则,不被 load 的其它内容占上下文)。
|
|
44
|
+
* add <一句话> = 追加一条(走锁写、幂等去重)。
|
|
45
|
+
* 门槛:只该放“违反会丢数据/静默失效/白干活”级规律;长句会被拒并指向概念页。
|
|
46
|
+
*/
|
|
47
|
+
export async function cmdRule({ dir, action, text }) {
|
|
48
|
+
let root;
|
|
49
|
+
try { root = await requireBrain(dir || process.cwd()); } catch {
|
|
50
|
+
return '未找到 .brain/ 图谱。先在项目根运行: abs init';
|
|
51
|
+
}
|
|
52
|
+
const p = brainPath(root, 'index.md');
|
|
53
|
+
if (!action || action === 'list' || action === 'show') {
|
|
54
|
+
const { items, found } = readRules(await readIfExists(p));
|
|
55
|
+
if (!found) return `index.md 无 \`${RULES_HEADING}\` 区(跑 abs load 会自动补位)`;
|
|
56
|
+
if (!items.length) return `${RULES_HEADING} 区为空(add "一句话" 追加)`;
|
|
57
|
+
return [`${RULES_HEADING} — ${items.length} 条:`, ...items].join('\n');
|
|
58
|
+
}
|
|
59
|
+
if (action !== 'add') {
|
|
60
|
+
return `用法: abs rule 列出硬规则\n abs rule add "一句话" [--note "补充"]`;
|
|
61
|
+
}
|
|
62
|
+
const clean = String(text || '').replace(/\s+/g, ' ').trim();
|
|
63
|
+
if (!clean) return '用法: abs rule add "一句话硬规则"';
|
|
64
|
+
// 门槛:**纪律不是记事本**。Rules 是最前面的项目铁律,每条必须一眼扫完。
|
|
65
|
+
// 2026-09-13 用户定:不带链接、不带解释、不写细节 —— 细节进概念页,Rules 只留结论。
|
|
66
|
+
// 宽严:中文一字信息量大,按 40 字算(≈ 英文 80 字符的量)。实测现有 12 条最长 56 字符。
|
|
67
|
+
if (clean.length > RULE_BODY_MAX) {
|
|
68
|
+
return `✗ 太长(${clean.length} > ${RULE_BODY_MAX})—— Rules 是纪律不是记事本:\n`
|
|
69
|
+
+ ` 每条要一眼扫完。细节/出处/例子 → 写进 concepts/ 概念页,这里只留一句话结论。`;
|
|
70
|
+
}
|
|
71
|
+
if (/\[\[|\]\]|https?:\/\//.test(clean)) {
|
|
72
|
+
return '✗ 不带链接 —— Rules 区会被反复全量打印,链接占位且让纪律读起来像索引。\n'
|
|
73
|
+
+ ' 要挂概念页,去概念页自己的「## 关联连接」里挂。';
|
|
74
|
+
}
|
|
75
|
+
let added = null;
|
|
76
|
+
await editFile(p, (cur) => {
|
|
77
|
+
if (cur === null) return SKIP;
|
|
78
|
+
const lines = cur.split('\n');
|
|
79
|
+
const i = lines.findIndex((l) => l.trim() === RULES_HEADING);
|
|
80
|
+
if (i === -1) return SKIP; // 无该区不擅自建(load 会补位)
|
|
81
|
+
if (lines.some((l) => l.trim() === `- ${clean}`)) return SKIP; // 幂等:同句不重复
|
|
82
|
+
// 插在该区最后一条条目之后(保序、不搅动其它条目)
|
|
83
|
+
let at = i + 1;
|
|
84
|
+
for (let k = i + 1; k < lines.length; k++) {
|
|
85
|
+
if (/^##\s/.test(lines[k])) break;
|
|
86
|
+
if (lines[k].trim().startsWith('- ')) at = k + 1;
|
|
87
|
+
}
|
|
88
|
+
lines.splice(at, 0, `- ${clean}`);
|
|
89
|
+
added = clean;
|
|
90
|
+
return { text: lines.join('\n') };
|
|
91
|
+
});
|
|
92
|
+
if (added === null) return `• 已有同句或 index.md 无 ${RULES_HEADING} 区(跳过)`;
|
|
93
|
+
const { items } = readRules(await readIfExists(p));
|
|
94
|
+
const warn = items.length > RULES_MAX
|
|
95
|
+
? `\n⚠ Rules 已 ${items.length} 条 > ${RULES_MAX}:考虑把其中几条提炼成概念页(跑 abs lint 会报)`
|
|
96
|
+
: '';
|
|
97
|
+
return `✓ 已加硬规则(共 ${items.length} 条)\n - ${clean}${warn}`;
|
|
98
|
+
}
|