@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.
@@ -0,0 +1,303 @@
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 ownBook = !ledger; // 账本是自己读的(不是调用方给的)
170
+ const book = ledger || await readLedger();
171
+ // 已有快照的日子整日跳过 —— 人已经总结过,托底不该再吵。
172
+ const haveSnapshot = await daysWithSnapshot(join(projectRoot, '.brain', 'sessions'));
173
+ const fresh = [], changed = [], skipped = [], active = [];
174
+ let touched = false; // 是否补记过 stat(需要回写账本,否则下次又得读全文)
175
+
176
+ for (const name of names.sort()) {
177
+ const full = join(dir, name);
178
+ let st;
179
+ try { st = await fs.stat(full); } catch { continue; }
180
+ if (now - st.mtimeMs < ACTIVE_MS) { active.push({ name, reason: '会话仍在写' }); continue; }
181
+
182
+ const rec = book.files[name];
183
+
184
+ // ── 便宜的预筛:文件大小 + mtime 都没变 → 内容必然没变 → 跳过,不读全文。
185
+ // 为何要它(2026-11-01 实测):全读算 md5 在 63 个文件 / 55MB 上是 74ms,
186
+ // 而只读 stat 是 0ms(快 74 倍)。这条路径会被 abs log 每次收尾调用,
187
+ // 且文件数只增不减 —— 不预筛就是「读取侧随规模增长」。
188
+ // 漏判风险为零:改内容必动 mtime,内容变了而 size 与 mtime 都不变不可能发生。
189
+ if (rec && rec.size === st.size && rec.mtime === st.mtimeMs && rec.md5) {
190
+ skipped.push({ name, reason: 'size+mtime 未变' });
191
+ continue;
192
+ }
193
+
194
+ // 已有快照的日子,先看账本里记的 day —— 不必读文件就能判。
195
+ // 实测(2026-11-01):63 个文件里有 41 个属于「已有快照」的日子,
196
+ // 原来要先 digestSession 读全文才知道日期,等于白读 41 次。账本里有 day 时先拦。
197
+ if (rec && rec.day && haveSnapshot.has(rec.day)) {
198
+ skipped.push({ name, reason: `${rec.day} 已有快照` });
199
+ continue;
200
+ }
201
+
202
+ const d = await digestSession(full);
203
+ if (!d || !d.users.length) { skipped.push({ name, reason: '无真实用户输入' }); continue; }
204
+ if (d.day && haveSnapshot.has(d.day)) { skipped.push({ name, reason: `${d.day} 已有快照` }); continue; }
205
+
206
+ // 到这里才需要 md5(要么文件变了,要么账本里没有)。
207
+ const md5 = await md5Of(full);
208
+ if (!md5) continue;
209
+ if (rec && rec.md5 === md5) {
210
+ // size/mtime 变了但内容没变(如 touch、备份还原)→ 补记新的 stat,下次走预筛
211
+ book.files[name] = { ...rec, size: st.size, mtime: st.mtimeMs };
212
+ touched = true;
213
+ skipped.push({ name, reason: 'md5 一致(仅 stat 变化)' });
214
+ continue;
215
+ }
216
+
217
+ const item = { name, md5, day: d.day, digest: d, sizeKB: Math.round(st.size / 1024), size: st.size, mtime: st.mtimeMs };
218
+ if (rec) changed.push({ ...item, prevMd5: rec.md5 }); else fresh.push(item);
219
+ }
220
+ // 补记的 stat 要落盘 —— 只改内存等于每次重读全文,预筛白做。
221
+ // 只在自己读的账本上回写:调用方传了 ledger 时,回写时机由它决定
222
+ // (cmdSummon 要等 --mark 才写,免得扫描顺手改了用户的账本)。
223
+ if (touched && ownBook) await writeLedger(book);
224
+
225
+ return { fresh, changed, skipped, active, dir };
226
+ }
227
+
228
+ /** 记一笔:这些会话已提取(不管人是否落盘,账本只记「提过了」)。 */
229
+ /** 记一笔:这些会话已提取。同时存 size+mtime —— 下次扫描靠它做便宜预筛,避免全读。 */
230
+ export function markSummarized(ledger, items, when = new Date().toISOString()) {
231
+ for (const it of items) {
232
+ ledger.files[it.name] = {
233
+ md5: it.md5, day: it.day, summarizedAt: when,
234
+ size: it.size, mtime: it.mtime,
235
+ };
236
+ }
237
+ return ledger;
238
+ }
239
+
240
+ /** 渲染待确认清单。不写盘。 */
241
+ export function renderScan(result, { full = false, limit = 15 } = {}) {
242
+ if (result.error) return `✗ ${result.error}`;
243
+ const out = [
244
+ `会话目录: ${result.dir}`,
245
+ `未总结 ${result.fresh.length} 个 · 有更新 ${result.changed.length} 个 · 已总结 ${result.skipped.length} 个 · 活跃跳过 ${result.active.length} 个`,
246
+ ];
247
+ const show = (title, items, tail) => {
248
+ if (!items.length) return;
249
+ out.push('', `--- ${title} (${items.length}) ---`);
250
+ for (const it of items.slice(0, limit)) {
251
+ out.push(`${it.day} ${it.sizeKB}KB ${it.name.slice(0, 34)}`);
252
+ const sample = it.digest.users.slice(0, full ? 8 : 2);
253
+ for (const u of sample) {
254
+ out.push(` · ${u.replace(/\s+/g, ' ').trim().slice(0, full ? 200 : 70)}`);
255
+ }
256
+ if (tail) out.push(` ${tail}`);
257
+ }
258
+ if (items.length > limit) out.push(` …另有 ${items.length - limit} 个`);
259
+ };
260
+ show('从未总结', result.fresh);
261
+ show('总结过但有更新(会话被续写,需补充)', result.changed, '↳ md5 与账本不符');
262
+ if (!result.fresh.length && !result.changed.length) {
263
+ out.push('', '✓ 所有已结束的会话都总结过了。');
264
+ } else {
265
+ out.push(
266
+ '',
267
+ '这是**待确认清单,不会自动写盘**。落哪一条由你判断:',
268
+ ' abs capture --layer 4 "…" 或 abs log "完成 X:…"',
269
+ ' abs summon --mark 记下「这些已提取」,下次不再列出',
270
+ );
271
+ }
272
+ return out.join('\n');
273
+ }
274
+
275
+ /** 收尾时顺手列出未总结/有更新的会话(给人看的尾巴)。
276
+ *
277
+ * 为何放在这里而不是各调用方(2026-11-02):abs log 与 abs capture 都要用,
278
+ * 两处各写一份必然漂移。失败一律吞掉 —— 记流水/记经验不能因为扫会话而失败。
279
+ *
280
+ * @param {string} root 项目根
281
+ * @param {object} opts { limit } 最多列几场
282
+ * @returns {Promise<string>} 无待总结时返回空串(不占输出)
283
+ */
284
+ export async function summonTail(root, { limit = 8 } = {}) {
285
+ try {
286
+ const r = await scanProject(root);
287
+ const pending = (r.fresh?.length || 0) + (r.changed?.length || 0);
288
+ if (!pending) return '';
289
+ const lines = ['', `--- 本项目有 ${pending} 场会话待总结 ---`];
290
+ for (const it of [...r.fresh, ...r.changed].slice(0, limit)) {
291
+ const tag = it.prevMd5 ? '(有更新)' : '';
292
+ lines.push(` ${it.day} ${it.sizeKB}KB${tag}`);
293
+ for (const u of (it.digest?.users || []).slice(0, 2)) {
294
+ lines.push(` · ${u.replace(/\s+/g, ' ').trim().slice(0, 70)}`);
295
+ }
296
+ }
297
+ if (pending > limit) lines.push(` …另有 ${pending - limit} 场(abs summon 看全量)`);
298
+ lines.push(' 落哪条由你判断: abs capture --layer N "…" | abs summon --mark 记账不再提');
299
+ return '\n' + lines.join('\n');
300
+ } catch {
301
+ return '';
302
+ }
303
+ }
package/src/install.js CHANGED
@@ -919,8 +919,11 @@ export function installSummary() {
919
919
  return HOSTS.map((h) => ` ${h.key.padEnd(12)} ${h.label}`).join('\n');
920
920
  }
921
921
 
922
- export async function runInstall({ agent, mcp = true, skill = true, yes = false } = {}) {
923
- const targets = agent ? [agent] : await pickAgents(yes);
922
+ export async function runInstall({ agent, all = false, mcp = true, skill = true, yes = false } = {}) {
923
+ // all=true 是显式全选的官方入口。
924
+ // 为何要它(2026-11-02):默认改成只装 pi 后,「装到所有宿主」这个能力
925
+ // 必须有明确表达方式 —— 否则测试只能靠 --yes 的旧语义,行为一变就全挂。
926
+ const targets = agent ? [agent] : (all ? Object.keys(INSTALLERS) : await pickAgents(yes));
924
927
  const failed = [];
925
928
  for (const key of targets) {
926
929
  const inst = INSTALLERS[key];
@@ -976,17 +979,28 @@ export async function runUninstall({ agent } = {}) {
976
979
  }
977
980
 
978
981
  // 交互式多选(无 TTY 时回退为全部)
982
+ /** 默认装的宿主。
983
+ *
984
+ * 2026-11-02 用户定:默认只装 pi,其余宿主 opt-in。
985
+ * 理由:装到不用的宿主上会往那边的配置文件里塞 hook/MCP 条目 ——
986
+ * 用户没在用 Claude Code / Codex / OpenCode 时,那是纯污染。
987
+ * 要装别的:abs install --agent claude-code(可逐个装)。
988
+ */
989
+ export const DEFAULT_AGENT = 'pi';
990
+
979
991
  async function pickAgents(yes = false) {
980
- // --yes / 非 TTY: 直接全选,绝不弹交互。
992
+ // --yes / 非 TTY: 装默认宿主(pi),绝不弹交互。
981
993
  // 坑: 曾经只看 isTTY,--yes 被收下却从不使用 —— 在 TTY 里跑
982
994
  // abs install --yes(自动化/脚本)仍会弹提示并挂起等输入。
983
- if (yes || !process.stdin.isTTY) return Object.keys(INSTALLERS);
995
+ if (yes || !process.stdin.isTTY) return [DEFAULT_AGENT];
984
996
  const readline = await import('node:readline/promises');
985
997
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
986
- console.log('选择要安装的智能体 (逗号分隔, 回车=全部):');
998
+ console.log(`选择要安装的智能体 (逗号分隔, 回车=只装 ${DEFAULT_AGENT}):`);
987
999
  console.log(installSummary());
988
1000
  const ans = (await rl.question('> ')).trim();
989
1001
  rl.close();
990
- if (!ans) return Object.keys(INSTALLERS);
1002
+ if (!ans) return [DEFAULT_AGENT];
1003
+ // all / * 作为显式全选的逃生门,避免把「装全部」的能力彻底拿掉
1004
+ if (ans === 'all' || ans === '*') return Object.keys(INSTALLERS);
991
1005
  return ans.split(',').map((s) => s.trim()).filter(Boolean);
992
1006
  }
package/src/note.js CHANGED
@@ -11,6 +11,19 @@ import { today, localStamp, LOG_KINDS } from './todo.js';
11
11
  import { editFile, SKIP } from './lock.js';
12
12
  import { impactOf } from './codegraph.js';
13
13
  import { clip, slugOf } from './text.js';
14
+ import { summonTail } from './brain/summon.js';
15
+
16
+
17
+ /** 惰性调 autoIndex —— 断循环 import:note.js ← capture.js ← db-cmd.js ← note.js。
18
+ * 动态 import 只在真正写入时解析,静态加载期不受影响。失败一律吞掉(见 autoIndex 边界)。 */
19
+ async function autoIndexLazy() {
20
+ try {
21
+ const { autoIndex } = await import('./brain/db-cmd.js');
22
+ return await autoIndex();
23
+ } catch {
24
+ return { ok: false, reason: '入库模块不可用' };
25
+ }
26
+ }
14
27
 
15
28
  // ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
16
29
 
@@ -90,7 +103,9 @@ export async function cmdNote({ dir, text, tags, when, impact, type }) {
90
103
  // index Sources 区登记(锁内幂等:别页已登记则跳过,防并发重复) + log 一行
91
104
  const slug = file.replace(/\.md$/, '');
92
105
  await registerInIndex(root, 'Sources', slug, heading);
93
- await cmdLog({ dir: root, title: clean, kind: 'note' });
106
+ await cmdLog({ dir: root, title: clean, kind: 'note', summon: false });
107
+ // 落盘即入库(库已存在时)。失败不阻断 —— 见 autoIndex 的边界说明。
108
+ await autoIndexLazy();
94
109
  return `✓ 经验暂存 → sources/${file}\n ${clean} ${atTag(who)}`;
95
110
  }
96
111
 
@@ -174,6 +189,7 @@ export async function cmdConcept({ dir, slug, title, tags, desc }) {
174
189
  }
175
190
  const oneLine = String(desc || '').trim() || clip(head, 60);
176
191
  await registerInIndex(root, 'Concepts', name, oneLine);
192
+ await autoIndexLazy();
177
193
  // 不写 log.md(2026-10-05 用户定):`新建概念页 x` 是**命令的副作用**不是成果 ——
178
194
  // 38 字符、零信息量,且「该页存在」已由 registerInIndex 落在 index.md 的 Concepts 区
179
195
  // (那是 index 的职责)。同件事落两处,且建 10 个页 = 10 行流水噪声自动重现,
@@ -245,7 +261,7 @@ export async function registerInIndex(root, section, slug, desc) {
245
261
  });
246
262
  }
247
263
  // ---------- log: 追加工作成果沉淀摘要(用户/AI 主动 abs log "..." 记, 不收工具动作流水) ----------
248
- export async function cmdLog({ dir, title, kind = 'dev' }) {
264
+ export async function cmdLog({ dir, title, kind = 'dev', summon = true }) {
249
265
  const root = await requireBrain(dir || process.cwd());
250
266
  const who = await requireUser(); // 写操作守卫
251
267
  // ★ kind 必须是枚举值(2026-10-05 加):此前无校验,传什么写什么 ——
@@ -279,6 +295,16 @@ export async function cmdLog({ dir, title, kind = 'dev' }) {
279
295
  return { text: lines.join('\n') };
280
296
  });
281
297
  // 重复时不报「✓ 已写入」—— 那是在说假话(用户会以为多了一条)。
282
- if (res === SKIP) return `• 同一分钟已有同一内容,已跳过 → ${p}\n ${line}`;
283
- return `✓ log → ${p}\n ${line}`;
284
- }
298
+ const head = res === SKIP
299
+ ? `• 同一分钟已有同一内容,已跳过 → ${p}\n ${line}`
300
+ : `✓ log → ${p}\n ${line}`;
301
+
302
+ // 落盘顺手深读本项目下的会话:没总结过的、有更新的,一并提出来。
303
+ // 为何挂在 log 上(2026-11-01 用户定):`abs log` 是收尾动作本身,
304
+ // 人在这里才愿意看「还有哪些会话没总结」;平时他不会主动跑 summon。
305
+ // 只给线索不写盘 —— 落哪一条仍是人的判断(见 summon.js 的边界说明)。
306
+ // summon=false 供 note/concept 内部调用时关闭:那些也是写 log,但不是收尾场景。
307
+ const tail = summon ? await summonTail(root) : '';
308
+ return head + tail;
309
+ }
310
+