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,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 | +}; |