@bolloon/bolloon-agent 0.3.47 → 0.3.49

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,470 @@
1
+ /**
2
+ * knowledge-organizer.ts — 自动整理心跳 · 知识层整理 (2026-08-10)
3
+ *
4
+ * 目的: 自动整理不止 skills — 每次整理 tick 还维护 Bolloon 的"自我认知"知识层:
5
+ *
6
+ * 1. archiveContextOs 归档 Context OS 资产层 (快照 + tmp 清理)
7
+ * 2. tidySocialRelations 整理外部社交关系 (known_peers + dunbar tier 统计/失联)
8
+ * 3. tidyExternalAgents 整理外部智能体描述 (peers/<pk>/agents/*)
9
+ * 4. tidyInternalAgents 整理内部智能体描述 (channels persona + persona/<agentId>/)
10
+ * 5. maintainJudgeness judgeness 内容维护 (descriptions 归档)
11
+ * 6. understandProjects 其他项目目录的理解 (扫描项目 → 04-Projects 索引)
12
+ * 7. understandUserProfile 用户画像的理解 (persona user.md + 01-Me → 画像快照)
13
+ * 8. archiveRecentLogs 最近记录的日志归档 (goals/engine 等 jsonl)
14
+ * 9. maintainGoals 用户长期/短期目标维护 (goals → 03-Current 摘要)
15
+ *
16
+ * 设计原则:
17
+ * - 每个整理器是纯函数: 输入 home/cwd (可注入), 输出 OrganizeSection, 全部 try/catch —
18
+ * 单个失败不阻塞其他 (整理是尽力而为, 不抛错).
19
+ * - 默认无 LLM (文件统计/归档/快照); 传 llm 时增强"理解类"整理器 (项目/画像/目标摘要).
20
+ * - runKnowledgeOrganize 串行跑全部 (顺序稳定, 日志可读), 汇总 sections.
21
+ */
22
+ import * as fs from 'fs/promises';
23
+ import * as os from 'os';
24
+ import * as path from 'path';
25
+ /** 读取 JSON 文件, 失败返回 null (安静) */
26
+ async function readJson(p) {
27
+ try {
28
+ return JSON.parse(await fs.readFile(p, 'utf-8'));
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /** 读取目录下直接子项 (文件+目录), 失败返回 [] */
35
+ async function readDir(p) {
36
+ try {
37
+ return await fs.readdir(p);
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ }
43
+ /** 目录下 *.md 文件 (一层), 带 mtime */
44
+ async function listMd(p) {
45
+ const entries = await readDir(p);
46
+ const out = [];
47
+ for (const e of entries) {
48
+ if (!e.endsWith('.md'))
49
+ continue;
50
+ try {
51
+ const st = await fs.stat(path.join(p, e));
52
+ out.push({ file: e, mtime: st.mtimeMs });
53
+ }
54
+ catch { /* 跳过 */ }
55
+ }
56
+ return out;
57
+ }
58
+ // ───────────────────────── 1. Context OS 归档 ─────────────────────────
59
+ const CONTEXT_OS_LAYERS = [
60
+ '01-Me', '02-Network', '03-Current', '04-Projects', '05-Prompts',
61
+ '06-Protocols', '07-Knowledge', '08-Insights', '09-Tools', '10-Skills',
62
+ '11-Write', '12-Analysis',
63
+ ];
64
+ /** 归档 Context OS: 统计各层资产 + 打快照清单 + 清理过期 tmp 草稿 */
65
+ export async function archiveContextOs(home) {
66
+ const root = path.join(home, '.bolloon', 'context-os');
67
+ const snapshotsDir = path.join(root, 'snapshots');
68
+ let handled = 0;
69
+ let totalAssets = 0;
70
+ let archivedTmp = 0;
71
+ try {
72
+ await fs.mkdir(snapshotsDir, { recursive: true });
73
+ // 统计各层资产 (直接子文件, 排除 README.md)
74
+ const layerStats = [];
75
+ for (const layer of CONTEXT_OS_LAYERS) {
76
+ const dir = path.join(root, layer);
77
+ const files = (await readDir(dir)).filter((f) => !f.startsWith('.') && f !== 'README.md');
78
+ if (files.length > 0)
79
+ layerStats.push({ layer, count: files.length });
80
+ totalAssets += files.length;
81
+ }
82
+ // tmp/ 过期草稿 (>1 天未改) → 归档到 snapshots/trash-<ts>/
83
+ const tmpDir = path.join(root, 'tmp');
84
+ try {
85
+ const tmpEntries = await fs.readdir(tmpDir);
86
+ const now = Date.now();
87
+ const trashDir = path.join(snapshotsDir, `trash-${Date.now()}`);
88
+ for (const f of tmpEntries) {
89
+ const fp = path.join(tmpDir, f);
90
+ try {
91
+ const st = await fs.stat(fp);
92
+ if (now - st.mtimeMs > 24 * 3600 * 1000) {
93
+ await fs.mkdir(trashDir, { recursive: true });
94
+ await fs.rename(fp, path.join(trashDir, f));
95
+ archivedTmp++;
96
+ }
97
+ }
98
+ catch { /* 单个失败跳过 */ }
99
+ }
100
+ }
101
+ catch { /* tmp 不存在跳过 */ }
102
+ // 打快照清单 (轻量归档, 幂等)
103
+ const manifest = {
104
+ ts: new Date().toISOString(),
105
+ layers: layerStats,
106
+ totalAssets,
107
+ archivedTmp,
108
+ };
109
+ await fs.writeFile(path.join(snapshotsDir, `manifest-${Date.now()}.json`), JSON.stringify(manifest, null, 2), 'utf-8');
110
+ handled = totalAssets;
111
+ return {
112
+ key: 'context-os', label: 'Context OS 归档',
113
+ handled,
114
+ summary: `${layerStats.length} 层 · ${totalAssets} 个资产${archivedTmp > 0 ? ` · 归档 tmp ${archivedTmp} 个` : ''}`,
115
+ };
116
+ }
117
+ catch (e) {
118
+ return { key: 'context-os', label: 'Context OS 归档', handled, error: e?.message || String(e) };
119
+ }
120
+ }
121
+ // ───────────────────────── 2. 外部社交关系 ─────────────────────────
122
+ /** 整理外部社交关系: known_peers 统计 (活跃/失联) + dunbar tier 分布 */
123
+ export async function tidySocialRelations(home) {
124
+ try {
125
+ const peers = (await readJson(path.join(home, '.bolloon', 'known_peers.json')))?.peers || {};
126
+ const peerEntries = Object.entries(peers);
127
+ const now = Date.now();
128
+ let live = 0;
129
+ let stale = 0;
130
+ for (const [, p] of peerEntries) {
131
+ const last = p?.lastConnectedAt ? new Date(p.lastConnectedAt).getTime() : 0;
132
+ if (now - last < 30 * 24 * 3600 * 1000)
133
+ live++;
134
+ else
135
+ stale++;
136
+ }
137
+ // dunbar tier 分布
138
+ const tierCount = {};
139
+ const peersDir = path.join(home, '.bolloon', 'peers');
140
+ const peerDirs = await readDir(peersDir);
141
+ for (const pk of peerDirs) {
142
+ const tier = await readJson(path.join(peersDir, pk, 'dunbar-tier.json'));
143
+ const t = tier?.tier || 'unknown';
144
+ tierCount[t] = (tierCount[t] || 0) + 1;
145
+ }
146
+ const tierDesc = Object.entries(tierCount)
147
+ .map(([t, n]) => `${t}=${n}`)
148
+ .join(' ');
149
+ return {
150
+ key: 'social', label: '外部社交关系',
151
+ handled: peerEntries.length,
152
+ summary: `${peerEntries.length} peers · 活跃 ${live} · 失联 ${stale} · tier[${tierDesc}]`,
153
+ };
154
+ }
155
+ catch (e) {
156
+ return { key: 'social', label: '外部社交关系', handled: 0, error: e?.message || String(e) };
157
+ }
158
+ }
159
+ // ───────────────────────── 3. 外部智能体描述 ─────────────────────────
160
+ /** 整理外部智能体描述: peers/<pk>/agents/ 下的远端 manifest */
161
+ export async function tidyExternalAgents(home) {
162
+ try {
163
+ const peersDir = path.join(home, '.bolloon', 'peers');
164
+ const peerDirs = await readDir(peersDir);
165
+ let manifests = 0;
166
+ let peersWithAgents = 0;
167
+ for (const pk of peerDirs) {
168
+ const agentDir = path.join(peersDir, pk, 'agents');
169
+ const files = (await readDir(agentDir)).filter((f) => f.endsWith('.json') || f.endsWith('.md'));
170
+ if (files.length > 0) {
171
+ manifests += files.length;
172
+ peersWithAgents++;
173
+ }
174
+ }
175
+ return {
176
+ key: 'agents-ext', label: '外部智能体描述',
177
+ handled: manifests,
178
+ summary: `${manifests} 个远端 agent (${peersWithAgents} 个 peer)`,
179
+ };
180
+ }
181
+ catch (e) {
182
+ return { key: 'agents-ext', label: '外部智能体描述', handled: 0, error: e?.message || String(e) };
183
+ }
184
+ }
185
+ // ───────────────────────── 4. 内部智能体描述 ─────────────────────────
186
+ /** 整理内部智能体描述: channels.json persona + persona/<agentId>/ 6 文件 */
187
+ export async function tidyInternalAgents(home) {
188
+ try {
189
+ // channels.json 真实位置在 ~/.bolloon/sessions/channels.json (server-storage 主路径), 兼容旧 ~/.bolloon/channels.json
190
+ const sessionsFile = path.join(home, '.bolloon', 'sessions', 'channels.json');
191
+ const legacyFile = path.join(home, '.bolloon', 'channels.json');
192
+ let channelsRaw = await readJson(sessionsFile);
193
+ if (!channelsRaw)
194
+ channelsRaw = await readJson(legacyFile);
195
+ // channels.json 是纯数组 (server-storage 主格式), 兼容旧 {channels:[...]} 对象形态
196
+ const channels = Array.isArray(channelsRaw) ? channelsRaw : channelsRaw?.channels || [];
197
+ let withPersona = 0;
198
+ for (const c of channels) {
199
+ if (c?.persona?.name || c?.persona?.description)
200
+ withPersona++;
201
+ }
202
+ const personaDir = path.join(home, '.bolloon', 'persona');
203
+ const agentDirs = (await readDir(personaDir)).filter((d) => !d.startsWith('.'));
204
+ let personaFiles = 0;
205
+ for (const d of agentDirs) {
206
+ personaFiles += (await readDir(path.join(personaDir, d))).filter((f) => f.endsWith('.md')).length;
207
+ }
208
+ return {
209
+ key: 'agents-int', label: '内部智能体描述',
210
+ handled: channels.length,
211
+ summary: `${channels.length} channels (${withPersona} 有 persona) · ${agentDirs.length} 个 persona 目录 · ${personaFiles} 个文档`,
212
+ };
213
+ }
214
+ catch (e) {
215
+ return { key: 'agents-int', label: '内部智能体描述', handled: 0, error: e?.message || String(e) };
216
+ }
217
+ }
218
+ // ───────────────────────── 5. judgeness 维护 ─────────────────────────
219
+ /** judgeness 内容维护: descriptions 统计 + 归档 >30 天未改的旧描述 */
220
+ export async function maintainJudgeness(home) {
221
+ try {
222
+ const descDir = path.join(home, '.bolloon', 'judgeness', 'descriptions');
223
+ const files = await listMd(descDir);
224
+ const now = Date.now();
225
+ let archived = 0;
226
+ const archiveDir = path.join(descDir, '..', 'archive');
227
+ for (const f of files) {
228
+ if (now - f.mtime > 30 * 24 * 3600 * 1000) {
229
+ try {
230
+ await fs.mkdir(archiveDir, { recursive: true });
231
+ await fs.rename(path.join(descDir, f.file), path.join(archiveDir, f.file));
232
+ archived++;
233
+ }
234
+ catch { /* 单个失败跳过 */ }
235
+ }
236
+ }
237
+ return {
238
+ key: 'judgeness', label: 'judgeness 维护',
239
+ handled: files.length,
240
+ summary: `${files.length} 条描述${archived > 0 ? ` · 归档 ${archived} 条旧描述` : ''}`,
241
+ };
242
+ }
243
+ catch (e) {
244
+ return { key: 'judgeness', label: 'judgeness 维护', handled: 0, error: e?.message || String(e) };
245
+ }
246
+ }
247
+ // ───────────────────────── 6. 其他项目目录的理解 ─────────────────────────
248
+ const PROJECT_MANIFESTS = ['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml', 'Makefile'];
249
+ /** 扫描的候选项目根目录 (相对 home) — 覆盖用户常见项目位置 */
250
+ const PROJECT_SCAN_ROOTS = ['Downloads', 'lean', 'DIAP-TS-SDK', 'alou', 'projects', 'workspace', 'dev', 'code', 'src'];
251
+ /** 扫描 home 下的项目目录 (深度 1, 找 manifest), 跳过 node_modules/.git/dist */
252
+ export async function scanProjects(home) {
253
+ const out = [];
254
+ for (const rootName of PROJECT_SCAN_ROOTS) {
255
+ const root = path.join(home, rootName);
256
+ const entries = await readDir(root);
257
+ for (const e of entries) {
258
+ if (e.startsWith('.') || e === 'node_modules' || e === 'Library')
259
+ continue;
260
+ const dir = path.join(root, e);
261
+ let type = '';
262
+ try {
263
+ const st = await fs.stat(dir);
264
+ if (!st.isDirectory())
265
+ continue;
266
+ for (const m of PROJECT_MANIFESTS) {
267
+ try {
268
+ await fs.access(path.join(dir, m));
269
+ type = m;
270
+ break;
271
+ }
272
+ catch { /* 下一个 */ }
273
+ }
274
+ if (!type)
275
+ continue;
276
+ out.push({ name: e, path: dir, type, mtime: st.mtimeMs });
277
+ }
278
+ catch { /* 跳过 */ }
279
+ }
280
+ }
281
+ // 去重 (同一目录可能被多个 root 覆盖)
282
+ const seen = new Set();
283
+ return out.filter((p) => {
284
+ if (seen.has(p.path))
285
+ return false;
286
+ seen.add(p.path);
287
+ return true;
288
+ });
289
+ }
290
+ /** 其他项目目录的理解: 扫描项目 → 更新 04-Projects/项目理解.md (llm 可选增强) */
291
+ export async function understandProjects(home, llm) {
292
+ try {
293
+ const projects = await scanProjects(home);
294
+ const now = new Date().toISOString().slice(0, 10);
295
+ let extra = '';
296
+ if (llm && projects.length > 0) {
297
+ try {
298
+ const lines = projects.map((p) => `- ${p.name} (${p.type})`).join('\n');
299
+ const resp = await llm(`以下是用户 ${os.homedir()} 下的项目清单, 请用一句话概括每个项目可能是什么 (技术栈/用途), 按原样输出每行:\n${lines}\n\n输出格式: - 项目名: 一句话理解`);
300
+ extra = `\n\n## LLM 理解 (${now})\n${resp.slice(0, 2000)}`;
301
+ }
302
+ catch { /* LLM 增强失败用模板 */ }
303
+ }
304
+ const sorted = [...projects].sort((a, b) => b.mtime - a.mtime);
305
+ const body = `# 项目理解索引 (自动整理心跳, ${now})\n\n` +
306
+ `> 由自动整理心跳生成 — 扫描 home 下常见项目目录的 manifest (package.json / pyproject.toml / go.mod / Cargo.toml).\n\n` +
307
+ `## 项目清单 (${projects.length})\n\n` +
308
+ sorted.map((p) => `- **${p.name}** \`${p.type}\` — ${p.path}`).join('\n') +
309
+ extra + '\n';
310
+ const outFile = path.join(home, '.bolloon', 'context-os', '04-Projects', '项目理解.md');
311
+ await fs.mkdir(path.dirname(outFile), { recursive: true });
312
+ await fs.writeFile(outFile, body, 'utf-8');
313
+ return {
314
+ key: 'projects', label: '项目目录理解',
315
+ handled: projects.length,
316
+ summary: `${projects.length} 个项目已索引 → 04-Projects/项目理解.md`,
317
+ };
318
+ }
319
+ catch (e) {
320
+ return { key: 'projects', label: '项目目录理解', handled: 0, error: e?.message || String(e) };
321
+ }
322
+ }
323
+ // ───────────────────────── 7. 用户画像的理解 ─────────────────────────
324
+ /** 用户画像的理解: 汇总 persona/<agentId>/user.md + context-os/01-Me/ → 画像快照 */
325
+ export async function understandUserProfile(home, llm) {
326
+ try {
327
+ const personaDir = path.join(home, '.bolloon', 'persona');
328
+ const agentDirs = (await readDir(personaDir)).filter((d) => !d.startsWith('.'));
329
+ const meDir = path.join(home, '.bolloon', 'context-os', '01-Me');
330
+ const meFiles = (await listMd(meDir)).filter((f) => f.file !== 'README.md');
331
+ let handled = agentDirs.length + meFiles.length;
332
+ const now = new Date().toISOString().slice(0, 10);
333
+ // 读 user.md 内容片段 (每 agent 目录)
334
+ let profileSnippet = '';
335
+ for (const d of agentDirs) {
336
+ const userMd = path.join(personaDir, d, 'user.md');
337
+ try {
338
+ const raw = (await fs.readFile(userMd, 'utf-8')).slice(0, 800);
339
+ if (raw.trim())
340
+ profileSnippet += `\n### ${d}/user.md\n${raw}\n`;
341
+ }
342
+ catch { /* 无 user.md */ }
343
+ }
344
+ let extra = '';
345
+ if (llm && (profileSnippet || meFiles.length > 0)) {
346
+ try {
347
+ const resp = await llm(`以下是用户的画像素材 (persona user.md + 01-Me 资产). 请提炼 3-5 条"稳定的用户画像要点" (身份/偏好/目标/工作方式):\n${profileSnippet.slice(0, 3000)}\n\n输出要点列表 (每条一行, 以 - 开头)`);
348
+ extra = `\n\n## LLM 画像要点 (${now})\n${resp.slice(0, 2000)}`;
349
+ }
350
+ catch { /* LLM 增强失败 */ }
351
+ }
352
+ const meNames = meFiles.map((f) => f.file).join('; ');
353
+ const body = `# 用户画像快照 (自动整理心跳, ${now})\n\n` +
354
+ `> 由自动整理心跳汇总 persona user.md + Context OS 01-Me. 详细档案见 01-Me/个人档案.md.\n\n` +
355
+ `## 画像素材来源\n- persona 目录: ${agentDirs.length} 个 (${agentDirs.join(', ') || '无'})\n- 01-Me 资产: ${meFiles.length} 个 (${meNames || '无'})` +
356
+ (profileSnippet ? `\n\n## user.md 内容片段\n${profileSnippet.slice(0, 3000)}` : '') +
357
+ extra + '\n';
358
+ const outFile = path.join(meDir, '用户画像快照.md');
359
+ await fs.mkdir(meDir, { recursive: true });
360
+ await fs.writeFile(outFile, body, 'utf-8');
361
+ return {
362
+ key: 'user', label: '用户画像理解',
363
+ handled,
364
+ summary: `${agentDirs.length} 个 persona + ${meFiles.length} 个 01-Me 资产 → 用户画像快照`,
365
+ };
366
+ }
367
+ catch (e) {
368
+ return { key: 'user', label: '用户画像理解', handled: 0, error: e?.message || String(e) };
369
+ }
370
+ }
371
+ // ───────────────────────── 8. 最近记录的日志归档 ─────────────────────────
372
+ /** 最近记录的日志: 统计 + 归档 >30 天未改的 jsonl 旧文件 (排除活跃依赖: goals/event.jsonl) */
373
+ export async function archiveRecentLogs(home) {
374
+ try {
375
+ const root = path.join(home, '.bolloon');
376
+ // 候选日志目录: goals/ engine/ trajectories/ sessions/jsonl/ sidechains/ (存在的才扫)
377
+ const logDirs = ['goals', 'engine', 'trajectories', path.join('sessions', 'jsonl'), 'sidechains'];
378
+ let total = 0;
379
+ let archived = 0;
380
+ const now = Date.now();
381
+ // goal-resume.ts 依赖 goals/event.jsonl 恢复目标事件 — 永不归档
382
+ const protectedFiles = new Set(['event.jsonl']);
383
+ for (const rel of logDirs) {
384
+ const dir = path.join(root, rel);
385
+ const entries = await readDir(dir);
386
+ for (const e of entries) {
387
+ if (!e.endsWith('.jsonl') && !e.endsWith('.log'))
388
+ continue;
389
+ if (protectedFiles.has(e))
390
+ continue;
391
+ total++;
392
+ try {
393
+ const st = await fs.stat(path.join(dir, e));
394
+ if (now - st.mtimeMs > 30 * 24 * 3600 * 1000) {
395
+ const archiveDir = path.join(dir, 'archive');
396
+ await fs.mkdir(archiveDir, { recursive: true });
397
+ await fs.rename(path.join(dir, e), path.join(archiveDir, e));
398
+ archived++;
399
+ }
400
+ }
401
+ catch { /* 单个失败跳过 */ }
402
+ }
403
+ }
404
+ return {
405
+ key: 'logs', label: '最近日志归档',
406
+ handled: total,
407
+ summary: `${total} 个日志文件${archived > 0 ? ` · 归档 ${archived} 个旧文件` : ''}`,
408
+ };
409
+ }
410
+ catch (e) {
411
+ return { key: 'logs', label: '最近日志归档', handled: 0, error: e?.message || String(e) };
412
+ }
413
+ }
414
+ // ───────────────────────── 9. 用户目标维护 ─────────────────────────
415
+ /** 用户长期/短期目标维护: goals/queue.json + event.jsonl + 03-Current → 目标摘要 */
416
+ export async function maintainGoals(home, llm) {
417
+ try {
418
+ const goalsDir = path.join(home, '.bolloon', 'goals');
419
+ const queue = (await readJson(path.join(goalsDir, 'queue.json'))) || {};
420
+ // queue.json 结构未知, 兼容 object/array
421
+ const queueItems = Array.isArray(queue) ? queue : Object.values(queue).filter((v) => v && typeof v === 'object');
422
+ const events = await readDir(goalsDir);
423
+ const now = new Date().toISOString().slice(0, 10);
424
+ const currentDir = path.join(home, '.bolloon', 'context-os', '03-Current');
425
+ const currentFiles = (await listMd(currentDir)).filter((f) => f.file !== 'README.md');
426
+ let extra = '';
427
+ if (llm && (queueItems.length > 0 || currentFiles.length > 0)) {
428
+ try {
429
+ const resp = await llm(`以下是用户当前目标素材 (goals queue + 03-Current 进行中的任务). 请区分长期目标与短期目标, 每条一行:\n- [长期/短期] 目标描述\n素材:\n${JSON.stringify(queueItems).slice(0, 1500)}\n${currentFiles.map((f) => f.file).join('\n')}`);
430
+ extra = `\n\n## LLM 目标分层 (${now})\n${resp.slice(0, 1500)}`;
431
+ }
432
+ catch { /* LLM 增强失败 */ }
433
+ }
434
+ const body = `# 用户目标摘要 (自动整理心跳, ${now})\n\n` +
435
+ `> 来源: goals/queue.json + goals/event.jsonl + 03-Current 进行中任务.\n\n` +
436
+ `## 目标队列\n${queueItems.length > 0 ? queueItems.map((g, i) => `- ${g?.description || g?.target || g?.id || `目标 ${i + 1}`}`).join('\n') : '(空)'}\n` +
437
+ `## 进行中任务 (03-Current)\n${currentFiles.length > 0 ? currentFiles.map((f) => `- ${f.file.replace(/\.md$/, '')}`).join('\n') : '(空)'}` +
438
+ extra + '\n';
439
+ const outFile = path.join(currentDir, '目标摘要.md');
440
+ await fs.mkdir(currentDir, { recursive: true });
441
+ await fs.writeFile(outFile, body, 'utf-8');
442
+ return {
443
+ key: 'goals', label: '用户目标维护',
444
+ handled: queueItems.length + currentFiles.length,
445
+ summary: `${queueItems.length} 条队列 + ${currentFiles.length} 个进行中 → 目标摘要`,
446
+ };
447
+ }
448
+ catch (e) {
449
+ return { key: 'goals', label: '用户目标维护', handled: 0, error: e?.message || String(e) };
450
+ }
451
+ }
452
+ // ───────────────────────── 总入口 ─────────────────────────
453
+ /** 自动整理心跳 · 知识层: 串行跑全部 9 个整理器, 汇总 sections */
454
+ export async function runKnowledgeOrganize(opts = {}) {
455
+ const home = opts.home || os.homedir();
456
+ const cwd = opts.cwd || process.cwd();
457
+ const llm = opts.llm;
458
+ const sections = [];
459
+ sections.push(await archiveContextOs(home));
460
+ sections.push(await tidySocialRelations(home));
461
+ sections.push(await tidyExternalAgents(home));
462
+ sections.push(await tidyInternalAgents(home));
463
+ sections.push(await maintainJudgeness(home));
464
+ sections.push(await understandProjects(home, llm));
465
+ sections.push(await understandUserProfile(home, llm));
466
+ sections.push(await archiveRecentLogs(home));
467
+ sections.push(await maintainGoals(home, llm));
468
+ const totalHandled = sections.reduce((acc, s) => acc + (s.error ? 0 : s.handled), 0);
469
+ return { sections, totalHandled };
470
+ }
@@ -28,6 +28,12 @@ import { initDocumentReceiver } from './p2p-document-tools.js';
28
28
  import { DiscoveredAgentsManager, createSocialHeartbeat } from '../social/heartbeat.js';
29
29
  import { SkillRegistry } from '@bolloon/constraint-runtime';
30
30
  import { loadSkillsFromPaths, defaultSkillPaths } from './skill-loader.js';
31
+ /** 2026-08-10: unreported 逃生门判定 — LLM 反复不把工具结果写进回复时, 超过上限强制收尾 (防死循环) */
32
+ export function decideUnreported(unreported, retries, max) {
33
+ if (unreported <= 0)
34
+ return 'none';
35
+ return retries < max ? 'retry' : 'force-final';
36
+ }
31
37
  // 拆分后的子模块 — 重新导出保 backward compat
32
38
  export { TOOL_DEFINITIONS, } from './pi-sdk-types.js';
33
39
  export { PiSessionManager } from './pi-sdk-session-manager.js';
@@ -1124,6 +1130,11 @@ ${this.getToolDefinitions()}
1124
1130
  const MAX_TOOL_CALLS_PER_LOOP = 25; // 单轮循环总工具调用上限 → 注入 hint
1125
1131
  let totalToolCallsThisLoop = 0;
1126
1132
  const lastNTools = []; // 最近 MAX_IDEMPOTENT_TOOL 次工具名, 检测重复
1133
+ // 2026-08-10: unreported 循环逃生门 — LLM 反复不把工具结果写进回复时, 3 次后强制 final (不死板)
1134
+ const MAX_UNREPORTED_RETRIES = 3;
1135
+ let unreportedRetries = 0;
1136
+ // 2026-08-10: 工具失败时的终端逃生引导 (shell_exec 白名单命令可诊断环境/推进任务)
1137
+ const SHELL_ESCAPE_HINT = ' [逃生] 若工具无法响应/报错, 可用 shell_exec 跑终端命令诊断 (白名单: ls/cat/head/tail/pwd/git status/npm run test 等), 或调整参数换一种方式完成; 不要重复调用同一失败工具.';
1127
1138
  // 2026-08-08: final 前 review 续跑 — 目标对齐 + 需求深挖 (见 loop-review.ts)
1128
1139
  // 不潦草收尾: LLM 想 <final gen> 时先跑 1-2 次 review, 达成用户需求才放行.
1129
1140
  // 上限=2 次 (用户要求"运行一两次"), 结束后按用户需求为准.
@@ -1671,7 +1682,7 @@ ${toolDefs}
1671
1682
  // 2026-07-28: 注入 Observation + Reflection 替代旧 hardcode 提示
1672
1683
  const obs = buildObservation(toolCall.name, toolCall.args, { success: false, error: result.error });
1673
1684
  const ref = buildReflection(toolCall.name, result.error, totalErrors, lastFailedToolCount);
1674
- this.messageHistory.push({ role: 'system', content: formatObservationWithReflection(obs, ref) });
1685
+ this.messageHistory.push({ role: 'system', content: formatObservationWithReflection(obs, ref) + SHELL_ESCAPE_HINT });
1675
1686
  if (onStream)
1676
1687
  onStream({ type: 'status', content: `💡 Reflection: ${obs.summary} → ${ref[0]?.action || '放弃'}`, tool: 'system' });
1677
1688
  if (lastFailedToolCount >= MAX_SAME_TOOL_FAILURES) {
@@ -1695,7 +1706,7 @@ ${toolDefs}
1695
1706
  this.logToHarness(toolCall.name, toolCall.args, errorResult);
1696
1707
  const obs = buildObservation(toolCall.name, toolCall.args, errorResult);
1697
1708
  const ref = buildReflection(toolCall.name, errorResult.error, totalErrors, lastFailedToolCount);
1698
- this.messageHistory.push({ role: 'system', content: formatObservationWithReflection(obs, ref) });
1709
+ this.messageHistory.push({ role: 'system', content: formatObservationWithReflection(obs, ref) + SHELL_ESCAPE_HINT });
1699
1710
  if (onStream)
1700
1711
  onStream({ type: 'status', content: `💡 Reflection: ${obs.summary}`, tool: 'system' });
1701
1712
  console.error(`[PiAgent] 工具执行异常 (累计 ${totalErrors}/${this.MAX_TOTAL_ERRORS}): ${execError}`);
@@ -1728,18 +1739,33 @@ ${toolDefs}
1728
1739
  console.log(`[PiAgent] 回复包含工具结果内容, 清除 successfulToolResults (${this.successfulToolResults.length} 个)`);
1729
1740
  this.successfulToolResults = [];
1730
1741
  }
1731
- if (this.successfulToolResults.length > 0 && iteration < this.MAX_REACT_ITERATIONS) {
1742
+ // 2026-08-10: 逃生门 decideUnreported: 未达上限 再提示一次; 超限 → 清空积压强制 final (防死循环)
1743
+ const unreportedDecision = decideUnreported(this.successfulToolResults.length, unreportedRetries, MAX_UNREPORTED_RETRIES);
1744
+ if (unreportedDecision === 'retry' && iteration < this.MAX_REACT_ITERATIONS) {
1745
+ unreportedRetries++;
1732
1746
  const unreported = this.successfulToolResults.length;
1733
- console.log(`[PiAgent] LLM 想 final_gen 但还有 ${unreported} 个工具结果未汇报, push hint 让其继续`);
1747
+ console.log(`[PiAgent] LLM 想 final_gen 但还有 ${unreported} 个工具结果未汇报 (${unreportedRetries}/${MAX_UNREPORTED_RETRIES}), push hint 让其继续`);
1734
1748
  this.messageHistory.push({
1735
1749
  role: 'system',
1736
1750
  content: `[dive-into stop condition] 你之前已成功执行了 ${unreported} 个工具, 但当前回复里没把它们的结果告诉用户. 请基于已有的工具结果 (在 history 里) 写一个完整总结回复给用户, 用 <final gen> 结尾. 不要再调工具.`
1737
1751
  });
1738
1752
  if (onStream) {
1739
- onStream({ type: 'status', content: `🔄 还有 ${unreported} 个工具结果未汇报, 让 LLM 继续总结`, tool: 'system' });
1753
+ onStream({ type: 'status', content: `🔄 还有 ${unreported} 个工具结果未汇报, 让 LLM 继续总结 (${unreportedRetries}/${MAX_UNREPORTED_RETRIES})`, tool: 'system' });
1740
1754
  }
1741
1755
  continue;
1742
1756
  }
1757
+ else if (unreportedDecision === 'force-final') {
1758
+ // 反复提示仍未汇报超过上限 → 清空积压强制 final, 不再死循环
1759
+ console.log(`[PiAgent] unreported 循环超限 (${unreportedRetries} 次), 清空积压强制 final`);
1760
+ this.successfulToolResults = [];
1761
+ this.messageHistory.push({
1762
+ role: 'system',
1763
+ content: `[dive-into stop condition] 已多次提示汇报工具结果仍未完成 (超过 ${MAX_UNREPORTED_RETRIES} 次). 现在直接基于你已知的信息写最终回复给用户, 用 <final gen> 结尾, 不要再调任何工具.`
1764
+ });
1765
+ if (onStream) {
1766
+ onStream({ type: 'status', content: `🔄 工具结果汇报超限, 强制收尾`, tool: 'system' });
1767
+ }
1768
+ }
1743
1769
  lastQualityScore = this.estimateResponseQuality(reply);
1744
1770
  // 2026-07-29: 质量门 — 即使 LLM 声称完成, 质量太低也继续
1745
1771
  if (lastQualityScore < this.QUALITY_THRESHOLD && refineAttempts < this.MAX_REFINE_ATTEMPTS) {
@@ -0,0 +1,322 @@
1
+ /**
2
+ * skill-organizer.ts — 自动整理心跳 (2026-08-10)
3
+ *
4
+ * 目的: 把"自动整理"变成 Bolloon 的心跳之一 (与社交心跳并列), 周期性做两件事:
5
+ *
6
+ * A. 扫描 skills view (~/.bolloon/skills + <cwd>/.bolloon/skills), 找出"遗留下来的 skills 指导":
7
+ * - 外部智能体迁移残留 (openclaw/hermes 迁移的 skills 展平为 `<分类>-<技能>`, 如 apple-* / creative-* / autonomous-ai-agents-*)
8
+ * - 空 body / 无 description 的占位 skill
9
+ * - status: archived 的归档残留
10
+ * - 用户级与项目级同名重复
11
+ * B. 经验进化 (完整总结, 不再只是记录使用什么工具): 读 run-end 候选, 用 LLM 把
12
+ * "工具调用记录" 扩写成完整的经验文档 (背景/触发条件/流程/注意事项/验证), 转正为正式 skill.
13
+ *
14
+ * 设计原则 (compile-first / 可测):
15
+ * - 所有目录/LLM 可注入 (home/cwd/llm), 测试用临时 HOME + mock LLM.
16
+ * - runSkillOrganize 纯函数式: 输入候选 + 现有 skills, 输出进化结果 + 遗留报告, 不依赖网络.
17
+ * - startOrganizeHeartbeat 提供 CLI/server 统一的心跳壳: interval + 重入锁 + onStart/onEnd/onError,
18
+ * CLI 通过 onStart/onEnd 把显示接到颜文字行 (inkSetTransient), 结束后清空.
19
+ */
20
+ import * as fs from 'fs/promises';
21
+ import * as os from 'os';
22
+ import * as path from 'path';
23
+ import { getUserSkillsDir, getProjectSkillsDir, sanitizeSkillName } from './skill-writer.js';
24
+ /** 迁移来源分类前缀 — 这些是外部智能体 (openclaw/hermes 等) 迁移进来的 skills, 属于"遗留"候选 */
25
+ const MIGRATED_PREFIXES = [
26
+ 'apple-',
27
+ 'autonomous-ai-agents-',
28
+ 'creative-',
29
+ 'data-science-',
30
+ 'email-',
31
+ 'github-',
32
+ 'media-',
33
+ 'mlops-',
34
+ 'note-taking-',
35
+ 'openclaw-imports-',
36
+ 'productivity-',
37
+ 'research-',
38
+ 'smart-home-',
39
+ 'social-media-',
40
+ 'software-development-',
41
+ ];
42
+ // ───────────────────────── A. 遗留 skills 扫描 ─────────────────────────
43
+ /** 判定一个 skill 是否"遗留": 返回原因列表 (空数组 = 正常) */
44
+ export function leftoverReasons(meta) {
45
+ const reasons = [];
46
+ const name = meta.name || '';
47
+ // 1. 迁移残留: 带外部智能体分类前缀
48
+ if (MIGRATED_PREFIXES.some((p) => name.startsWith(p))) {
49
+ reasons.push('迁移遗留 (外部智能体分类前缀)');
50
+ }
51
+ // 2. 占位: 无描述或正文过短
52
+ const desc = (meta.description || '').trim();
53
+ const body = (meta.body || '').trim();
54
+ if (!desc)
55
+ reasons.push('无 description');
56
+ if (body.length < 50)
57
+ reasons.push('正文过短 (疑似占位)');
58
+ // 3. 归档残留
59
+ if (meta.status === 'archived')
60
+ reasons.push('status=archived (归档残留)');
61
+ return reasons;
62
+ }
63
+ /** 扫描一个 skill 目录, 返回遗留报告 */
64
+ export async function scanSkillDir(dir) {
65
+ const out = [];
66
+ let entries;
67
+ try {
68
+ entries = await fs.readdir(dir, { withFileTypes: true });
69
+ }
70
+ catch {
71
+ return out;
72
+ }
73
+ for (const entry of entries) {
74
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
75
+ continue;
76
+ const skillFile = path.join(dir, entry.name, 'SKILL.md');
77
+ let raw = '';
78
+ try {
79
+ raw = await fs.readFile(skillFile, 'utf-8');
80
+ }
81
+ catch {
82
+ continue; // 没有 SKILL.md 不算 skill
83
+ }
84
+ const meta = parseSkillRaw(raw, entry.name);
85
+ const reasons = leftoverReasons(meta);
86
+ if (reasons.length > 0) {
87
+ out.push({ dir, name: meta.name, description: meta.description || '', reasons });
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ /** 轻量 frontmatter/正文解析 (不依赖 skill-loader, 避免循环依赖) */
93
+ function parseSkillRaw(raw, fallbackName) {
94
+ let name = fallbackName;
95
+ let description = '';
96
+ let status = '';
97
+ let body = raw;
98
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n?/);
99
+ if (fmMatch) {
100
+ body = raw.slice(fmMatch[0].length);
101
+ const fm = fmMatch[1];
102
+ const nameM = fm.match(/^name:\s*(.+)$/m);
103
+ const descM = fm.match(/^description:\s*(.+)$/m);
104
+ const statusM = fm.match(/^status:\s*(.+)$/m);
105
+ if (nameM)
106
+ name = nameM[1].trim();
107
+ if (descM)
108
+ description = descM[1].trim();
109
+ if (statusM)
110
+ status = statusM[1].trim();
111
+ }
112
+ return { name, description, status, body: body.trim() };
113
+ }
114
+ /** 扫描全部 skills view (用户级 + 项目级), 找同名重复 */
115
+ export async function scanLeftoverSkills(opts = {}) {
116
+ const home = opts.home || os.homedir();
117
+ const cwd = opts.cwd || process.cwd();
118
+ const dirs = [getUserSkillsDir(home), getProjectSkillsDir(cwd)];
119
+ const found = new Map();
120
+ const seenNames = new Set();
121
+ for (const dir of dirs) {
122
+ const items = await scanSkillDir(dir);
123
+ for (const it of items) {
124
+ found.set(`${dir}::${it.name}`, it);
125
+ }
126
+ // 同名重复检测 (跨目录)
127
+ const names = (await fs.readdir(dir).catch(() => [])).filter((n) => !n.startsWith('.'));
128
+ for (const n of names) {
129
+ if (seenNames.has(n)) {
130
+ const dup = {
131
+ dir,
132
+ name: n,
133
+ description: '同名 skill 已在其他目录存在',
134
+ reasons: ['跨目录同名重复'],
135
+ };
136
+ found.set(`${dir}::dup::${n}`, dup);
137
+ }
138
+ seenNames.add(n);
139
+ }
140
+ }
141
+ return Array.from(found.values());
142
+ }
143
+ // ───────────────────────── B. 经验进化 (LLM 完整总结) ─────────────────────────
144
+ /** 把候选的工具调用记录扩写成完整经验 — LLM prompt (结构化 JSON 输出) */
145
+ export function buildEvolvePrompt(c) {
146
+ return `你是经验总结专家。下面是 Bolloon 智能体在一轮运行中连续成功调用的工具记录 (已运行 ${c.runs || 1} 次)。
147
+
148
+ 工具记录:
149
+ 名称: ${c.name}
150
+ 描述: ${c.description}
151
+ 原始记录:
152
+ ${String(c.body || '').slice(0, 4000)}
153
+
154
+ 请把这份"工具调用记录"扩写成一份完整的、可复用的经验文档, 要求:
155
+ 1. name: 一个简短的小写英文名 (字母数字连字符, 概括这套操作)
156
+ 2. description: 一句话描述 (什么场景用)
157
+ 3. body: 完整经验正文, 按以下结构:
158
+ ## 背景 — 这套操作在什么场景下用, 解决什么问题
159
+ ## 触发条件 — 什么信号/需求出现时应该使用
160
+ ## 流程 — 具体步骤 (保留原始工具名), 每步说明目的
161
+ ## 注意事项 — 易错点 / 边界情况 / 已知坑
162
+ ## 验证 — 怎么确认结果正确
163
+
164
+ 只输出一个 JSON 对象, 不要任何其他文字:
165
+ {"name": "...", "description": "...", "body": "..."}`;
166
+ }
167
+ /** 解析 LLM 返回的 JSON (容错: 剥 markdown 代码块, 取第一个 {...}) */
168
+ export function parseEvolveJson(raw) {
169
+ if (!raw)
170
+ return null;
171
+ const cleaned = raw.replace(/```(?:json)?/g, '').trim();
172
+ const m = cleaned.match(/\{[\s\S]*\}/);
173
+ if (!m)
174
+ return null;
175
+ try {
176
+ const obj = JSON.parse(m[0]);
177
+ return {
178
+ name: typeof obj.name === 'string' ? obj.name : undefined,
179
+ description: typeof obj.description === 'string' ? obj.description : undefined,
180
+ body: typeof obj.body === 'string' ? obj.body : undefined,
181
+ };
182
+ }
183
+ catch {
184
+ return null;
185
+ }
186
+ }
187
+ /** 完整经验进化: 候选 → LLM 总结 → 正式 skill (转正 + 清理候选). 返回转正 skill 名列表 */
188
+ export async function evolveCandidates(opts) {
189
+ const { listSkillCandidates, writeSkillCandidate } = await import('./skill-writer.js');
190
+ const home = opts.home || os.homedir();
191
+ const cands = await listSkillCandidates(home);
192
+ const evolved = [];
193
+ const max = opts.maxEvolve ?? 3;
194
+ // 优先进化 runs 多的 (更可能可复用)
195
+ const sorted = [...cands].sort((a, b) => (b.runs ?? 1) - (a.runs ?? 1)).slice(0, max);
196
+ for (const c of sorted) {
197
+ try {
198
+ const raw = await opts.llm(buildEvolvePrompt(c));
199
+ const parsed = parseEvolveJson(raw);
200
+ if (!parsed?.body || parsed.body.trim().length < 50) {
201
+ // LLM 输出不可用 → 保留候选, 记录一次失败 (不转正)
202
+ await writeSkillCandidate({
203
+ ...c,
204
+ body: `${c.body}\n- ${new Date().toISOString().slice(0, 16)} ${opts.source || 'organize'}: LLM 总结失败, 保留待人工`,
205
+ }).catch(() => { });
206
+ continue;
207
+ }
208
+ const { createSkill } = await import('./skill-writer.js');
209
+ const safeName = sanitizeSkillName(parsed.name || c.name) || sanitizeSkillName(c.name);
210
+ const r = await createSkill(safeName, parsed.description || c.description, parsed.body, {
211
+ status: 'active',
212
+ triggers: c.description ? [c.description.slice(0, 60)] : undefined,
213
+ });
214
+ if (!r.ok)
215
+ continue;
216
+ evolved.push(safeName);
217
+ // 转正成功 → 清理候选文件
218
+ const { listSkillCandidates: listAgain } = await import('./skill-writer.js');
219
+ const after = await listAgain(home);
220
+ for (const cc of after) {
221
+ if (cc.file && (cc.name === c.name || sanitizeSkillName(cc.name) === sanitizeSkillName(c.name))) {
222
+ await fs.rm(cc.file, { force: true }).catch(() => { });
223
+ }
224
+ }
225
+ }
226
+ catch {
227
+ /* 单个候选失败不阻塞整轮 */
228
+ }
229
+ }
230
+ return { evolved, scanned: cands.length };
231
+ }
232
+ /** 自动整理主流程: 遗留扫描 (必做) + 经验进化 (有 LLM 时) */
233
+ export async function runSkillOrganize(opts = {}) {
234
+ const evolve = opts.evolve ?? true;
235
+ const result = { scannedCandidates: 0, evolved: [], leftovers: [] };
236
+ try {
237
+ result.leftovers = await scanLeftoverSkills({ home: opts.home, cwd: opts.cwd });
238
+ }
239
+ catch (e) {
240
+ result.error = `遗留扫描失败: ${e?.message || String(e)}`;
241
+ }
242
+ if (evolve && opts.llm) {
243
+ try {
244
+ const { evolved, scanned } = await evolveCandidates({
245
+ llm: opts.llm,
246
+ source: opts.source,
247
+ home: opts.home,
248
+ maxEvolve: opts.maxEvolve,
249
+ });
250
+ result.evolved = evolved;
251
+ result.scannedCandidates = scanned;
252
+ }
253
+ catch (e) {
254
+ result.error = `${result.error ? result.error + '; ' : ''}经验进化失败: ${e?.message || String(e)}`;
255
+ }
256
+ }
257
+ else {
258
+ try {
259
+ const { listSkillCandidates } = await import('./skill-writer.js');
260
+ result.scannedCandidates = (await listSkillCandidates(opts.home || os.homedir())).length;
261
+ }
262
+ catch {
263
+ result.scannedCandidates = 0;
264
+ }
265
+ }
266
+ return result;
267
+ }
268
+ /** 自动整理总入口 (2026-08-10): skills 整理 (遗留扫描 + 经验进化) + 知识层整理 (9 类) */
269
+ export async function runAutoOrganize(opts = {}) {
270
+ const result = await runSkillOrganize(opts);
271
+ try {
272
+ const { runKnowledgeOrganize } = await import('./knowledge-organizer.js');
273
+ result.knowledge = await runKnowledgeOrganize({ home: opts.home, cwd: opts.cwd, llm: opts.llm });
274
+ }
275
+ catch (e) {
276
+ result.error = `${result.error ? result.error + '; ' : ''}知识层整理失败: ${e?.message || String(e)}`;
277
+ }
278
+ return result;
279
+ }
280
+ /** 自动整理心跳: 定时触发整理, 带重入锁 (上一轮没跑完不重复触发) */
281
+ export function startOrganizeHeartbeat(opts) {
282
+ const intervalMs = opts.intervalMs ?? 30 * 60_000;
283
+ let timer = null;
284
+ let stopped = false;
285
+ let running = false;
286
+ const runOnce = async () => {
287
+ if (running || stopped)
288
+ return null;
289
+ running = true;
290
+ try {
291
+ opts.onStart?.();
292
+ const r = await opts.run();
293
+ opts.onEnd?.(r);
294
+ return r;
295
+ }
296
+ catch (e) {
297
+ opts.onError?.(e instanceof Error ? e : new Error(String(e)));
298
+ return null;
299
+ }
300
+ finally {
301
+ running = false;
302
+ }
303
+ };
304
+ const schedule = () => {
305
+ if (stopped)
306
+ return;
307
+ timer = setTimeout(async () => {
308
+ await runOnce();
309
+ schedule();
310
+ }, intervalMs);
311
+ };
312
+ schedule();
313
+ return {
314
+ stop() {
315
+ stopped = true;
316
+ if (timer)
317
+ clearTimeout(timer);
318
+ timer = null;
319
+ },
320
+ runOnce,
321
+ };
322
+ }
@@ -54,6 +54,9 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
54
54
  const [status, setStatus] = useState(initialStatus);
55
55
  const { exit } = useApp();
56
56
  const [thinking, setThinking] = useState(false);
57
+ // 2026-08-10: 临时状态行 (自动整理心跳/run-end 经验整理用) — 显示在颜文字行位置,
58
+ // 结束后设 null 即清空 (显示为空). 不进入消息历史, 不会残留显示效果.
59
+ const [transient, setTransient] = useState(null);
57
60
  const thinkingIdx = useRef(0);
58
61
  // 双击 Esc 退出当前进程 (500ms 窗口内第二次按下)
59
62
  const lastEscRef = useRef(0);
@@ -286,10 +289,15 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
286
289
  globalThis.__inkSetStatus = (s) => {
287
290
  setStatus(s);
288
291
  };
292
+ // 2026-08-10: 临时状态行 (自动整理/经验整理): 传字符串显示, 传 null 清空 (显示为空)
293
+ globalThis.__inkSetTransient = (v) => {
294
+ setTransient(v === undefined ? null : v);
295
+ };
289
296
  return () => {
290
297
  delete globalThis.__inkAppend;
291
298
  delete globalThis.__inkSetStatus;
292
299
  delete globalThis.__inkSetThinking;
300
+ delete globalThis.__inkSetTransient;
293
301
  };
294
302
  }, []);
295
303
  const onSubmit = useCallback((value) => {
@@ -539,7 +547,7 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
539
547
  }, 600);
540
548
  return () => clearInterval(timer);
541
549
  }, [thinking]);
542
- return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(LogoBox, { width: terminalW }), _jsx(Messages, { msgs: msgs }), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "#c4d640", children: '─'.repeat(terminalW) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "#c4d640", children: '─'.repeat(terminalW) }) }), popupOpen && (tabState || mention) && (_jsx(MentionPopup, { title: popupTitle, items: filtered, sel: safeSel, width: terminalW, loading: loadingFiles })), picker && (_jsx(MentionPopup, { title: picker.title, items: picker.items, sel: Math.min(picker.sel, picker.items.length - 1), width: terminalW })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "#c4d640", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, focus: !popupOpen && !picker, placeholder: "\u8F93\u5165\u6D88\u606F... @\u667A\u80FD\u4F53 /\u547D\u4EE4 #\u6587\u4EF6 \u00B7 Esc \u53CC\u51FB\u9000\u51FA \u00B7 /queue \u6392\u961F \u00B7 !\u7EC8\u7AEF\u547D\u4EE4" }, tiKey)] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "#c4d640", children: '─'.repeat(terminalW) }) })] }));
550
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(LogoBox, { width: terminalW }), _jsx(Messages, { msgs: msgs }), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) })), transient && (_jsx(Box, { children: _jsx(Text, { children: transient }) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "#c4d640", children: '─'.repeat(terminalW) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "#c4d640", children: '─'.repeat(terminalW) }) }), popupOpen && (tabState || mention) && (_jsx(MentionPopup, { title: popupTitle, items: filtered, sel: safeSel, width: terminalW, loading: loadingFiles })), picker && (_jsx(MentionPopup, { title: picker.title, items: picker.items, sel: Math.min(picker.sel, picker.items.length - 1), width: terminalW })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "#c4d640", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, focus: !popupOpen && !picker, placeholder: "\u8F93\u5165\u6D88\u606F... @\u667A\u80FD\u4F53 /\u547D\u4EE4 #\u6587\u4EF6 \u00B7 Esc \u53CC\u51FB\u9000\u51FA \u00B7 /queue \u6392\u961F \u00B7 !\u7EC8\u7AEF\u547D\u4EE4" }, tiKey)] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "#c4d640", children: '─'.repeat(terminalW) }) })] }));
543
551
  };
544
552
  export { InkApp };
545
553
  // ─── 启动 ────────────────────────────────────────────────────────────────────
@@ -576,3 +584,9 @@ export function inkSetThinking(v) {
576
584
  if (fn)
577
585
  fn(v);
578
586
  }
587
+ /** 2026-08-10: 设置/清除临时状态行 (自动整理/经验整理). 传 null 清空 → 显示为空 */
588
+ export function inkSetTransient(v) {
589
+ const fn = globalThis.__inkSetTransient;
590
+ if (fn)
591
+ fn(v);
592
+ }
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { getGlobalSharedContext } from './social/global-shared-context.js';
16
16
  import { createBollharnessIntegration } from './bollharness-integration/index.js';
17
17
  import * as readline from 'readline';
18
18
  import { printBanner, renderDialog, renderUserMessage, renderAgentMessage, renderMessageBox, renderToolCallListItem, termWidth } from './cli/loading-tui.js';
19
- import { startInk, stopInk, inkAppendLine as appendLine, inkSetStatus, inkSetThinking } from './cli/ink-app.js';
19
+ import { startInk, stopInk, inkAppendLine as appendLine, inkSetStatus, inkSetThinking, inkSetTransient } from './cli/ink-app.js';
20
20
  // 启动自动检查更新:后台、节流、检测到新版本自动安装(可被 --no-update / BOLLOON_SKIP_UPDATE 关闭)
21
21
  import { createRequire } from 'module';
22
22
  const _require = createRequire(import.meta.url);
@@ -423,6 +423,8 @@ let cliStartTime = 0;
423
423
  let cliModelName = '…';
424
424
  let cliAgentName = '…';
425
425
  let cliActiveChannelId = null;
426
+ // 2026-08-10: CLI 自动整理心跳 (与社交心跳并列, 独立于 server) — 退出时 stop
427
+ let cliOrganizeHeartbeat = null;
426
428
  function fmtDuration(ms) {
427
429
  const s = Math.floor(ms / 1000);
428
430
  if (s < 60)
@@ -559,6 +561,64 @@ async function startCLI(comm) {
559
561
  catch { /* 降级: getCliCtxUsage 返回 0/1M */ }
560
562
  const initialStatus = `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ 0s${C_DIM} │${RESET} ${buildContextBar(getCliCtxUsage())}`;
561
563
  startInk((text) => { processInput(text, comm); }, initialStatus, getStatus);
564
+ // 2026-08-10: 自动整理心跳 (CLI 侧, 与社交心跳并列) — 启动后立即"固定看一下 skills view"
565
+ // (扫描遗留 skills), 之后按周期 (默认 30min, env BOLLOON_ORGANIZE_HEARTBEAT_MS) 完整进化经验.
566
+ // 显示走 transient 颜文字行: 触发时显示, 结束后清空 (显示为空).
567
+ try {
568
+ const { startOrganizeHeartbeat } = await import('./agents/skill-organizer.js');
569
+ let firstOrganizeScan = true; // 启动第一轮只做快速遗留扫描 (无 LLM), 不阻塞启动
570
+ cliOrganizeHeartbeat = startOrganizeHeartbeat({
571
+ intervalMs: Number(process.env.BOLLOON_ORGANIZE_HEARTBEAT_MS) || 30 * 60_000,
572
+ onStart: () => inkSetTransient(`${C_DIM}(`・ω・´) 自动整理经验中...${RESET}`),
573
+ onEnd: (r) => {
574
+ inkSetTransient(null); // 结束后去除显示效果 (显示为空)
575
+ // 2026-08-10: 整理结果统一进 bolloon 艺术字框 (renderMessageBox 圆角框, 与反思框同款)
576
+ const boxLines = [];
577
+ if (r && r.leftovers.length > 0) {
578
+ boxLines.push(`🧹 遗留 skills (${r.leftovers.length}): ${r.leftovers.slice(0, 8).map(l => l.name).join(', ')}${r.leftovers.length > 8 ? ' ...' : ''}`);
579
+ }
580
+ if (r && r.evolved.length > 0) {
581
+ boxLines.push(`✨ 经验进化: ${r.evolved.join(', ')}`);
582
+ }
583
+ // 知识层整理汇总 (Context OS/社交/智能体/judgeness/项目/画像/日志/目标)
584
+ const kSections = (r?.knowledge?.sections || []).filter(s => s.handled > 0 || s.error);
585
+ if (kSections.length > 0) {
586
+ boxLines.push(`🧠 知识整理: ${kSections.map(s => s.error ? `${s.label}✗` : `${s.label}✓`).join(' ')}`);
587
+ }
588
+ if (boxLines.length > 0) {
589
+ appendLine(renderMessageBox({ title: '自动整理完成', body: boxLines.join('\n'), color: C_ACCENT, maxLines: 10 }));
590
+ }
591
+ },
592
+ onError: () => inkSetTransient(null),
593
+ run: async () => {
594
+ // 启动第一轮 (firstOrganizeScan=true) 只做快速扫描 — 不拿 LLM, 立即执行.
595
+ // 后续周期轮才取 agent LLM 做完整经验进化 (2026-08-10: getAgent 在无 LLM 环境可能
596
+ // 长时间挂起 → 8s 超时降级为仅扫描)
597
+ let llm;
598
+ const needEvolve = !firstOrganizeScan;
599
+ if (needEvolve) {
600
+ try {
601
+ const a = await Promise.race([
602
+ getAgent().catch(() => null),
603
+ new Promise((res) => setTimeout(() => res(null), 8000)),
604
+ ]);
605
+ if (a && typeof a.promptStream === 'function') {
606
+ llm = (p) => a.promptStream(p, () => { }, undefined, cliActiveChannelId || undefined);
607
+ }
608
+ }
609
+ catch { /* 无 agent → 仅扫描 */ }
610
+ }
611
+ const { runAutoOrganize } = await import('./agents/skill-organizer.js');
612
+ const evolve = needEvolve && !!llm;
613
+ firstOrganizeScan = false;
614
+ return runAutoOrganize({ llm, source: 'cli:organize-heartbeat', evolve });
615
+ },
616
+ });
617
+ // 启动即跑一轮: 每次打开后固定看一下 skills view (遗留扫描, 快, 不阻塞)
618
+ // 延迟 3s 等 Ink 挂载完成 (global __inkAppend/__inkSetTransient 注册) — 否则首轮显示丢失
619
+ setTimeout(() => { cliOrganizeHeartbeat?.runOnce().catch(() => { }); }, 3000);
620
+ }
621
+ catch { /* 自动整理启动失败不阻塞 CLI */ }
562
622
  // Wait on a promise that resolves on Ctrl+C / 双击 Esc
563
623
  // (ink-app 的 requestExit 调 __inkRequestExit → resolve, 清理后 process.exit)
564
624
  let cliExitResolve = () => { };
@@ -568,6 +628,10 @@ async function startCLI(comm) {
568
628
  delete globalThis.__inkRequestExit;
569
629
  stopInk();
570
630
  appendLine(`\n${CYAN}👋 再见!${RESET}`);
631
+ try {
632
+ cliOrganizeHeartbeat?.stop();
633
+ }
634
+ catch { /* 非致命 */ }
571
635
  comm.stop();
572
636
  process.exit(0);
573
637
  }
@@ -1616,18 +1680,20 @@ async function processInput(input, comm) {
1616
1680
  appendLine(renderAgentMessage(response));
1617
1681
  // 停止思考动画
1618
1682
  inkSetThinking(false);
1619
- // 2026-08-04: run-end 经验整理 — 连续成功工具 ≥2 自动写 skill 候选 (颜文字加载)
1683
+ // 2026-08-04: run-end 经验整理 — 连续成功工具 ≥2 自动写 skill 候选
1684
+ // 2026-08-10: 显示改走 transient 行 (颜文字位置): 开始时显示, 结束后清空 (显示为空),
1685
+ // 不再追加 ✨ 消息行 → 不残留显示效果
1620
1686
  if (runEndOkSteps.length >= 2) {
1621
- appendLine(`${C_DIM}(`・ω・´) 整理本轮经验中... ${runEndOkSteps.length} 个工具调用${RESET}`);
1687
+ inkSetTransient(`${C_DIM}(`・ω・´) 整理本轮经验中... ${runEndOkSteps.length} 个工具调用${RESET}`);
1622
1688
  setImmediate(async () => {
1623
1689
  try {
1624
1690
  const { writeRunEndSkillCandidates } = await import('./agents/skill-writer.js');
1625
- const r = await writeRunEndSkillCandidates(runEndOkSteps, 'cli:interactive');
1626
- if (r.wrote) {
1627
- appendLine(`${C_OK}✨ (◕‿◕) 经验候选已写入: ${r.names}${RESET}`);
1628
- }
1691
+ await writeRunEndSkillCandidates(runEndOkSteps, 'cli:interactive');
1629
1692
  }
1630
1693
  catch { /* 非致命, 静默 */ }
1694
+ finally {
1695
+ inkSetTransient(null); // 结束后去除显示效果 (显示为空)
1696
+ }
1631
1697
  });
1632
1698
  }
1633
1699
  // 更新状态栏: 上下文进度 (2026-08-06: 每轮按当前 messageHistory 重算并写回 ContextManager,
@@ -50,6 +50,8 @@ const DEFAULTS = {
50
50
  backoffFactor: 2,
51
51
  maxSocialIntervalMs: 30 * 60_000,
52
52
  goalReevalMs: 60 * 60_000,
53
+ // 2026-08-10: 自动整理心跳
54
+ organizeIntervalMs: 30 * 60_000,
53
55
  };
54
56
  const MAX_BACKOFF_LEVEL = 6;
55
57
  export class AgentHeartbeat {
@@ -58,6 +60,9 @@ export class AgentHeartbeat {
58
60
  lastInitiated = new Map();
59
61
  beaconTimer = null;
60
62
  socialTimer = null;
63
+ // 2026-08-10: 自动整理心跳 timer + 重入锁 (上一轮没跑完不重复触发)
64
+ organizeTimer = null;
65
+ organizeRunning = false;
61
66
  started = false;
62
67
  // === 生命周期状态 ===
63
68
  phase = 'BOOTSTRAP';
@@ -78,6 +83,10 @@ export class AgentHeartbeat {
78
83
  onPeerAlive: options.onPeerAlive,
79
84
  onActivity: options.onActivity,
80
85
  onLifecycleChange: options.onLifecycleChange,
86
+ organizeEnabled: options.organizeEnabled ?? true,
87
+ organizeIntervalMs: options.organizeIntervalMs ?? DEFAULTS.organizeIntervalMs,
88
+ organize: options.organize,
89
+ onOrganizeEvent: options.onOrganizeEvent,
81
90
  beaconIntervalMs: options.beaconIntervalMs ?? DEFAULTS.beaconIntervalMs,
82
91
  socialIntervalMs: options.socialIntervalMs ?? DEFAULTS.socialIntervalMs,
83
92
  cooldownMs: options.cooldownMs ?? DEFAULTS.cooldownMs,
@@ -110,10 +119,15 @@ export class AgentHeartbeat {
110
119
  if (this.isSocialEnabled()) {
111
120
  this.scheduleSocial();
112
121
  }
122
+ // 2026-08-10: 自动整理心跳 — 与社交独立, 社交关闭也照跑
123
+ if (this.opts.organizeEnabled && this.opts.organize) {
124
+ this.scheduleOrganize();
125
+ }
113
126
  // 立即发一次 beacon, 让对端尽快看到自己
114
127
  this.tickBeacon().catch(() => { });
115
128
  console.log(`[heartbeat] 社交心跳已启动 (beacon=${this.opts.beaconIntervalMs}ms` +
116
- `${this.isSocialEnabled() ? `, social=${this.opts.socialIntervalMs}ms, cooldown=${this.opts.cooldownMs}ms` : ', social=关闭'} )`);
129
+ `${this.isSocialEnabled() ? `, social=${this.opts.socialIntervalMs}ms, cooldown=${this.opts.cooldownMs}ms` : ', social=关闭'}` +
130
+ `${this.opts.organizeEnabled && this.opts.organize ? `, organize=${this.opts.organizeIntervalMs}ms` : ', organize=关闭'} )`);
117
131
  }
118
132
  /** 优雅停止: 清理全部定时器 (供全局 runtime 的 SIGTERM/SIGINT 清理调用) */
119
133
  stop() {
@@ -121,8 +135,11 @@ export class AgentHeartbeat {
121
135
  clearInterval(this.beaconTimer);
122
136
  if (this.socialTimer)
123
137
  clearTimeout(this.socialTimer);
138
+ if (this.organizeTimer)
139
+ clearTimeout(this.organizeTimer);
124
140
  this.beaconTimer = null;
125
141
  this.socialTimer = null;
142
+ this.organizeTimer = null;
126
143
  this.started = false;
127
144
  this.setPhase('PAUSED');
128
145
  console.log('[heartbeat] 社交心跳已停止 (定时器已清理)');
@@ -192,6 +209,50 @@ export class AgentHeartbeat {
192
209
  });
193
210
  }, this.currentSocialInterval());
194
211
  }
212
+ // ===================== 自动整理心跳 (2026-08-10) =====================
213
+ // 与社交心跳并列的第三条心跳: 周期性整理 skills 经验 (候选进化) + 扫描遗留 skills.
214
+ // 与社交生命周期完全独立 — 社交关闭/退避 RESTING 不影响整理照跑.
215
+ /** 是否启用了自动整理 */
216
+ isOrganizeEnabled() {
217
+ return this.opts.enabled && this.opts.organizeEnabled && !!this.opts.organize;
218
+ }
219
+ scheduleOrganize() {
220
+ if (!this.started || !this.isOrganizeEnabled()) {
221
+ this.organizeTimer = null;
222
+ return;
223
+ }
224
+ this.organizeTimer = setTimeout(() => {
225
+ this.tickOrganize()
226
+ .catch((e) => console.warn('[heartbeat] organize tick 失败:', e?.message))
227
+ .finally(() => {
228
+ if (this.started && this.isOrganizeEnabled())
229
+ this.scheduleOrganize();
230
+ });
231
+ }, this.opts.organizeIntervalMs);
232
+ }
233
+ /** 跑一轮自动整理 (导出供测试/启动即跑: 每次打开后固定看一下 skills view) */
234
+ async tickOrganize() {
235
+ if (!this.isOrganizeEnabled() || this.organizeRunning)
236
+ return;
237
+ this.organizeRunning = true;
238
+ this.opts.onOrganizeEvent?.({ phase: 'start' });
239
+ try {
240
+ const self = await this.opts.self();
241
+ const r = await this.opts.organize({ self });
242
+ this.opts.onOrganizeEvent?.({
243
+ phase: 'end',
244
+ summary: r?.summary || (r?.done ? '完成' : ''),
245
+ });
246
+ return r;
247
+ }
248
+ catch (e) {
249
+ this.opts.onOrganizeEvent?.({ phase: 'error', error: e?.message || String(e) });
250
+ throw e;
251
+ }
252
+ finally {
253
+ this.organizeRunning = false;
254
+ }
255
+ }
195
256
  /** 社交决策 tick: 先评估生命周期, 再决定是否对存活 peer 发起对话 */
196
257
  async tickSocial() {
197
258
  this.opts.onActivity?.();
@@ -2332,6 +2332,49 @@ export async function createWebServer(port = 3000, options = {}) {
2332
2332
  ts: Date.now(),
2333
2333
  }, 'p2p-global');
2334
2334
  },
2335
+ // === 2026-08-10: 自动整理心跳 (与社交并列) ===
2336
+ // 周期性: ① 扫描 skills view 找遗留 skills ② 候选经验 LLM 完整进化 (不再只是记录工具).
2337
+ // 与社交生命周期独立 — 社交关闭 (BOLLOON_AGENT_HEARTBEAT_SOCIAL=0) 整理仍照跑.
2338
+ organizeEnabled: true,
2339
+ organizeIntervalMs: Number(process.env.BOLLOON_ORGANIZE_HEARTBEAT_MS) || 30 * 60_000,
2340
+ organize: async () => {
2341
+ try {
2342
+ watchdogRef?.recordActivity?.('agent-organize');
2343
+ }
2344
+ catch { }
2345
+ const { runAutoOrganize } = await import('../agents/skill-organizer.js');
2346
+ // 用第一个本地 channel 的 agent 做 LLM 完整经验进化 (拿不到则仅扫描)
2347
+ // 2026-08-10: getAgentForChannel 初始化可能挂起 → 8s 超时降级
2348
+ let llm;
2349
+ try {
2350
+ const channels = await loadChannels();
2351
+ const local = channels[0];
2352
+ if (local) {
2353
+ const agent = await Promise.race([
2354
+ getAgentForChannel(local.id, local.did || '', local.name, local.didDocRef).catch(() => null),
2355
+ new Promise((res) => setTimeout(() => res(null), 8000)),
2356
+ ]);
2357
+ if (agent && typeof agent.promptStream === 'function') {
2358
+ llm = (p) => agent.promptStream(p, () => { }, undefined, local.id);
2359
+ }
2360
+ }
2361
+ }
2362
+ catch { /* 无 agent → 仅扫描 */ }
2363
+ const r = await runAutoOrganize({ llm, source: 'server:organize-heartbeat', evolve: !!llm });
2364
+ const kTotal = r.knowledge?.totalHandled ?? 0;
2365
+ return { done: true, summary: `进化 ${r.evolved.length} 个 skill, 遗留 ${r.leftovers.length} 个, 知识层 ${kTotal} 项` };
2366
+ },
2367
+ onOrganizeEvent: (evt) => {
2368
+ if (evt?.phase === 'start') {
2369
+ console.log('[heartbeat] 自动整理开始 (skills 遗留扫描 + 经验进化)');
2370
+ }
2371
+ else if (evt?.phase === 'end') {
2372
+ console.log(`[heartbeat] 自动整理完成${evt.summary ? `: ${evt.summary}` : ''}`);
2373
+ }
2374
+ else if (evt?.phase === 'error') {
2375
+ console.warn(`[heartbeat] 自动整理失败 (non-fatal): ${evt?.error || ''}`);
2376
+ }
2377
+ },
2335
2378
  });
2336
2379
  agentHeartbeat.start();
2337
2380
  // 注册到全局, 让 24h HealthMonitor.checkHeartbeat 能观测到本智能体 (getDiscoveredAgents/isAntColonyEnabled)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.3.47",
3
+ "version": "0.3.49",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",