From ef48bf0b161fcffeda90b7f5294d9e0097aadbca Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 25 Mar 2026 10:51:58 +0800 Subject: [PATCH] feat: implement Agent Memory Phase 1 MVP --- data/agents/ceo/memory/memory.md | 2 +- data/agents/ceo/todo.md | 1 + server/routes/agent-chat.js | 14 +++++++++++++- server/services/memory-service.js | 320 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 server/services/memory-service.js diff --git a/data/agents/ceo/memory/memory.md b/data/agents/ceo/memory/memory.md index 388601c..081d9e4 100644 --- a/data/agents/ceo/memory/memory.md +++ b/data/agents/ceo/memory/memory.md @@ -1,7 +1,7 @@ # 我的记忆 ## 关键事实 -(尚无记录) +- [2026-03-25] [TASK from master] → ## 战略规划的 3 个核心要点 ## 已验证的模式 (尚无记录) diff --git a/data/agents/ceo/todo.md b/data/agents/ceo/todo.md index 37d2062..61682bc 100644 --- a/data/agents/ceo/todo.md +++ b/data/agents/ceo/todo.md @@ -9,3 +9,4 @@ | T-mn4ajjuz | 【启动首个互动剧项目】NarrativeAI Inc. 公司架构、核心团队和运营流程已全部完成,现在正式启动首个互动剧项目。具体要求:1) 基于公司定位(AI互动剧平台)确定首个项目选题方向;2) 设计首个互动剧的题材、目标用户、核心卖点;3) 输出项目立项文档到 workspace/project_001_proposal.md,包含:项目名称、故事梗概、目标用户画像、核心互动机制、预期KPI、团队分工、里程碑计划;4) 完成后向我汇报 | ✅ 完成 | master-001 | P0 | 2026-03-24 07:26 | - | | 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 | - | | 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 | - | +| T-mn5g4hlb | 列出你知道的关于战略规划的3个核心要点,简要回答即可,不需要使用工具。 | ✅ 完成 | master | P1 | 2026-03-25 02:50 | - | diff --git a/server/routes/agent-chat.js b/server/routes/agent-chat.js index b04ff52..8bcf37a 100644 --- a/server/routes/agent-chat.js +++ b/server/routes/agent-chat.js @@ -20,6 +20,7 @@ const { ClaudeCliProtocol } = require('@mas/acp-core'); const { Parser } = require('../src/core/parser'); const AgentManager = require('../src/core/agent-manager'); const { GoalsService } = require('../services/goals'); +const { saveSessionSummary, loadRecentMemory } = require('../services/memory-service'); const systemConfig = require('../config'); const router = express.Router(); @@ -287,10 +288,14 @@ function buildSystemPrompt(agentDir) { const heartbeat = loadFile(agentDir, 'heartbeat.md'); if (heartbeat.trim()) parts.push(heartbeat.trim()); - // memory + // memory — 核心记忆索引 const memory = loadFile(agentDir, 'memory/memory.md'); if (memory.trim()) parts.push('---\n\n# 我的记忆\n\n' + memory.trim()); + // short-term — 近期对话摘要 (Phase 1 MVP) + const recentMemory = loadRecentMemory(config?.id || path.basename(agentDir)); + if (recentMemory) parts.push('---\n\n' + recentMemory); + // skills let mainSkill = loadFile(agentDir, '.claude/skills/main_skill/SKILL.md'); if (mainSkill.trim()) { @@ -1308,6 +1313,13 @@ ${resultsText.join('\n\n')} meta.status = 'completed'; saveMeta(agentId, convId, meta); + // ── Phase 1 MVP: 自动保存 Session 摘要到记忆 ── + try { + saveSessionSummary(agentId, convId, meta, fullOutput); + } catch (memErr) { + console.error('[Memory] Session 摘要保存失败:', memErr.message); + } + sendSSE('result', { conversationId: convId, agentId, messageId: msgId, output: fullOutput }); } catch (err) { diff --git a/server/services/memory-service.js b/server/services/memory-service.js new file mode 100644 index 0000000..948f065 --- /dev/null +++ b/server/services/memory-service.js @@ -0,0 +1,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, +}; -- libgit2 0.21.4