memory-service.js
10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
/**
* MemoryService — Agent 自动记忆读写
*
* Phase 1 (MVP):
* - 对话结束 → 自动生成 session 摘要 → short-term/
* - 对话结束 → 关键事实追加 → memory.md
* - 新对话开始 → 加载最近 N 个 session 摘要注入 prompt
*
* Phase 2 (TODO):
* - 海马体关键词匹配
* - 小脑方案固化
*/
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.resolve(__dirname, '../../data/agents');
const MAX_SHORT_TERM = 10; // 最多保留最近 N 个 session 摘要
const MAX_MEMORY_MD_SIZE = 3000; // memory.md 最大字符数
const INJECT_SESSION_COUNT = 5; // prompt 注入最近 N 个 session
// ════════════════════════════════════════
// 1. 对话结束 → 自动保存 Session 摘要
// ════════════════════════════════════════
/**
* 从 messages.log 和 meta 中提取信息,生成 session 摘要,写入 short-term/
*
* @param {string} agentId
* @param {string} convId
* @param {object} meta - { status, stats, createdAt, updatedAt, title }
* @param {string} lastOutput - Agent 最后的输出文本
*/
function saveSessionSummary(agentId, convId, meta, lastOutput) {
try {
const agentDir = path.join(DATA_DIR, agentId);
const shortTermDir = path.join(agentDir, 'memory', 'short-term');
if (!fs.existsSync(shortTermDir)) {
fs.mkdirSync(shortTermDir, { recursive: true });
}
// 提取任务描述 (从 title 或 messages.log 的第一条)
const task = extractTask(meta.title || '', agentId, convId);
// 提取结果摘要 (Agent 最后输出的前 500 字)
const resultSummary = extractResultSummary(lastOutput);
// 提取提到的文件路径
const files = extractMentionedFiles(lastOutput);
// 计算持续时间
const createdAt = new Date(meta.createdAt);
const updatedAt = new Date(meta.updatedAt || Date.now());
const durationSec = Math.round((updatedAt.getTime() - createdAt.getTime()) / 1000);
const durationStr = durationSec > 60
? `${Math.round(durationSec / 60)}分钟`
: `${durationSec}秒`;
// 构造摘要
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
const summary = [
`# Session 摘要`,
``,
`- **时间**: ${timestamp}`,
`- **对话ID**: ${convId}`,
`- **状态**: ${meta.status || 'completed'}`,
`- **工具调用**: ${meta.stats?.toolCallCount || 0} 次`,
`- **耗时**: ${durationStr}`,
``,
`## 任务`,
task || '(无明确任务描述)',
``,
`## 结果`,
resultSummary || '(无输出)',
];
if (files.length > 0) {
summary.push('', '## 涉及文件', ...files.map(f => `- ${f}`));
}
summary.push('');
// 写入文件
const filename = `session-${Date.now()}.md`;
const filepath = path.join(shortTermDir, filename);
fs.writeFileSync(filepath, summary.join('\n'), 'utf-8');
// 清理过旧的 session 文件(保留最近 MAX_SHORT_TERM 个)
cleanupShortTerm(shortTermDir);
// 追加关键事实到 memory.md
appendToMemoryMd(agentId, task, resultSummary, meta);
console.log(`[Memory] ✅ Session 摘要已保存: ${agentId}/${filename}`);
return filepath;
} catch (err) {
console.error(`[Memory] ⚠️ Session 摘要保存失败:`, err.message);
return null;
}
}
// ════════════════════════════════════════
// 2. 新对话开始 → 加载近期记忆
// ════════════════════════════════════════
/**
* 加载最近 N 个 session 摘要,用于注入 system prompt
*
* @param {string} agentId
* @returns {string} 格式化的近期记忆文本
*/
function loadRecentMemory(agentId) {
try {
const shortTermDir = path.join(DATA_DIR, agentId, 'memory', 'short-term');
if (!fs.existsSync(shortTermDir)) return '';
// 按文件名倒序(时间戳降序)取最近 N 个
const files = fs.readdirSync(shortTermDir)
.filter(f => f.startsWith('session-') && f.endsWith('.md'))
.sort()
.reverse()
.slice(0, INJECT_SESSION_COUNT);
if (files.length === 0) return '';
const sessions = files.map(f => {
const content = fs.readFileSync(path.join(shortTermDir, f), 'utf-8');
return compactSession(content);
});
return [
`# 近期记忆(最近 ${sessions.length} 次对话)`,
'',
...sessions,
].join('\n');
} catch (err) {
console.error(`[Memory] ⚠️ 加载近期记忆失败:`, err.message);
return '';
}
}
// ════════════════════════════════════════
// 3. memory.md 自动追加
// ════════════════════════════════════════
/**
* 追加关键事实到 memory.md
* 只在有实质操作(toolCallCount > 0)时追加
*/
function appendToMemoryMd(agentId, task, result, meta) {
if (!meta.stats?.toolCallCount || meta.stats.toolCallCount === 0) return;
const memoryPath = path.join(DATA_DIR, agentId, 'memory', 'memory.md');
let content = '';
try {
content = fs.readFileSync(memoryPath, 'utf-8');
} catch { /* 文件不存在 */ }
const timestamp = new Date().toISOString().substring(0, 10);
const taskOneLine = (task || '').split('\n')[0].substring(0, 80);
const resultOneLine = (result || '').split('\n')[0].substring(0, 100);
// 构造新条目
const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`;
// 追加到"关键事实"段
if (content.includes('## 关键事实')) {
content = content.replace(
/## 关键事实\n([\s\S]*?)(?=\n## |$)/,
(match, existing) => {
const trimmed = existing.trim();
if (trimmed === '(尚无记录)') {
return `## 关键事实\n${entry}\n`;
}
return `## 关键事实\n${trimmed}\n${entry}\n`;
}
);
} else {
// 没有"关键事实"段,追加到末尾
content += `\n\n## 关键事实\n${entry}\n`;
}
// 大小控制:超过限制时截断最早的条目
if (content.length > MAX_MEMORY_MD_SIZE) {
content = truncateMemory(content);
}
fs.writeFileSync(memoryPath, content, 'utf-8');
}
// ════════════════════════════════════════
// 内部工具函数
// ════════════════════════════════════════
/** 从 title 提取任务描述 */
function extractTask(title, agentId, convId) {
// 去掉 [TASK from xxx] 前缀
let task = title.replace(/\[TASK from \w+\]\s*/g, '').trim();
// 去掉任务编号行
task = task.replace(/任务编号:\s*T-\w+\s*/g, '').trim();
task = task.replace(/优先级:\s*P\d+\s*/g, '').trim();
// 如果 title 太短,尝试从 messages.log 读取
if (task.length < 10) {
try {
const logPath = path.join(DATA_DIR, agentId, 'conversations', convId, 'messages.log');
const log = fs.readFileSync(logPath, 'utf-8');
const userMsg = log.match(/\[USER\]\n([\s\S]*?)(?:\n\[AGENT\]|$)/);
if (userMsg) task = userMsg[1].trim().substring(0, 300);
} catch { /* ignore */ }
}
return task.substring(0, 300);
}
/** 从 Agent 输出提取结果摘要 */
function extractResultSummary(output) {
if (!output) return '';
// 优先取 [RESULT: DONE]...[/RESULT] 块
const resultBlock = output.match(/\[RESULT:\s*DONE\]([\s\S]*?)\[\/RESULT\]/);
if (resultBlock) return resultBlock[1].trim().substring(0, 500);
// 否则取最后 500 字
const trimmed = output.trim();
if (trimmed.length > 500) {
return '...' + trimmed.substring(trimmed.length - 500);
}
return trimmed;
}
/** 提取输出中提到的文件路径 */
function extractMentionedFiles(output) {
if (!output) return [];
// 匹配常见文件路径模式
const patterns = [
/(?:写入|创建|保存|输出)[到至::]\s*[`"']?([^\s`"']+\.\w+)/g,
/(?:data\/\w+\/[^\s]+)/g,
/(?:workspace\/[^\s]+)/g,
];
const files = new Set();
for (const pat of patterns) {
let m;
while ((m = pat.exec(output)) !== null) {
const f = m[1] || m[0];
if (f.length > 5 && f.length < 200) files.add(f);
}
}
return [...files].slice(0, 10); // 最多 10 个
}
/** 压缩 session 内容为紧凑格式(用于 prompt 注入) */
function compactSession(content) {
// 提取关键字段
const time = content.match(/时间\**[::]\s*(.+)/)?.[1] || '';
const status = content.match(/状态\**[::]\s*(.+)/)?.[1] || '';
const tools = content.match(/工具调用\**[::]\s*(.+)/)?.[1] || '';
const duration = content.match(/耗时\**[::]\s*(.+)/)?.[1] || '';
// 提取任务和结果段
const taskMatch = content.match(/## 任务\n([\s\S]*?)(?=\n## |$)/);
const resultMatch = content.match(/## 结果\n([\s\S]*?)(?=\n## |$)/);
const filesMatch = content.match(/## 涉及文件\n([\s\S]*?)(?=\n## |$)/);
const task = taskMatch?.[1]?.trim().substring(0, 100) || '';
const result = resultMatch?.[1]?.trim().substring(0, 200) || '';
const filesList = filesMatch?.[1]?.trim() || '';
const parts = [`### ${time} [${status}] (${tools}, ${duration})`];
if (task) parts.push(`- 任务: ${task}`);
if (result) parts.push(`- 结果: ${result}`);
if (filesList) parts.push(`- 文件: ${filesList.replace(/\n/g, ', ').replace(/- /g, '')}`);
parts.push('');
return parts.join('\n');
}
/** 清理过旧的 short-term 文件 */
function cleanupShortTerm(dir) {
try {
const files = fs.readdirSync(dir)
.filter(f => f.startsWith('session-') && f.endsWith('.md'))
.sort();
// 保留最近 MAX_SHORT_TERM 个
while (files.length > MAX_SHORT_TERM) {
const oldest = files.shift();
fs.unlinkSync(path.join(dir, oldest));
}
} catch { /* ignore */ }
}
/** memory.md 超大时截断最早条目 */
function truncateMemory(content) {
const lines = content.split('\n');
// 找到 ## 关键事实 段,删除最早的条目
const factIdx = lines.findIndex(l => l.startsWith('## 关键事实'));
if (factIdx === -1) return content;
// 找到条目行(以 - [ 开头)
const entries = [];
for (let i = factIdx + 1; i < lines.length; i++) {
if (lines[i].startsWith('## ')) break;
if (lines[i].startsWith('- [')) entries.push(i);
}
// 删除最早的 1/3 条目
const removeCount = Math.max(1, Math.floor(entries.length / 3));
const removeIndices = new Set(entries.slice(0, removeCount));
const newLines = lines.filter((_, i) => !removeIndices.has(i));
return newLines.join('\n');
}
module.exports = {
saveSessionSummary,
loadRecentMemory,
appendToMemoryMd,
};