@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.
@@ -0,0 +1,102 @@
1
+ // src/brain/shape.js — .brain/ 三个受管文件的标准形状 + 结构核对。
2
+ // 从 store.js 拆出(2026-11-01):init 与 load 共用同一份形状表,
3
+ // 单独立文件避免二者互相 import。
4
+ import { promises as fs } from 'node:fs';
5
+ import { brainPath } from '../index.js';
6
+ import { editFile, SKIP } from '../lock.js';
7
+ import { enforceBrainFormat, ensureStateMark } from '../todo.js';
8
+ import { clip } from '../text.js';
9
+
10
+ /** 标准形状表(与 indexTemplate/logTemplate/todoTemplate 同源)。
11
+ * 标记不一致 = 直接改成标准(“能自己处理的先处理”)。
12
+ * 只按**整行精确匹配**改标题,绝不动正文 —— 不做模糊替换,否则正文里提到的旧名会被误改。 */
13
+ // sources/ 堆积阀值:超过就是「采了没消化」。lint 报 SOURCES-PILED-UP,load 顶部同步提示。
14
+ // 两处共用同一常量 —— 阀值只有一个真源(SOURCES_MAX 在 lint.js 定义,此处导入使用)。
15
+
16
+ export const BRAIN_SHAPE = {
17
+ 'todo.md': {
18
+ h1: '# 📋 Todo Board',
19
+ order: ['## Todo', '## Done'],
20
+ },
21
+ 'log.md': {
22
+ h1: '# 🗒 Activity Log',
23
+ order: [], // log 无固定分区(条目行自带时间,倒序)
24
+ },
25
+ 'index.md': {
26
+ h1: '# 🗂 Graph Index',
27
+ order: ['## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
28
+ },
29
+ };
30
+
31
+ /** 旧标记 → 标准标记。load 发现就改(幂等)。含 H1 与分区/分组标题的旧名。 */
32
+ export const LEGACY_MARKS = [
33
+ ['# 🗂 图谱索引', '# 🗂 Graph Index'],
34
+ ['# 🗒 操作日志', '# 🗒 Activity Log'],
35
+ ['# 📋 Todo 看板', '# 📋 Todo Board'],
36
+ ['# 操作日志', '# 🗒 Activity Log'],
37
+ ['# 图谱索引', '# 🗂 Graph Index'],
38
+ ['# Todo 看板', '# 📋 Todo Board'],
39
+ // 四区 → 两区(2026-09-13 用户定)。**这条路径是 load 走的**(rebuildStructure),
40
+ // 与 todo.js 的 LEGACY_SECTION_RENAMES(normalizeTodo 用)是两条独立迁移路径 ——
41
+ // 只改一处会导致另一条认不出旧区名,把它们当"非标准分区"原样留末尾(未完成任务
42
+ // 留在文件里但不再被当 TODO)。测试 'load 也要能迁移' 钉住这一点。
43
+ ['## Backlog', '## Todo'],
44
+ ['## Today / In Progress', '## Todo'],
45
+ ['## Blocked', '## Todo'],
46
+ ['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
47
+ ['### 归档', '### Archived'],
48
+ ['### (未标日期)', '### Undated'],
49
+ ];
50
+
51
+ /** 全套旧标记 → 标准标记(含 H1)。shape 核对与 gate 写入两处共用,故放在这里。 */
52
+ export const legacyRenames = () => LEGACY_MARKS;
53
+
54
+ /**
55
+ * 核对 index/log/todo 的结构,不符就按标准重建(B 档:重排分区 + 内容按归属回填)。
56
+ * 它每次 load 都跑,所以改动立即生效,不必等额外命令。
57
+ *
58
+ * 边界(内容安全):
59
+ * - 只动**标题行位置**与缺失分区的空位,已有内容行按原归属搬运,不改写;
60
+ * - **非标准分区原样留在末尾**(人自加的区不合入,机器不猜语义);
61
+ * - log.md 不做重排(无固定分区,条目自带时间倒序);
62
+ * - 锁内重算 + “改了才写盘”,幂等。
63
+ *
64
+ * 不抛错(load 不能因核对失败而挂)。返回 { fixed:[描述], warn:[描述] }。
65
+ */
66
+ export async function checkBrainShape(root) {
67
+ const fixed = [];
68
+ const warn = [];
69
+ for (const [file, spec] of Object.entries(BRAIN_SHAPE)) {
70
+ const p = brainPath(root, file);
71
+ let text;
72
+ try { text = await fs.readFile(p, 'utf8'); } catch { continue; }
73
+ if (!text.trim()) continue;
74
+ const l1 = (text.split('\n')[0] || '').trim();
75
+ const knownH1 = l1 === spec.h1 || LEGACY_MARKS.some(([o]) => o === l1 && o.startsWith('# '));
76
+ let changed = [];
77
+ await editFile(p, (cur) => {
78
+ if (cur === null) return SKIP;
79
+ // 格式闸门收口在一处(见 todo.js enforceBrainFormat):样式/空行/无头 三条规则
80
+ // 与写入侧共用同一实现,写盘前顺手把破坏格式的部分整理回标准形态。
81
+ // 走 normalizeTodo 的语义(Blocked → [滞留中])由 enforceBrainFormat 内部保证。
82
+ const r = enforceBrainFormat(cur, { ...spec, renames: legacyRenames() });
83
+ // 兜底:仍无状态标记的未完成任务补默认值(新格式文件本就有标记,此处不触发)。
84
+ const next2 = file === 'todo.md'
85
+ ? r.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
86
+ : r.text;
87
+ if (next2 === cur) return SKIP;
88
+ changed = r.fixed.length ? r.fixed : ['结构按标准重排'];
89
+ return { text: next2 };
90
+ }).catch((e) => {
91
+ // 不能吞:load 是开机第一入口,静默跳过会让人以为库是健康的。
92
+ // 典型触发:文件里有孤儿子任务(真错,不修而报)。
93
+ // 只报**具体问题行**(以 `·` 开头的那些),不把整个错误头拼进去。
94
+ const msg = e && e.message ? e.message : '未知错误';
95
+ const detail = msg.split('\n').filter((x) => x.trim().startsWith('·')).map((x) => x.trim().slice(1).trim());
96
+ warn.push(`${file}: 未能自动归一 —— ${detail.length ? detail.join('; ') : msg.split('\n')[0]}`.slice(0, 200));
97
+ });
98
+ if (changed.length) fixed.push(`${file}: ${changed.join('; ')}`);
99
+ if (!knownH1) warn.push(`${file}: 标题非标准(读到 "${clip(l1, 24) || '(空)'}")`);
100
+ }
101
+ return { fixed, warn };
102
+ }
@@ -0,0 +1,62 @@
1
+ // src/brain/summon-cmd.js — `abs summon` 命令族:会话总结的托底扫描。
2
+ //
3
+ // 定位:正常路径是收尾时 `abs capture` / `abs log`。这条路被跳过时,本命令把
4
+ // 「有实质内容但还没总结」的会话提出来。它是托底,不是替代。
5
+ //
6
+ // 三个动作:
7
+ // abs summon 扫当前项目,列出未总结 / 有更新的会话(只读,不写盘)
8
+ // abs summon --mark 把本次列出的记为「已提取」,下次不再列
9
+ // abs summon --status 看账本统计
10
+ import { resolve } from 'node:path';
11
+ import { findBrainRoot } from '../index.js';
12
+ import {
13
+ scanProject, renderScan, readLedger, writeLedger, markSummarized, ledgerPath,
14
+ } from './summon.js';
15
+
16
+ /** 解析项目根:显式 --dir 优先,否则用当前目录的图谱根。 */
17
+ async function resolveRoot(dir) {
18
+ if (dir) {
19
+ const r = await findBrainRoot(resolve(dir));
20
+ if (!r) throw new Error(`✗ ${resolve(dir)} 下没有 .brain/ 图谱`);
21
+ return r;
22
+ }
23
+ const here = await findBrainRoot(process.cwd());
24
+ if (!here) throw new Error('✗ 当前目录没有 .brain/ 图谱(用 --dir 指定,或先 abs init)');
25
+ return here;
26
+ }
27
+
28
+ export async function cmdSummon({ dir, mark = false, full = false, limit = 15, status = false } = {}) {
29
+ const path = ledgerPath();
30
+
31
+ if (status) {
32
+ const book = await readLedger(path);
33
+ const n = Object.keys(book.files).length;
34
+ const days = new Set(Object.values(book.files).map((r) => r.day).filter(Boolean));
35
+ return [
36
+ `📒 总结账本: ${path}`,
37
+ `已记录 ${n} 个会话,跨 ${days.size} 天`,
38
+ n ? `最近一条: ${Object.entries(book.files).sort((a, b) => (b[1].summarizedAt || '').localeCompare(a[1].summarizedAt || ''))[0].map((x) => Array.isArray(x) ? JSON.stringify(x).slice(0, 90) : x).join(' · ')}` : '',
39
+ ].filter(Boolean).join('\n');
40
+ }
41
+
42
+ let root;
43
+ try { root = await resolveRoot(dir); } catch (e) { return e.message; }
44
+
45
+ const book = await readLedger(path);
46
+ const result = await scanProject(root, { ledger: book });
47
+
48
+ const pending = [...result.fresh, ...result.changed];
49
+ if (!mark) return renderScan(result, { full, limit });
50
+
51
+ // --mark:记为已提取。不写知识页 —— 那是人的判断,不是本命令的。
52
+ if (!pending.length) return '✓ 没有待提取的会话,账本未改动。';
53
+ markSummarized(book, pending);
54
+ await writeLedger(book, path);
55
+ return [
56
+ `✓ 已记账 ${pending.length} 个会话(下次不再列出)`,
57
+ ` 账本: ${path}`,
58
+ '',
59
+ '注意:这一步只记「这些会话我提过了」,**没有写任何知识页**。',
60
+ '要沉淀的仍要显式落: abs capture --layer N "…" / abs log "…"',
61
+ ].join('\n');
62
+ }
@@ -0,0 +1,236 @@
1
+ // src/brain/summon.js — 会话总结的托底:扫 pi 会话目录,找出「还没总结过」的会话。
2
+ //
3
+ // 为何需要它(2026-11-01 实测):pi 会话目录里本项目 63 个会话跨 26 天,
4
+ // 而 .brain/sessions/ 只有 19 天有快照 —— 缺 12 天。正常路径是收尾时人主动总结,
5
+ // 但这个动作会被跳过。「琐碎知识没有正常总结」是结构问题:会话发生了,没人回头提炼。
6
+ // 本命令是**托底**,不是替代:有快照就不管,没快照才提出来。
7
+ //
8
+ // 判重靠 md5 账本(用户定,2026-11-01):
9
+ // 每条已总结的会话记录其文件 md5。下次扫描:
10
+ // · md5 相同 → 已总结过,跳过
11
+ // · md5 不同 → 会话被补写过(同一会话继续聊),要**补充总结**
12
+ // 为何用 md5 而不是 mtime/size:mtime 会因任何触碰变化(备份、同步),
13
+ // size 在「改了同一长度内容」时不变。md5 是内容判据,恰好对应「会话内容变没变」。
14
+ // 代价是读全文 —— 实测本项目 63 个文件 55MB,全读约 0.2s,可接受。
15
+ //
16
+ // 边界(守「已存在的人工内容一律不覆盖」):
17
+ // 本命令**只输出摘要给人看,不写任何 .brain/ 知识页**。
18
+ // 账本只记「这个会话文件我提过了」,不记内容判断。
19
+ // 要落哪一条由人确认后显式 `abs capture` / `abs log`。
20
+ //
21
+ // 会话文件格式(pi,2026-11-01 实测):
22
+ // JSONL,每行 { type: 'session' | 'message' | ... };message.content 是**数组**。
23
+ // 坑:曾按 typeof content === 'string' 判断 → 用户消息恒 0 条。
24
+
25
+ import { promises as fs } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { homedir } from 'node:os';
28
+ import { createHash } from 'node:crypto';
29
+
30
+ /** 活跃会话判定:mtime 在此毫秒内 → 文件还在写,本轮跳过。
31
+ * 实测(2026-11-01)当前会话 0 分钟前,其余最晚 189 分钟前,30 分钟阈值安全。 */
32
+ export const ACTIVE_MS = 30 * 60 * 1000;
33
+
34
+ /** 会话根目录。ABS_PI_SESSIONS 可覆盖(测试隔离 / 别的宿主)。 */
35
+ export function sessionsRoot() {
36
+ return process.env.ABS_PI_SESSIONS || join(homedir(), '.pi', 'agent', 'sessions');
37
+ }
38
+
39
+ /** 项目绝对路径 → pi 的会话目录名。
40
+ *
41
+ * 规则(2026-11-01 实测 pi 的 42 个真实目录反推,5 个样本全通过):
42
+ * `--` + 去掉开头的 `/` + 所有 `/` 换成 `-` + `--`
43
+ * `/Users/fanchao/Code/agent_brain_sync`
44
+ * → `--Users-fanchao-Code-agent_brain_sync--`
45
+ *
46
+ * 坑一:曾用 `replace(/[^A-Za-z0-9]/g, '-')` —— 下划线 `_` 也被换掉,
47
+ * `agent_brain_sync` 变 `agent-brain-sync`,目录找不到(实测报错)。
48
+ * `_` 与 `.` 必须原样保留。
49
+ * 坑二:这个编码**不可逆**——路径本身可能含 `-`(如 `vibe-coding-help`),
50
+ * 解码时无法区分哪个 `-` 原本是 `/`。实测 42 个目录只有 14 个能解码回存在的路径。
51
+ * 所以只能按本函数正向拼,不要试图反查。 */
52
+ export function projectDirName(projectRoot) {
53
+ const body = String(projectRoot).replace(/^\/+/, '').replace(/\//g, '-');
54
+ return `--${body}--`;
55
+ }
56
+
57
+ /** 账本路径:和库同目录(~/.abs/summarized.json),ABS_SUMMON_LEDGER 可覆盖。 */
58
+ export function ledgerPath() {
59
+ return process.env.ABS_SUMMON_LEDGER || join(homedir(), '.abs', 'summarized.json');
60
+ }
61
+
62
+ /** content 数组取纯文本。string 也兼容。 */
63
+ function textOf(content) {
64
+ if (typeof content === 'string') return content;
65
+ if (!Array.isArray(content)) return '';
66
+ return content.filter((c) => c && c.type === 'text').map((c) => c.text || '').join('\n');
67
+ }
68
+
69
+ /** 是否值得当成「用户真说的话」。滤掉注入块与无信息量的单句。 */
70
+ function isRealUserText(t) {
71
+ const s = String(t || '').trim();
72
+ if (!s) return false;
73
+ if (s.startsWith('<')) return false; // 系统注入块
74
+ if (s.startsWith('[abs')) return false; // abs 自己的提醒
75
+ if (s.startsWith('[SYSTEM')) return false;
76
+ if (s.length <= 4 && /^(abs|继续|好的|是的|嗯|ok|好)$/i.test(s)) return false;
77
+ return true;
78
+ }
79
+
80
+ /** 会话文件 md5。判「内容有没有变」的唯一可靠判据。 */
81
+ async function md5Of(file) {
82
+ const buf = await fs.readFile(file).catch(() => null);
83
+ if (!buf) return null;
84
+ return createHash('md5').update(buf).digest('hex');
85
+ }
86
+
87
+ /** 解析一个会话文件 → 摘要素材。 */
88
+ export async function digestSession(file) {
89
+ let raw;
90
+ try { raw = await fs.readFile(file, 'utf8'); } catch { return null; }
91
+ const out = {
92
+ file, start: '', endedAt: '', day: '',
93
+ users: [], filesEdited: new Map(), toolCalls: 0,
94
+ };
95
+ for (const l of raw.split('\n')) {
96
+ if (!l) continue;
97
+ let o;
98
+ try { o = JSON.parse(l); } catch { continue; }
99
+ if (o.type === 'session') { out.start = o.timestamp || ''; out.cwd = o.cwd; continue; }
100
+ if (o.type !== 'message') continue;
101
+ if (o.timestamp) out.endedAt = o.timestamp;
102
+ const m = o.message || {};
103
+ if (m.role === 'user') {
104
+ const t = textOf(m.content).trim();
105
+ if (isRealUserText(t)) out.users.push(t);
106
+ } else if (m.role === 'assistant' && Array.isArray(m.content)) {
107
+ for (const c of m.content) {
108
+ if (!c || c.type !== 'toolCall') continue;
109
+ out.toolCalls++;
110
+ const p = c.arguments && c.arguments.path;
111
+ if (p && typeof p === 'string' && ['edit', 'write'].includes(c.name)) {
112
+ out.filesEdited.set(p, (out.filesEdited.get(p) || 0) + 1);
113
+ }
114
+ }
115
+ }
116
+ }
117
+ out.day = (out.start || '').slice(0, 10);
118
+ return out;
119
+ }
120
+
121
+ /** 已有快照的日期集合(从 .brain/sessions/*.md 文件名取)。
122
+ *
123
+ * 为何要和 md5 账本并用(2026-11-01):两条判据回答的是不同问题 ——
124
+ * · 有快照 → 那天**已经总结过**(人写的,最权威)
125
+ * · 账本记录 md5 → 那个**会话文件**提过了
126
+ * 只按 md5 判会把「已有快照的日子」全列出来当未总结(实测 15 条全是已有快照的),
127
+ * 那是把托底做成噪音。两条都要:有快照就跳过,没有才看账本。 */
128
+ async function daysWithSnapshot(brainSessionsDir) {
129
+ const days = new Set();
130
+ let files;
131
+ try { files = await fs.readdir(brainSessionsDir); } catch { return days; }
132
+ for (const f of files) {
133
+ const m = f.match(/(\d{4}-\d{2}-\d{2})/);
134
+ if (m) days.add(m[1]);
135
+ }
136
+ return days;
137
+ }
138
+
139
+ /** 读账本。格式 { version, files: { "<相对文件名>": { md5, day, summarizedAt } } } */
140
+ export async function readLedger(path = ledgerPath()) {
141
+ try {
142
+ const j = JSON.parse(await fs.readFile(path, 'utf8'));
143
+ if (j && typeof j.files === 'object') return j;
144
+ } catch { /* 不存在或坏了 → 空账本 */ }
145
+ return { version: 1, files: {} };
146
+ }
147
+
148
+ /** 写账本(原子:tmp + rename)。 */
149
+ export async function writeLedger(ledger, path = ledgerPath()) {
150
+ await fs.mkdir(join(path, '..'), { recursive: true });
151
+ const tmp = `${path}.tmp-${Date.now()}`;
152
+ await fs.writeFile(tmp, JSON.stringify(ledger, null, 2) + '\n');
153
+ await fs.rename(tmp, path);
154
+ }
155
+
156
+ /**
157
+ * 扫一个项目,分三类:
158
+ * fresh —— 从未总结过(账本里没有)
159
+ * changed —— 总结过但 md5 变了(会话被续写,要补充)
160
+ * skipped —— md5 一致(真的总结过了)
161
+ * 跳过活跃会话(文件仍在写),避免把半截内容记成已总结。
162
+ */
163
+ export async function scanProject(projectRoot, { ledger, now = Date.now() } = {}) {
164
+ const dir = join(sessionsRoot(), projectDirName(projectRoot));
165
+ let names;
166
+ try { names = (await fs.readdir(dir)).filter((f) => f.endsWith('.jsonl')); } catch {
167
+ return { error: `找不到会话目录: ${dir}`, fresh: [], changed: [], skipped: [], active: [] };
168
+ }
169
+ const book = ledger || await readLedger();
170
+ // 已有快照的日子整日跳过 —— 人已经总结过,托底不该再吵。
171
+ const haveSnapshot = await daysWithSnapshot(join(projectRoot, '.brain', 'sessions'));
172
+ const fresh = [], changed = [], skipped = [], active = [];
173
+
174
+ for (const name of names.sort()) {
175
+ const full = join(dir, name);
176
+ let st;
177
+ try { st = await fs.stat(full); } catch { continue; }
178
+ if (now - st.mtimeMs < ACTIVE_MS) { active.push({ name, reason: '会话仍在写' }); continue; }
179
+
180
+ const md5 = await md5Of(full);
181
+ if (!md5) continue;
182
+ const d = await digestSession(full);
183
+ if (!d || !d.users.length) { skipped.push({ name, reason: '无真实用户输入' }); continue; }
184
+ if (d.day && haveSnapshot.has(d.day)) { skipped.push({ name, reason: `${d.day} 已有快照` }); continue; }
185
+
186
+ const rec = book.files[name];
187
+ if (rec && rec.md5 === md5) { skipped.push({ name, reason: 'md5 一致' }); continue; }
188
+
189
+ const item = { name, md5, day: d.day, digest: d, sizeKB: Math.round(st.size / 1024) };
190
+ if (rec) changed.push({ ...item, prevMd5: rec.md5 }); else fresh.push(item);
191
+ }
192
+ return { fresh, changed, skipped, active, dir };
193
+ }
194
+
195
+ /** 记一笔:这些会话已提取(不管人是否落盘,账本只记「提过了」)。 */
196
+ export function markSummarized(ledger, items, when = new Date().toISOString()) {
197
+ for (const it of items) {
198
+ ledger.files[it.name] = { md5: it.md5, day: it.day, summarizedAt: when };
199
+ }
200
+ return ledger;
201
+ }
202
+
203
+ /** 渲染待确认清单。不写盘。 */
204
+ export function renderScan(result, { full = false, limit = 15 } = {}) {
205
+ if (result.error) return `✗ ${result.error}`;
206
+ const out = [
207
+ `会话目录: ${result.dir}`,
208
+ `未总结 ${result.fresh.length} 个 · 有更新 ${result.changed.length} 个 · 已总结 ${result.skipped.length} 个 · 活跃跳过 ${result.active.length} 个`,
209
+ ];
210
+ const show = (title, items, tail) => {
211
+ if (!items.length) return;
212
+ out.push('', `--- ${title} (${items.length}) ---`);
213
+ for (const it of items.slice(0, limit)) {
214
+ out.push(`${it.day} ${it.sizeKB}KB ${it.name.slice(0, 34)}`);
215
+ const sample = it.digest.users.slice(0, full ? 8 : 2);
216
+ for (const u of sample) {
217
+ out.push(` · ${u.replace(/\s+/g, ' ').trim().slice(0, full ? 200 : 70)}`);
218
+ }
219
+ if (tail) out.push(` ${tail}`);
220
+ }
221
+ if (items.length > limit) out.push(` …另有 ${items.length - limit} 个`);
222
+ };
223
+ show('从未总结', result.fresh);
224
+ show('总结过但有更新(会话被续写,需补充)', result.changed, '↳ md5 与账本不符');
225
+ if (!result.fresh.length && !result.changed.length) {
226
+ out.push('', '✓ 所有已结束的会话都总结过了。');
227
+ } else {
228
+ out.push(
229
+ '',
230
+ '这是**待确认清单,不会自动写盘**。落哪一条由你判断:',
231
+ ' abs capture --layer 4 "…" 或 abs log "完成 X:…"',
232
+ ' abs summon --mark 记下「这些已提取」,下次不再列出',
233
+ );
234
+ }
235
+ return out.join('\n');
236
+ }
package/src/note.js CHANGED
@@ -12,6 +12,18 @@ import { editFile, SKIP } from './lock.js';
12
12
  import { impactOf } from './codegraph.js';
13
13
  import { clip, slugOf } from './text.js';
14
14
 
15
+
16
+ /** 惰性调 autoIndex —— 断循环 import:note.js ← capture.js ← db-cmd.js ← note.js。
17
+ * 动态 import 只在真正写入时解析,静态加载期不受影响。失败一律吞掉(见 autoIndex 边界)。 */
18
+ async function autoIndexLazy() {
19
+ try {
20
+ const { autoIndex } = await import('./brain/db-cmd.js');
21
+ return await autoIndex();
22
+ } catch {
23
+ return { ok: false, reason: '入库模块不可用' };
24
+ }
25
+ }
26
+
15
27
  // ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
16
28
 
17
29
  export async function cmdNote({ dir, text, tags, when, impact, type }) {
@@ -91,6 +103,8 @@ export async function cmdNote({ dir, text, tags, when, impact, type }) {
91
103
  const slug = file.replace(/\.md$/, '');
92
104
  await registerInIndex(root, 'Sources', slug, heading);
93
105
  await cmdLog({ dir: root, title: clean, kind: 'note' });
106
+ // 落盘即入库(库已存在时)。失败不阻断 —— 见 autoIndex 的边界说明。
107
+ await autoIndexLazy();
94
108
  return `✓ 经验暂存 → sources/${file}\n ${clean} ${atTag(who)}`;
95
109
  }
96
110
 
@@ -174,6 +188,7 @@ export async function cmdConcept({ dir, slug, title, tags, desc }) {
174
188
  }
175
189
  const oneLine = String(desc || '').trim() || clip(head, 60);
176
190
  await registerInIndex(root, 'Concepts', name, oneLine);
191
+ await autoIndexLazy();
177
192
  // 不写 log.md(2026-10-05 用户定):`新建概念页 x` 是**命令的副作用**不是成果 ——
178
193
  // 38 字符、零信息量,且「该页存在」已由 registerInIndex 落在 index.md 的 Concepts 区
179
194
  // (那是 index 的职责)。同件事落两处,且建 10 个页 = 10 行流水噪声自动重现,