Commit ef48bf0b161fcffeda90b7f5294d9e0097aadbca

Authored by yibo
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
data/agents/ceo/memory/memory.md
1 # 我的记忆 1 # 我的记忆
2 2
3 ## 关键事实 3 ## 关键事实
4 -(尚无记录) 4 +- [2026-03-25] [TASK from master] → ## 战略规划的 3 个核心要点
5 5
6 ## 已验证的模式 6 ## 已验证的模式
7 (尚无记录) 7 (尚无记录)
data/agents/ceo/todo.md
@@ -9,3 +9,4 @@ @@ -9,3 +9,4 @@
9 | T-mn4ajjuz | 【启动首个互动剧项目】NarrativeAI Inc. 公司架构、核心团队和运营流程已全部完成,现在正式启动首个互动剧项目。具体要求:1) 基于公司定位(AI互动剧平台)确定首个项目选题方向;2) 设计首个互动剧的题材、目标用户、核心卖点;3) 输出项目立项文档到 workspace/project_001_proposal.md,包含:项目名称、故事梗概、目标用户画像、核心互动机制、预期KPI、团队分工、里程碑计划;4) 完成后向我汇报 | ✅ 完成 | master-001 | P0 | 2026-03-24 07:26 | - | 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 | 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 | - | 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 | 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 | - | 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,6 +20,7 @@ const { ClaudeCliProtocol } = require('@mas/acp-core');
20 const { Parser } = require('../src/core/parser'); 20 const { Parser } = require('../src/core/parser');
21 const AgentManager = require('../src/core/agent-manager'); 21 const AgentManager = require('../src/core/agent-manager');
22 const { GoalsService } = require('../services/goals'); 22 const { GoalsService } = require('../services/goals');
  23 +const { saveSessionSummary, loadRecentMemory } = require('../services/memory-service');
23 const systemConfig = require('../config'); 24 const systemConfig = require('../config');
24 const router = express.Router(); 25 const router = express.Router();
25 26
@@ -287,10 +288,14 @@ function buildSystemPrompt(agentDir) { @@ -287,10 +288,14 @@ function buildSystemPrompt(agentDir) {
287 const heartbeat = loadFile(agentDir, 'heartbeat.md'); 288 const heartbeat = loadFile(agentDir, 'heartbeat.md');
288 if (heartbeat.trim()) parts.push(heartbeat.trim()); 289 if (heartbeat.trim()) parts.push(heartbeat.trim());
289 290
290 - // memory 291 + // memory — 核心记忆索引
291 const memory = loadFile(agentDir, 'memory/memory.md'); 292 const memory = loadFile(agentDir, 'memory/memory.md');
292 if (memory.trim()) parts.push('---\n\n# 我的记忆\n\n' + memory.trim()); 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 // skills 299 // skills
295 let mainSkill = loadFile(agentDir, '.claude/skills/main_skill/SKILL.md'); 300 let mainSkill = loadFile(agentDir, '.claude/skills/main_skill/SKILL.md');
296 if (mainSkill.trim()) { 301 if (mainSkill.trim()) {
@@ -1308,6 +1313,13 @@ ${resultsText.join('\n\n')} @@ -1308,6 +1313,13 @@ ${resultsText.join('\n\n')}
1308 meta.status = 'completed'; 1313 meta.status = 'completed';
1309 saveMeta(agentId, convId, meta); 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 sendSSE('result', { conversationId: convId, agentId, messageId: msgId, output: fullOutput }); 1323 sendSSE('result', { conversationId: convId, agentId, messageId: msgId, output: fullOutput });
1312 1324
1313 } catch (err) { 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 +};