@bolloon/bolloon-agent 0.2.7 → 0.2.9

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,276 @@
1
+ /**
2
+ * chat-archiver.ts — 按 peer 月度归档聊天记录 + 接 memory 摘要
3
+ *
4
+ * 设计目的 (2026-07-05):
5
+ * 解决"对方不在线也能看到历史"的核心问题.
6
+ * sessions/cache/<channelId>.json 单文件无限增长, 超过 50MB 直接拒绝加载.
7
+ *
8
+ * 新方案:
9
+ * - 每次 /message 处理后, 同时写到 3 个地方:
10
+ * ① sessions/cache/<channelId>.json (原有, 最近窗口用)
11
+ * ② ~/.bolloon/peers/<pk>/chat-<YYYY-MM>.md (按 peer 月度归档)
12
+ * ③ ~/.bolloon/memory/<agentId>/peers/<pk>/<YYYY-MM>.summary.md (月度摘要)
13
+ *
14
+ * 触发时机:
15
+ * - appendChatArchive(): 每次保存远端/本地 user + ai 消息后立即调用 (高频)
16
+ * - compressMonthlyArchive(): 月底 / 90 天滚动 / 显式调用 (低频, 调 LLM 摘要)
17
+ *
18
+ * peer 维度 而不是 channel 维度: 同一个远端节点可能跨多个 channel 协作,
19
+ * 按 peer 归档才能完整还原"与对方的对话历史".
20
+ */
21
+ import * as fs from 'fs/promises';
22
+ import * as fsSync from 'fs';
23
+ import * as path from 'path';
24
+ import * as os from 'os';
25
+ import * as peerFs from '../network/peer-fs.js';
26
+ // ============== 路径 ==============
27
+ const HOME = process.env.BOLLOON_HOME || path.join(os.homedir(), '.bolloon');
28
+ export function getPeerSummaryDir(agentId, publicKey) {
29
+ const safeAgent = sanitizeId(agentId);
30
+ const safePeer = sanitizeId(publicKey);
31
+ return path.join(HOME, 'memory', safeAgent, 'peers', safePeer);
32
+ }
33
+ export function getPeerSummaryPath(agentId, publicKey, yearMonth) {
34
+ return path.join(getPeerSummaryDir(agentId, publicKey), `${yearMonth}.summary.md`);
35
+ }
36
+ export function getPeerSummaryCursorPath(agentId, publicKey, yearMonth) {
37
+ return path.join(getPeerSummaryDir(agentId, publicKey), `${yearMonth}.cursor`);
38
+ }
39
+ function sanitizeId(s) {
40
+ return s.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
41
+ }
42
+ // ============== appendChatArchive — 高频, 每次 message 都调 ==============
43
+ /**
44
+ * 追加一条 chat entry 到 peer 的月度归档.
45
+ * 不依赖 LLM, 纯文件 IO; 失败静默不阻塞主对话.
46
+ */
47
+ export async function appendChatArchive(opts) {
48
+ try {
49
+ await peerFs.appendChat(opts.publicKey, opts.entry);
50
+ return { ok: true, path: peerFs.getPeerChatPath(opts.publicKey) };
51
+ }
52
+ catch (err) {
53
+ return { ok: false, error: err?.message || String(err) };
54
+ }
55
+ }
56
+ // ============== compressMonthlyArchive — 低频, 月底/显式调 ==============
57
+ /**
58
+ * 读取月度 markdown 归档, 调 LLM 摘要, append 写到 memory.
59
+ * 用 cursor 跟踪已摘要到第几行, 增量压缩.
60
+ */
61
+ async function readArchiveLines(publicKey, yearMonth) {
62
+ const file = peerFs.getPeerChatPath(publicKey, yearMonth);
63
+ try {
64
+ const raw = await fs.readFile(file, 'utf-8');
65
+ return raw.split('\n');
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ }
71
+ async function readSummaryCursor(cursorPath) {
72
+ try {
73
+ const raw = await fs.readFile(cursorPath, 'utf-8');
74
+ const n = parseInt(raw.trim(), 10);
75
+ return Number.isFinite(n) && n >= 0 ? n : 0;
76
+ }
77
+ catch {
78
+ return 0;
79
+ }
80
+ }
81
+ async function writeSummaryCursor(cursorPath, value) {
82
+ await fs.mkdir(path.dirname(cursorPath), { recursive: true });
83
+ await fs.writeFile(cursorPath, String(value), 'utf-8');
84
+ }
85
+ /**
86
+ * 尝试调 LLM 生成月度摘要. 失败 → 抛错, 由 caller fallback 到模板.
87
+ */
88
+ async function tryMonthlyLlm(systemPrompt, userPrompt) {
89
+ try {
90
+ const piAi = await import('../llm/pi-ai.js');
91
+ const generateText = piAi.generateText;
92
+ if (typeof generateText !== 'function')
93
+ throw new Error('generateText not exported');
94
+ const result = await generateText({
95
+ messages: [
96
+ { role: 'system', content: systemPrompt },
97
+ { role: 'user', content: userPrompt },
98
+ ],
99
+ temperature: 0.2,
100
+ maxTokens: 1000,
101
+ });
102
+ const text = result?.reply || result?.text || '';
103
+ if (typeof text !== 'string' || text.length < 20)
104
+ throw new Error('LLM returned too-short text');
105
+ return text;
106
+ }
107
+ catch (e) {
108
+ throw new Error(`LLM call failed: ${e?.message || String(e)}`);
109
+ }
110
+ }
111
+ /**
112
+ * 模板 fallback: 简单统计 user/ai 数量, 列前 5 条关键内容.
113
+ */
114
+ function templateMonthlySummary(opts) {
115
+ const user = opts.entries.filter(e => e.msgType === 'user' || e.source === 'local' || e.source === 'remote');
116
+ const ai = opts.entries.filter(e => e.msgType === 'ai' || e.source?.startsWith('ai-mention'));
117
+ const channelSet = new Set(opts.entries.map(e => e.channelName).filter(Boolean));
118
+ const sampleUser = user.slice(0, 5).map(e => ` - [${e.ts}] ${e.text.slice(0, 80)}`).join('\n');
119
+ const sampleAi = ai.slice(0, 5).map(e => ` - [${e.ts}] ${e.text.slice(0, 80)}`).join('\n');
120
+ return `# 月度对话摘要 — ${opts.yearMonth} (peer ${opts.publicKey.slice(0, 12)}…)
121
+
122
+ 时间: ${opts.timestamp}
123
+ 对话轮次: ${opts.entries.length} (user=${user.length}, ai=${ai.length})
124
+ 涉及 channel: ${Array.from(channelSet).slice(0, 10).join(', ') || '(无)'}
125
+
126
+ ## 用户关键提问
127
+ ${sampleUser || ' (无)'}
128
+
129
+ ## AI 关键回答
130
+ ${sampleAi || ' (无)'}
131
+
132
+ ## 备注
133
+ - 此摘要由模板生成 (LLM 调用失败 fallback)
134
+ `;
135
+ }
136
+ /**
137
+ * 解析 markdown 归档为 entries (用 "### " 切分)
138
+ */
139
+ function parseArchiveEntries(lines) {
140
+ const out = [];
141
+ let i = 0;
142
+ while (i < lines.length) {
143
+ if (lines[i].startsWith('### ')) {
144
+ const header = lines[i];
145
+ // 解析 "### 2026-07-05 10:30:45 UTC [channelName] — source"
146
+ const m = header.match(/^### (.+?)\s*(?:\[(.+?)\])?\s*—\s*(\S+?)(?:\s+\(.+?\))?$/);
147
+ if (m) {
148
+ const ts = m[1].replace(/\s+UTC$/, '').trim();
149
+ const channelName = m[2];
150
+ const source = m[3];
151
+ // 找下一段 "### " 或 "---" 之间的内容
152
+ let j = i + 1;
153
+ const bodyLines = [];
154
+ while (j < lines.length && !lines[j].startsWith('### ') && !lines[j].startsWith('---')) {
155
+ bodyLines.push(lines[j]);
156
+ j++;
157
+ }
158
+ const text = bodyLines.join('\n').trim();
159
+ out.push({
160
+ startLine: i,
161
+ entry: {
162
+ ts: ts.includes('T') ? ts : ts.replace(' ', 'T') + 'Z',
163
+ source,
164
+ channelName,
165
+ text,
166
+ }
167
+ });
168
+ i = j;
169
+ continue;
170
+ }
171
+ }
172
+ i++;
173
+ }
174
+ return out;
175
+ }
176
+ /**
177
+ * 主入口: 压缩月度归档到 memory.
178
+ */
179
+ export async function compressMonthlyArchive(opts) {
180
+ const agentId = sanitizeId(opts.agentId);
181
+ const publicKey = opts.publicKey;
182
+ const yearMonth = opts.yearMonth || peerFs.currentYearMonth();
183
+ const home = opts.home || HOME;
184
+ const summaryPath = getPeerSummaryPath(agentId, publicKey, yearMonth);
185
+ const cursorPath = getPeerSummaryCursorPath(agentId, publicKey, yearMonth);
186
+ const archivePath = peerFs.getPeerChatPath(publicKey, yearMonth);
187
+ // 读归档
188
+ const lines = await readArchiveLines(publicKey, yearMonth);
189
+ if (lines.length === 0) {
190
+ return { summaryPath, cursorPath, entriesCount: 0, bytesWritten: 0, skipped: 'no-archive' };
191
+ }
192
+ // 解析 entries
193
+ const allEntries = parseArchiveEntries(lines);
194
+ const cursor = await readSummaryCursor(cursorPath);
195
+ const newEntries = allEntries.slice(cursor);
196
+ const minNew = opts.minNewEntries ?? 20;
197
+ if (newEntries.length < minNew) {
198
+ return { summaryPath, cursorPath, entriesCount: newEntries.length, bytesWritten: 0, skipped: 'too-few-entries' };
199
+ }
200
+ const timestamp = new Date().toISOString();
201
+ let summaryBody;
202
+ try {
203
+ const sysPrompt = '你是 bolloon 月度记忆压缩助手. 输入是按 peer 月度归档的聊天记录 markdown, 输出 400-800 字中文摘要, 包含: 1) 关键合作主题; 2) 共同完成的成果; 3) 待跟进事项; 4) 对方能力偏好. 不要寒暄, 不复述已知.';
204
+ const recentSnippet = newEntries.slice(-30).map(e => {
205
+ const ts = e.entry.ts;
206
+ const chan = e.entry.channelName ? `[${e.entry.channelName}]` : '';
207
+ return `[${ts}]${chan} ${e.entry.source}: ${e.entry.text}`;
208
+ }).join('\n').slice(0, 8000);
209
+ const userPrompt = `Peer: ${publicKey}\n月份: ${yearMonth}\n时间: ${timestamp}\n新增条数: ${newEntries.length}\n\n最近对话:\n${recentSnippet}`;
210
+ summaryBody = await tryMonthlyLlm(sysPrompt, userPrompt);
211
+ }
212
+ catch (e) {
213
+ summaryBody = templateMonthlySummary({
214
+ publicKey,
215
+ yearMonth,
216
+ entries: newEntries.map(e => e.entry),
217
+ timestamp,
218
+ });
219
+ }
220
+ const block = `\n\n---\n\n## 增量摘要 @ ${timestamp} (${newEntries.length} entries)\n\n${summaryBody.trim()}\n`;
221
+ await fs.mkdir(path.dirname(summaryPath), { recursive: true });
222
+ await fs.appendFile(summaryPath, block, 'utf-8');
223
+ await writeSummaryCursor(cursorPath, allEntries.length);
224
+ return {
225
+ summaryPath,
226
+ cursorPath,
227
+ entriesCount: newEntries.length,
228
+ bytesWritten: Buffer.byteLength(block, 'utf-8'),
229
+ };
230
+ }
231
+ /**
232
+ * 把 session.json 中的所有消息按 peer 切分, 一次性 archive 到各 peer 月度文件.
233
+ * 用于: ① 启动时迁移已有 session 历史到 peer archive; ② 兜底补档.
234
+ */
235
+ export async function archiveSessionMessagesForPeer(opts) {
236
+ let written = 0;
237
+ let errors = 0;
238
+ for (const m of opts.messages) {
239
+ const ts = m.timestamp || new Date().toISOString();
240
+ const entry = {
241
+ ts,
242
+ source: m.source || (m.type === 'user' ? 'remote' : 'ai-mention'),
243
+ channelId: m.channelId,
244
+ channelName: m.channelName,
245
+ text: m.content,
246
+ fromPublicKey: m.fromPublicKey || opts.publicKey,
247
+ msgType: m.type,
248
+ };
249
+ const r = await appendChatArchive({ publicKey: opts.publicKey, entry });
250
+ if (r.ok)
251
+ written++;
252
+ else
253
+ errors++;
254
+ }
255
+ return { written, errors };
256
+ }
257
+ /**
258
+ * 列出指定 peer 所有可用的月度摘要 (按月份倒序)
259
+ */
260
+ export async function listPeerSummaries(agentId, publicKey) {
261
+ const dir = getPeerSummaryDir(agentId, publicKey);
262
+ try {
263
+ const entries = await fs.readdir(dir);
264
+ return entries
265
+ .filter(e => /^\d{4}-\d{2}\.summary\.md$/.test(e))
266
+ .map(e => ({
267
+ yearMonth: e.replace(/\.summary\.md$/, ''),
268
+ path: path.join(dir, e),
269
+ size: fsSync.statSync(path.join(dir, e)).size,
270
+ }))
271
+ .sort((a, b) => b.yearMonth.localeCompare(a.yearMonth));
272
+ }
273
+ catch {
274
+ return [];
275
+ }
276
+ }
@@ -136,10 +136,13 @@ async function collectJudgmentsSummary(topN) {
136
136
  topValues,
137
137
  };
138
138
  }
139
- async function collectSkills() {
139
+ async function collectSkills(projectCwd) {
140
140
  const home = process.env.HOME || os.homedir() || '/tmp';
141
141
  const userSkillsDir = path.join(home, '.bolloon', 'skills');
142
- const projectSkillsDir = path.join(process.cwd(), '.bolloon', 'skills');
142
+ // 2026-07-04: opts.cwd 而不是 process.cwd(), collectBolloonContext({cwd})
143
+ // 在测试或子进程里能正确隔离项目级 skill 目录 (之前 process.cwd() 会读到
144
+ // caller 的 cwd, 测试空目录时泄漏真实项目 skills).
145
+ const projectSkillsDir = path.join(projectCwd || process.cwd(), '.bolloon', 'skills');
143
146
  const out = [];
144
147
  const seen = new Set();
145
148
  for (const dir of [userSkillsDir, projectSkillsDir]) {
@@ -273,7 +276,7 @@ export async function collectBolloonContext(opts) {
273
276
  collectProject(cwd, bolloonMdMaxBytes),
274
277
  collectGit(cwd, gitCommitLimit),
275
278
  collectPersona(),
276
- collectSkills(),
279
+ collectSkills(cwd),
277
280
  Promise.resolve(collectEnv()),
278
281
  collectPending(opts),
279
282
  collectHierarchy(cwd),
@@ -9,6 +9,7 @@ import * as os from 'os';
9
9
  import * as path from 'path';
10
10
  import { getCachedBolloonContext, clearBolloonContextCache } from './context-collector.js';
11
11
  import { formatContextForSystemPrompt } from './project-context.js';
12
+ import { loadPersonaDocs, formatPersonaForSystemPrompt } from './persona-loader.js';
12
13
  let lastSessionStartAt = 0;
13
14
  const MIN_INTERVAL_MS = 5000; // 同一进程 5s 内最多触发一次, 防止循环
14
15
  export async function onSessionStart(opts = {}) {
@@ -25,10 +26,23 @@ export async function onSessionStart(opts = {}) {
25
26
  if (opts.channelId) {
26
27
  systemAddition = `# 当前 channel: ${opts.channelId}\n\n` + systemAddition;
27
28
  }
29
+ // 在头部最前拼 persona 文档 (如果 agentId 存在)
30
+ if (opts.agentId) {
31
+ try {
32
+ const docs = await loadPersonaDocs(opts.agentId);
33
+ const personaText = formatPersonaForSystemPrompt(docs);
34
+ if (personaText) {
35
+ systemAddition = personaText + '\n\n' + systemAddition;
36
+ }
37
+ }
38
+ catch (err) {
39
+ console.warn('[lifecycle-hooks] loadPersonaDocs failed (silent):', err);
40
+ }
41
+ }
28
42
  return {
29
43
  systemAddition,
30
44
  collectMs: Date.now() - start,
31
- truncated: systemAddition.length > 0 && systemAddition.includes('截断模式'),
45
+ truncated: systemAddition.length > 0 && systemAddition.includes('截断'),
32
46
  };
33
47
  }
34
48
  catch (err) {
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Memory Compressor — session 消息 → LLM 摘要 → ~/.bolloon/memory/<agentId>/sessions/
3
+ *
4
+ * 触发: 每次 /message 处理后, saveSession 之后 (server.ts:2073 之后)
5
+ * 行为:
6
+ * - 读 session messages
7
+ * - 只压缩 cursor 之后的增量 (≥ 4 条新 messages 才压缩, 防频繁)
8
+ * - 调 LLM 生成中文摘要 (失败 fallback 到纯模板)
9
+ * - append 模式写入 <safe-channelId>__<safe-sessionId>.summary.md
10
+ * - 维护 cursor 文件 <safe-channelId>__<safe-sessionId>.cursor
11
+ *
12
+ * 失败静默: 调用方 try/catch 后 console.warn, 不阻塞主对话.
13
+ */
14
+ import * as fs from 'fs/promises';
15
+ import * as os from 'os';
16
+ import * as path from 'path';
17
+ export function sanitizeAgentId(id) {
18
+ return id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
19
+ }
20
+ export function sanitizeKey(id) {
21
+ return id.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 128);
22
+ }
23
+ /**
24
+ * ~/.bolloon/memory/<sanitizedAgentId>/
25
+ */
26
+ export function getMemoryDir(agentId, home) {
27
+ return path.join(home || os.homedir(), '.bolloon', 'memory', sanitizeAgentId(agentId));
28
+ }
29
+ /**
30
+ * ~/.bolloon/memory/<agentId>/sessions/<safe-channelId>__<safe-sessionId>.summary.md
31
+ */
32
+ export function getSessionSummaryPath(agentId, channelId, sessionId, home) {
33
+ const key = `${sanitizeKey(channelId)}__${sanitizeKey(sessionId)}`;
34
+ return path.join(getMemoryDir(agentId, home), 'sessions', `${key}.summary.md`);
35
+ }
36
+ export function getSessionCursorPath(agentId, channelId, sessionId, home) {
37
+ const key = `${sanitizeKey(channelId)}__${sanitizeKey(sessionId)}`;
38
+ return path.join(getMemoryDir(agentId, home), 'sessions', `${key}.cursor`);
39
+ }
40
+ /**
41
+ * Session 缓存文件路径 (跟 server.ts:1806 sessionKey 规则一致)
42
+ */
43
+ function getSessionCacheFile(channelId, sessionId, home) {
44
+ const root = path.join(home || os.homedir(), '.bolloon', 'sessions', 'cache');
45
+ const safeChannel = sanitizeKey(channelId);
46
+ const safeSession = sanitizeKey(sessionId).replace(/:/g, '__');
47
+ return path.join(root, `${safeChannel}__${safeSession}.json`);
48
+ }
49
+ async function readCursor(cursorPath) {
50
+ try {
51
+ const raw = await fs.readFile(cursorPath, 'utf-8');
52
+ const n = parseInt(raw.trim(), 10);
53
+ return Number.isFinite(n) && n >= 0 ? n : 0;
54
+ }
55
+ catch {
56
+ return 0;
57
+ }
58
+ }
59
+ async function writeCursor(cursorPath, value) {
60
+ await fs.mkdir(path.dirname(cursorPath), { recursive: true });
61
+ await fs.writeFile(cursorPath, String(value), 'utf-8');
62
+ }
63
+ async function readSessionMessages(sessionCacheFile) {
64
+ try {
65
+ const raw = await fs.readFile(sessionCacheFile, 'utf-8');
66
+ const parsed = JSON.parse(raw);
67
+ if (Array.isArray(parsed))
68
+ return parsed;
69
+ if (Array.isArray(parsed.messages))
70
+ return parsed.messages;
71
+ return [];
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ }
77
+ /**
78
+ * 纯模板摘要 (LLM 失败 fallback, 不依赖外部调用)
79
+ */
80
+ function templateSummary(opts) {
81
+ const userMsgs = opts.newMessages.filter(m => m.type === 'user');
82
+ const aiMsgs = opts.newMessages.filter(m => m.type === 'ai');
83
+ const userSnippet = userMsgs.slice(0, 3).map(m => ` - ${m.content.slice(0, 80)}`).join('\n');
84
+ const aiSnippet = aiMsgs.slice(0, 3).map(m => ` - ${m.content.slice(0, 80)}`).join('\n');
85
+ return `# Session 摘要 — ${opts.channelId} / ${opts.sessionId}
86
+
87
+ 时间: ${opts.timestamp}
88
+ 新增消息数: ${opts.newMessages.length} (user=${userMsgs.length}, ai=${aiMsgs.length})
89
+
90
+ ## 用户关键提问
91
+ ${userSnippet || ' (无)'}
92
+
93
+ ## AI 关键回答
94
+ ${aiSnippet || ' (无)'}
95
+
96
+ ## 备注
97
+ - 此摘要由模板生成 (LLM 调用失败 fallback)
98
+ `;
99
+ }
100
+ /**
101
+ * 尝试调 LLM 生成更精炼的中文摘要. 失败 → 抛错, 由 caller fallback.
102
+ *
103
+ * 用动态 import + 失败静默 — 不引入 pi-ai 强依赖 (避免循环).
104
+ */
105
+ async function tryLlmSummary(systemPrompt, userPrompt) {
106
+ try {
107
+ const piAi = await import('../llm/pi-ai.js');
108
+ const generateText = piAi.generateText;
109
+ if (typeof generateText !== 'function')
110
+ throw new Error('generateText not exported');
111
+ const result = await generateText({
112
+ messages: [
113
+ { role: 'system', content: systemPrompt },
114
+ { role: 'user', content: userPrompt },
115
+ ],
116
+ temperature: 0.2,
117
+ maxTokens: 800,
118
+ });
119
+ const text = result?.reply || result?.text || '';
120
+ if (typeof text !== 'string' || text.length < 20)
121
+ throw new Error('LLM returned too-short text');
122
+ return text;
123
+ }
124
+ catch (e) {
125
+ throw new Error(`LLM call failed: ${e?.message || String(e)}`);
126
+ }
127
+ }
128
+ export async function compressSessionToMemory(opts) {
129
+ const agentId = sanitizeAgentId(opts.agentId);
130
+ const home = opts.home || os.homedir();
131
+ const summaryPath = getSessionSummaryPath(agentId, opts.channelId, opts.sessionId, home);
132
+ const cursorPath = getSessionCursorPath(agentId, opts.channelId, opts.sessionId, home);
133
+ const sessionCacheFile = getSessionCacheFile(opts.channelId, opts.sessionId, home);
134
+ const allMessages = await readSessionMessages(sessionCacheFile);
135
+ const cursor = await readCursor(cursorPath);
136
+ const newMessages = allMessages.slice(cursor);
137
+ const minNew = opts.minNewMessages ?? 4;
138
+ if (allMessages.length === 0) {
139
+ return { summaryPath, cursorPath, messagesCount: 0, bytesWritten: 0, skipped: 'no-new-messages' };
140
+ }
141
+ if (newMessages.length < minNew) {
142
+ return { summaryPath, cursorPath, messagesCount: newMessages.length, bytesWritten: 0, skipped: 'too-few-messages' };
143
+ }
144
+ const timestamp = new Date().toISOString();
145
+ let summaryBody;
146
+ try {
147
+ const sysPrompt = '你是 bolloon 记忆压缩助手. 输入是一段 session 消息历史 (用户问题 + AI 回答), 输出 200-400 字中文摘要, 包含 3-5 条关键发现和未完成事项. 不要寒暄, 不要复述已知. 格式: ## 关键发现 / ## 待办.';
148
+ const recentSnippet = newMessages.slice(-10).map(m => `[${m.type}] ${m.content}`).join('\n---\n').slice(0, 6000);
149
+ const userPrompt = `Channel: ${opts.channelId}\nSession: ${opts.sessionId}\n时间: ${timestamp}\n新增消息数: ${newMessages.length}\n\n最近消息:\n${recentSnippet}`;
150
+ summaryBody = await tryLlmSummary(sysPrompt, userPrompt);
151
+ }
152
+ catch (e) {
153
+ summaryBody = templateSummary({
154
+ channelId: opts.channelId,
155
+ sessionId: opts.sessionId,
156
+ newMessages,
157
+ timestamp,
158
+ });
159
+ }
160
+ const block = `\n\n---\n\n## 增量摘要 @ ${timestamp} (${newMessages.length} messages)\n\n${summaryBody.trim()}\n`;
161
+ await fs.mkdir(path.dirname(summaryPath), { recursive: true });
162
+ await fs.appendFile(summaryPath, block, 'utf-8');
163
+ await writeCursor(cursorPath, allMessages.length);
164
+ return {
165
+ summaryPath,
166
+ cursorPath,
167
+ messagesCount: newMessages.length,
168
+ bytesWritten: Buffer.byteLength(block, 'utf-8'),
169
+ };
170
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Persona Documents Loader — ~/.bolloon/persona/<agentId>/*.md
3
+ *
4
+ * 失败静默: 文件不存在 → 字段 = '', 不抛错
5
+ * 安全: agentId sanitize (防路径穿越)
6
+ * 6 段输出顺序: identity → soul → project → user → agent → wiki
7
+ */
8
+ import * as fs from 'fs/promises';
9
+ import * as os from 'os';
10
+ import * as path from 'path';
11
+ const FILE_KEYS = ['soul', 'identity', 'project', 'user', 'agent', 'wiki'];
12
+ /**
13
+ * 安全转 agentId: 只保留 [a-zA-Z0-9_-], 限长 64
14
+ * 防止路径穿越 ('/', '..', '\\\\' 都变 '_')
15
+ */
16
+ export function sanitizeAgentId(id) {
17
+ return id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
18
+ }
19
+ /**
20
+ * 读 ~/.bolloon/persona/<sanitizedAgentId>/{6 files}
21
+ * 文件不存在 → 该字段 = ''
22
+ */
23
+ export async function loadPersonaDocs(agentId, home) {
24
+ const safeId = sanitizeAgentId(agentId);
25
+ const root = home || os.homedir();
26
+ const baseDir = path.join(root, '.bolloon', 'persona', safeId);
27
+ const docs = {
28
+ agentId: safeId,
29
+ soul: '',
30
+ identity: '',
31
+ project: '',
32
+ user: '',
33
+ agent: '',
34
+ wiki: '',
35
+ };
36
+ await Promise.all(FILE_KEYS.map(async (key) => {
37
+ const file = path.join(baseDir, `${key}.md`);
38
+ try {
39
+ const content = await fs.readFile(file, 'utf-8');
40
+ docs[key] = content;
41
+ }
42
+ catch {
43
+ docs[key] = '';
44
+ }
45
+ }));
46
+ return docs;
47
+ }
48
+ const DEFAULT_MAX_CHARS = 4000;
49
+ const SECTION_LABELS = {
50
+ identity: 'Identity',
51
+ soul: 'Soul',
52
+ project: 'Project',
53
+ user: 'User',
54
+ agent: 'Agent',
55
+ wiki: 'Wiki',
56
+ };
57
+ const OUTPUT_ORDER = ['identity', 'soul', 'project', 'user', 'agent', 'wiki'];
58
+ /**
59
+ * 把 PersonaDocs 格式化成 markdown 段, 拼到 system prompt 头部.
60
+ *
61
+ * 超 maxChars 时按比例截断: 每个字段都保留头部,
62
+ * 保证 6 段标识都出现, 不砍段.
63
+ */
64
+ export function formatPersonaForSystemPrompt(docs, maxChars) {
65
+ const cap = maxChars ?? DEFAULT_MAX_CHARS;
66
+ // 段: 标识 + 字段值 (空字段跳过)
67
+ const sections = [];
68
+ for (const key of OUTPUT_ORDER) {
69
+ const v = docs[key];
70
+ if (v && v.length > 0) {
71
+ sections.push({ key, text: `## ${SECTION_LABELS[key]}\n${v}` });
72
+ }
73
+ }
74
+ if (sections.length === 0)
75
+ return '';
76
+ // 预算每段 (去掉 ## 标识和换行的固定开销)
77
+ const header = `# Persona (agentId=${docs.agentId})\n\n`;
78
+ const fixedOverhead = header.length + (sections.length - 1) * 2;
79
+ const perSectionBudget = Math.max(50, Math.floor((cap - fixedOverhead) / sections.length));
80
+ const parts = [header.trim()];
81
+ for (const sec of sections) {
82
+ let body = sec.text;
83
+ if (body.length > perSectionBudget) {
84
+ body = body.substring(0, perSectionBudget) + '\n... (截断)';
85
+ }
86
+ parts.push(body);
87
+ }
88
+ let result = parts.join('\n\n');
89
+ if (result.length > cap) {
90
+ const truncateMarker = '\n... (截断)';
91
+ result = result.substring(0, Math.max(0, cap - truncateMarker.length)) + truncateMarker;
92
+ }
93
+ return result;
94
+ }