Commit ef48bf0b161fcffeda90b7f5294d9e0097aadbca
1 parent
90527823
feat: implement Agent Memory Phase 1 MVP
- New: server/services/memory-service.js - saveSessionSummary(): auto-save session summary to short-term/ - loadRecentMemory(): inject last 5 sessions into system prompt - appendToMemoryMd(): auto-append key facts with size control - Modified: server/routes/agent-chat.js - buildSystemPrompt() now loads recent short-term sessions - Conversation completion triggers auto memory save - Verified: CEO Agent conversation creates session file + updates memory.md
Showing
4 changed files
with
335 additions
and
2 deletions
data/agents/ceo/memory/memory.md
data/agents/ceo/todo.md
| ... | ... | @@ -9,3 +9,4 @@ |
| 9 | 9 | | T-mn4ajjuz | 【启动首个互动剧项目】NarrativeAI Inc. 公司架构、核心团队和运营流程已全部完成,现在正式启动首个互动剧项目。具体要求:1) 基于公司定位(AI互动剧平台)确定首个项目选题方向;2) 设计首个互动剧的题材、目标用户、核心卖点;3) 输出项目立项文档到 workspace/project_001_proposal.md,包含:项目名称、故事梗概、目标用户画像、核心互动机制、预期KPI、团队分工、里程碑计划;4) 完成后向我汇报 | ✅ 完成 | master-001 | P0 | 2026-03-24 07:26 | - | |
| 10 | 10 | | T-mn4aq3tt | 【完成首个项目剧本大纲】首个互动剧项目《深渊回响》已立项,现在需要完成详细剧本大纲。具体要求:1) 参考 workspace/project_001_proposal.md 中的项目设定;2) 设计完整的故事结构:开端(研究站失联)、发展(救援队员逐步发现真相)、高潮(核心秘密揭露)、结局(多重分支结局);3) 设计核心角色及其对话风格;4) 设计关键互动节点和分支逻辑;5) 将剧本大纲输出到 workspace/project_001_script_outline.md;6) 完成后向我汇报 | ✅ 完成 | master-001 | P1 | 2026-03-24 07:31 | - | |
| 11 | 11 | | T-mn4b2s1a | 【完成技术架构搭建】首个互动剧项目《深渊回响》剧本大纲已完成,现在需要完成技术架构搭建。具体要求:1) 参考 workspace/project_001_proposal.md 和 workspace/project_001_script_outline.md;2) 设计技术架构方案,包括:AI对话引擎选型、分支逻辑系统、用户状态管理、内容存储方案;3) 明确技术选型理由和依赖库;4) 输出技术架构文档到 workspace/project_001_tech_architecture.md;5) 完成后向我汇报,标志着 NarrativeAI Inc. 公司设立任务全部完成 | ✅ 完成 | master-001 | P0 | 2026-03-24 07:41 | - | |
| 12 | +| T-mn5g4hlb | 列出你知道的关于战略规划的3个核心要点,简要回答即可,不需要使用工具。 | ✅ 完成 | master | P1 | 2026-03-25 02:50 | - | | ... | ... |
server/routes/agent-chat.js
| ... | ... | @@ -20,6 +20,7 @@ const { ClaudeCliProtocol } = require('@mas/acp-core'); |
| 20 | 20 | const { Parser } = require('../src/core/parser'); |
| 21 | 21 | const AgentManager = require('../src/core/agent-manager'); |
| 22 | 22 | const { GoalsService } = require('../services/goals'); |
| 23 | +const { saveSessionSummary, loadRecentMemory } = require('../services/memory-service'); | |
| 23 | 24 | const systemConfig = require('../config'); |
| 24 | 25 | const router = express.Router(); |
| 25 | 26 | |
| ... | ... | @@ -287,10 +288,14 @@ function buildSystemPrompt(agentDir) { |
| 287 | 288 | const heartbeat = loadFile(agentDir, 'heartbeat.md'); |
| 288 | 289 | if (heartbeat.trim()) parts.push(heartbeat.trim()); |
| 289 | 290 | |
| 290 | - // memory | |
| 291 | + // memory — 核心记忆索引 | |
| 291 | 292 | const memory = loadFile(agentDir, 'memory/memory.md'); |
| 292 | 293 | if (memory.trim()) parts.push('---\n\n# 我的记忆\n\n' + memory.trim()); |
| 293 | 294 | |
| 295 | + // short-term — 近期对话摘要 (Phase 1 MVP) | |
| 296 | + const recentMemory = loadRecentMemory(config?.id || path.basename(agentDir)); | |
| 297 | + if (recentMemory) parts.push('---\n\n' + recentMemory); | |
| 298 | + | |
| 294 | 299 | // skills |
| 295 | 300 | let mainSkill = loadFile(agentDir, '.claude/skills/main_skill/SKILL.md'); |
| 296 | 301 | if (mainSkill.trim()) { |
| ... | ... | @@ -1308,6 +1313,13 @@ ${resultsText.join('\n\n')} |
| 1308 | 1313 | meta.status = 'completed'; |
| 1309 | 1314 | saveMeta(agentId, convId, meta); |
| 1310 | 1315 | |
| 1316 | + // ── Phase 1 MVP: 自动保存 Session 摘要到记忆 ── | |
| 1317 | + try { | |
| 1318 | + saveSessionSummary(agentId, convId, meta, fullOutput); | |
| 1319 | + } catch (memErr) { | |
| 1320 | + console.error('[Memory] Session 摘要保存失败:', memErr.message); | |
| 1321 | + } | |
| 1322 | + | |
| 1311 | 1323 | sendSSE('result', { conversationId: convId, agentId, messageId: msgId, output: fullOutput }); |
| 1312 | 1324 | |
| 1313 | 1325 | } catch (err) { | ... | ... |
server/services/memory-service.js
0 → 100644
| 1 | +/** | |
| 2 | + * MemoryService — Agent 自动记忆读写 | |
| 3 | + * | |
| 4 | + * Phase 1 (MVP): | |
| 5 | + * - 对话结束 → 自动生成 session 摘要 → short-term/ | |
| 6 | + * - 对话结束 → 关键事实追加 → memory.md | |
| 7 | + * - 新对话开始 → 加载最近 N 个 session 摘要注入 prompt | |
| 8 | + * | |
| 9 | + * Phase 2 (TODO): | |
| 10 | + * - 海马体关键词匹配 | |
| 11 | + * - 小脑方案固化 | |
| 12 | + */ | |
| 13 | + | |
| 14 | +const fs = require('fs'); | |
| 15 | +const path = require('path'); | |
| 16 | + | |
| 17 | +const DATA_DIR = path.resolve(__dirname, '../../data/agents'); | |
| 18 | +const MAX_SHORT_TERM = 10; // 最多保留最近 N 个 session 摘要 | |
| 19 | +const MAX_MEMORY_MD_SIZE = 3000; // memory.md 最大字符数 | |
| 20 | +const INJECT_SESSION_COUNT = 5; // prompt 注入最近 N 个 session | |
| 21 | + | |
| 22 | +// ════════════════════════════════════════ | |
| 23 | +// 1. 对话结束 → 自动保存 Session 摘要 | |
| 24 | +// ════════════════════════════════════════ | |
| 25 | + | |
| 26 | +/** | |
| 27 | + * 从 messages.log 和 meta 中提取信息,生成 session 摘要,写入 short-term/ | |
| 28 | + * | |
| 29 | + * @param {string} agentId | |
| 30 | + * @param {string} convId | |
| 31 | + * @param {object} meta - { status, stats, createdAt, updatedAt, title } | |
| 32 | + * @param {string} lastOutput - Agent 最后的输出文本 | |
| 33 | + */ | |
| 34 | +function saveSessionSummary(agentId, convId, meta, lastOutput) { | |
| 35 | + try { | |
| 36 | + const agentDir = path.join(DATA_DIR, agentId); | |
| 37 | + const shortTermDir = path.join(agentDir, 'memory', 'short-term'); | |
| 38 | + | |
| 39 | + if (!fs.existsSync(shortTermDir)) { | |
| 40 | + fs.mkdirSync(shortTermDir, { recursive: true }); | |
| 41 | + } | |
| 42 | + | |
| 43 | + // 提取任务描述 (从 title 或 messages.log 的第一条) | |
| 44 | + const task = extractTask(meta.title || '', agentId, convId); | |
| 45 | + | |
| 46 | + // 提取结果摘要 (Agent 最后输出的前 500 字) | |
| 47 | + const resultSummary = extractResultSummary(lastOutput); | |
| 48 | + | |
| 49 | + // 提取提到的文件路径 | |
| 50 | + const files = extractMentionedFiles(lastOutput); | |
| 51 | + | |
| 52 | + // 计算持续时间 | |
| 53 | + const createdAt = new Date(meta.createdAt); | |
| 54 | + const updatedAt = new Date(meta.updatedAt || Date.now()); | |
| 55 | + const durationSec = Math.round((updatedAt.getTime() - createdAt.getTime()) / 1000); | |
| 56 | + const durationStr = durationSec > 60 | |
| 57 | + ? `${Math.round(durationSec / 60)}分钟` | |
| 58 | + : `${durationSec}秒`; | |
| 59 | + | |
| 60 | + // 构造摘要 | |
| 61 | + const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); | |
| 62 | + const summary = [ | |
| 63 | + `# Session 摘要`, | |
| 64 | + ``, | |
| 65 | + `- **时间**: ${timestamp}`, | |
| 66 | + `- **对话ID**: ${convId}`, | |
| 67 | + `- **状态**: ${meta.status || 'completed'}`, | |
| 68 | + `- **工具调用**: ${meta.stats?.toolCallCount || 0} 次`, | |
| 69 | + `- **耗时**: ${durationStr}`, | |
| 70 | + ``, | |
| 71 | + `## 任务`, | |
| 72 | + task || '(无明确任务描述)', | |
| 73 | + ``, | |
| 74 | + `## 结果`, | |
| 75 | + resultSummary || '(无输出)', | |
| 76 | + ]; | |
| 77 | + | |
| 78 | + if (files.length > 0) { | |
| 79 | + summary.push('', '## 涉及文件', ...files.map(f => `- ${f}`)); | |
| 80 | + } | |
| 81 | + | |
| 82 | + summary.push(''); | |
| 83 | + | |
| 84 | + // 写入文件 | |
| 85 | + const filename = `session-${Date.now()}.md`; | |
| 86 | + const filepath = path.join(shortTermDir, filename); | |
| 87 | + fs.writeFileSync(filepath, summary.join('\n'), 'utf-8'); | |
| 88 | + | |
| 89 | + // 清理过旧的 session 文件(保留最近 MAX_SHORT_TERM 个) | |
| 90 | + cleanupShortTerm(shortTermDir); | |
| 91 | + | |
| 92 | + // 追加关键事实到 memory.md | |
| 93 | + appendToMemoryMd(agentId, task, resultSummary, meta); | |
| 94 | + | |
| 95 | + console.log(`[Memory] ✅ Session 摘要已保存: ${agentId}/${filename}`); | |
| 96 | + return filepath; | |
| 97 | + } catch (err) { | |
| 98 | + console.error(`[Memory] ⚠️ Session 摘要保存失败:`, err.message); | |
| 99 | + return null; | |
| 100 | + } | |
| 101 | +} | |
| 102 | + | |
| 103 | +// ════════════════════════════════════════ | |
| 104 | +// 2. 新对话开始 → 加载近期记忆 | |
| 105 | +// ════════════════════════════════════════ | |
| 106 | + | |
| 107 | +/** | |
| 108 | + * 加载最近 N 个 session 摘要,用于注入 system prompt | |
| 109 | + * | |
| 110 | + * @param {string} agentId | |
| 111 | + * @returns {string} 格式化的近期记忆文本 | |
| 112 | + */ | |
| 113 | +function loadRecentMemory(agentId) { | |
| 114 | + try { | |
| 115 | + const shortTermDir = path.join(DATA_DIR, agentId, 'memory', 'short-term'); | |
| 116 | + if (!fs.existsSync(shortTermDir)) return ''; | |
| 117 | + | |
| 118 | + // 按文件名倒序(时间戳降序)取最近 N 个 | |
| 119 | + const files = fs.readdirSync(shortTermDir) | |
| 120 | + .filter(f => f.startsWith('session-') && f.endsWith('.md')) | |
| 121 | + .sort() | |
| 122 | + .reverse() | |
| 123 | + .slice(0, INJECT_SESSION_COUNT); | |
| 124 | + | |
| 125 | + if (files.length === 0) return ''; | |
| 126 | + | |
| 127 | + const sessions = files.map(f => { | |
| 128 | + const content = fs.readFileSync(path.join(shortTermDir, f), 'utf-8'); | |
| 129 | + return compactSession(content); | |
| 130 | + }); | |
| 131 | + | |
| 132 | + return [ | |
| 133 | + `# 近期记忆(最近 ${sessions.length} 次对话)`, | |
| 134 | + '', | |
| 135 | + ...sessions, | |
| 136 | + ].join('\n'); | |
| 137 | + } catch (err) { | |
| 138 | + console.error(`[Memory] ⚠️ 加载近期记忆失败:`, err.message); | |
| 139 | + return ''; | |
| 140 | + } | |
| 141 | +} | |
| 142 | + | |
| 143 | +// ════════════════════════════════════════ | |
| 144 | +// 3. memory.md 自动追加 | |
| 145 | +// ════════════════════════════════════════ | |
| 146 | + | |
| 147 | +/** | |
| 148 | + * 追加关键事实到 memory.md | |
| 149 | + * 只在有实质操作(toolCallCount > 0)时追加 | |
| 150 | + */ | |
| 151 | +function appendToMemoryMd(agentId, task, result, meta) { | |
| 152 | + if (!meta.stats?.toolCallCount || meta.stats.toolCallCount === 0) return; | |
| 153 | + | |
| 154 | + const memoryPath = path.join(DATA_DIR, agentId, 'memory', 'memory.md'); | |
| 155 | + let content = ''; | |
| 156 | + try { | |
| 157 | + content = fs.readFileSync(memoryPath, 'utf-8'); | |
| 158 | + } catch { /* 文件不存在 */ } | |
| 159 | + | |
| 160 | + const timestamp = new Date().toISOString().substring(0, 10); | |
| 161 | + const taskOneLine = (task || '').split('\n')[0].substring(0, 80); | |
| 162 | + const resultOneLine = (result || '').split('\n')[0].substring(0, 100); | |
| 163 | + | |
| 164 | + // 构造新条目 | |
| 165 | + const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; | |
| 166 | + | |
| 167 | + // 追加到"关键事实"段 | |
| 168 | + if (content.includes('## 关键事实')) { | |
| 169 | + content = content.replace( | |
| 170 | + /## 关键事实\n([\s\S]*?)(?=\n## |$)/, | |
| 171 | + (match, existing) => { | |
| 172 | + const trimmed = existing.trim(); | |
| 173 | + if (trimmed === '(尚无记录)') { | |
| 174 | + return `## 关键事实\n${entry}\n`; | |
| 175 | + } | |
| 176 | + return `## 关键事实\n${trimmed}\n${entry}\n`; | |
| 177 | + } | |
| 178 | + ); | |
| 179 | + } else { | |
| 180 | + // 没有"关键事实"段,追加到末尾 | |
| 181 | + content += `\n\n## 关键事实\n${entry}\n`; | |
| 182 | + } | |
| 183 | + | |
| 184 | + // 大小控制:超过限制时截断最早的条目 | |
| 185 | + if (content.length > MAX_MEMORY_MD_SIZE) { | |
| 186 | + content = truncateMemory(content); | |
| 187 | + } | |
| 188 | + | |
| 189 | + fs.writeFileSync(memoryPath, content, 'utf-8'); | |
| 190 | +} | |
| 191 | + | |
| 192 | +// ════════════════════════════════════════ | |
| 193 | +// 内部工具函数 | |
| 194 | +// ════════════════════════════════════════ | |
| 195 | + | |
| 196 | +/** 从 title 提取任务描述 */ | |
| 197 | +function extractTask(title, agentId, convId) { | |
| 198 | + // 去掉 [TASK from xxx] 前缀 | |
| 199 | + let task = title.replace(/\[TASK from \w+\]\s*/g, '').trim(); | |
| 200 | + // 去掉任务编号行 | |
| 201 | + task = task.replace(/任务编号:\s*T-\w+\s*/g, '').trim(); | |
| 202 | + task = task.replace(/优先级:\s*P\d+\s*/g, '').trim(); | |
| 203 | + | |
| 204 | + // 如果 title 太短,尝试从 messages.log 读取 | |
| 205 | + if (task.length < 10) { | |
| 206 | + try { | |
| 207 | + const logPath = path.join(DATA_DIR, agentId, 'conversations', convId, 'messages.log'); | |
| 208 | + const log = fs.readFileSync(logPath, 'utf-8'); | |
| 209 | + const userMsg = log.match(/\[USER\]\n([\s\S]*?)(?:\n\[AGENT\]|$)/); | |
| 210 | + if (userMsg) task = userMsg[1].trim().substring(0, 300); | |
| 211 | + } catch { /* ignore */ } | |
| 212 | + } | |
| 213 | + | |
| 214 | + return task.substring(0, 300); | |
| 215 | +} | |
| 216 | + | |
| 217 | +/** 从 Agent 输出提取结果摘要 */ | |
| 218 | +function extractResultSummary(output) { | |
| 219 | + if (!output) return ''; | |
| 220 | + // 优先取 [RESULT: DONE]...[/RESULT] 块 | |
| 221 | + const resultBlock = output.match(/\[RESULT:\s*DONE\]([\s\S]*?)\[\/RESULT\]/); | |
| 222 | + if (resultBlock) return resultBlock[1].trim().substring(0, 500); | |
| 223 | + | |
| 224 | + // 否则取最后 500 字 | |
| 225 | + const trimmed = output.trim(); | |
| 226 | + if (trimmed.length > 500) { | |
| 227 | + return '...' + trimmed.substring(trimmed.length - 500); | |
| 228 | + } | |
| 229 | + return trimmed; | |
| 230 | +} | |
| 231 | + | |
| 232 | +/** 提取输出中提到的文件路径 */ | |
| 233 | +function extractMentionedFiles(output) { | |
| 234 | + if (!output) return []; | |
| 235 | + // 匹配常见文件路径模式 | |
| 236 | + const patterns = [ | |
| 237 | + /(?:写入|创建|保存|输出)[到至::]\s*[`"']?([^\s`"']+\.\w+)/g, | |
| 238 | + /(?:data\/\w+\/[^\s]+)/g, | |
| 239 | + /(?:workspace\/[^\s]+)/g, | |
| 240 | + ]; | |
| 241 | + | |
| 242 | + const files = new Set(); | |
| 243 | + for (const pat of patterns) { | |
| 244 | + let m; | |
| 245 | + while ((m = pat.exec(output)) !== null) { | |
| 246 | + const f = m[1] || m[0]; | |
| 247 | + if (f.length > 5 && f.length < 200) files.add(f); | |
| 248 | + } | |
| 249 | + } | |
| 250 | + return [...files].slice(0, 10); // 最多 10 个 | |
| 251 | +} | |
| 252 | + | |
| 253 | +/** 压缩 session 内容为紧凑格式(用于 prompt 注入) */ | |
| 254 | +function compactSession(content) { | |
| 255 | + // 提取关键字段 | |
| 256 | + const time = content.match(/时间\**[::]\s*(.+)/)?.[1] || ''; | |
| 257 | + const status = content.match(/状态\**[::]\s*(.+)/)?.[1] || ''; | |
| 258 | + const tools = content.match(/工具调用\**[::]\s*(.+)/)?.[1] || ''; | |
| 259 | + const duration = content.match(/耗时\**[::]\s*(.+)/)?.[1] || ''; | |
| 260 | + | |
| 261 | + // 提取任务和结果段 | |
| 262 | + const taskMatch = content.match(/## 任务\n([\s\S]*?)(?=\n## |$)/); | |
| 263 | + const resultMatch = content.match(/## 结果\n([\s\S]*?)(?=\n## |$)/); | |
| 264 | + const filesMatch = content.match(/## 涉及文件\n([\s\S]*?)(?=\n## |$)/); | |
| 265 | + | |
| 266 | + const task = taskMatch?.[1]?.trim().substring(0, 100) || ''; | |
| 267 | + const result = resultMatch?.[1]?.trim().substring(0, 200) || ''; | |
| 268 | + const filesList = filesMatch?.[1]?.trim() || ''; | |
| 269 | + | |
| 270 | + const parts = [`### ${time} [${status}] (${tools}, ${duration})`]; | |
| 271 | + if (task) parts.push(`- 任务: ${task}`); | |
| 272 | + if (result) parts.push(`- 结果: ${result}`); | |
| 273 | + if (filesList) parts.push(`- 文件: ${filesList.replace(/\n/g, ', ').replace(/- /g, '')}`); | |
| 274 | + parts.push(''); | |
| 275 | + | |
| 276 | + return parts.join('\n'); | |
| 277 | +} | |
| 278 | + | |
| 279 | +/** 清理过旧的 short-term 文件 */ | |
| 280 | +function cleanupShortTerm(dir) { | |
| 281 | + try { | |
| 282 | + const files = fs.readdirSync(dir) | |
| 283 | + .filter(f => f.startsWith('session-') && f.endsWith('.md')) | |
| 284 | + .sort(); | |
| 285 | + | |
| 286 | + // 保留最近 MAX_SHORT_TERM 个 | |
| 287 | + while (files.length > MAX_SHORT_TERM) { | |
| 288 | + const oldest = files.shift(); | |
| 289 | + fs.unlinkSync(path.join(dir, oldest)); | |
| 290 | + } | |
| 291 | + } catch { /* ignore */ } | |
| 292 | +} | |
| 293 | + | |
| 294 | +/** memory.md 超大时截断最早条目 */ | |
| 295 | +function truncateMemory(content) { | |
| 296 | + const lines = content.split('\n'); | |
| 297 | + // 找到 ## 关键事实 段,删除最早的条目 | |
| 298 | + const factIdx = lines.findIndex(l => l.startsWith('## 关键事实')); | |
| 299 | + if (factIdx === -1) return content; | |
| 300 | + | |
| 301 | + // 找到条目行(以 - [ 开头) | |
| 302 | + const entries = []; | |
| 303 | + for (let i = factIdx + 1; i < lines.length; i++) { | |
| 304 | + if (lines[i].startsWith('## ')) break; | |
| 305 | + if (lines[i].startsWith('- [')) entries.push(i); | |
| 306 | + } | |
| 307 | + | |
| 308 | + // 删除最早的 1/3 条目 | |
| 309 | + const removeCount = Math.max(1, Math.floor(entries.length / 3)); | |
| 310 | + const removeIndices = new Set(entries.slice(0, removeCount)); | |
| 311 | + const newLines = lines.filter((_, i) => !removeIndices.has(i)); | |
| 312 | + | |
| 313 | + return newLines.join('\n'); | |
| 314 | +} | |
| 315 | + | |
| 316 | +module.exports = { | |
| 317 | + saveSessionSummary, | |
| 318 | + loadRecentMemory, | |
| 319 | + appendToMemoryMd, | |
| 320 | +}; | ... | ... |