/** * MemoryService — Agent 自动记忆读写 * * Phase 1 (MVP): * - 对话结束 → 自动生成 session 摘要 → short-term/ * - 对话结束 → 关键事实追加 → memory.md * - 新对话开始 → 加载最近 N 个 session 摘要注入 prompt * * Phase 2: * - 海马体关键词匹配 → 检索相关小脑方案 * - 小脑方案固化 → cerebellum/ 存储成功方案 * - 海马体索引自动更新 */ const fs = require('fs'); const path = require('path'); // Agent 目录寻址收编进 agent-locator(28 号设计 §2.5)。 const locator = require('./agent-locator'); /** * 记忆与会话的落点:公司态目录 `system/agents/{aid}/{cid}/`(28 §4.1,批次 3 第 13 条)。 * 记忆归属 (agentId, companyId) —— 同一个本体在两家公司各记各的,不共享; * 没有公司的调用(master 等)落 `_global`,存量目录由 scripts/migrate-agent-registry.js 搬过去。 */ function getAgentDir(id) { return locator.companyStateDir(id); } const MAX_SHORT_TERM = 10; // 最多保留最近 N 个 session 摘要 const MAX_MEMORY_MD_SIZE = 3000; // memory.md 最大字符数 const INJECT_SESSION_COUNT = 5; // prompt 注入最近 N 个 session const HIPPOCAMPUS_MIN_SCORE = 0.3; // 海马体匹配最低分 const MAX_CEREBELLUM_INJECT = 2; // 最多注入 N 个小脑方案 // ════════════════════════════════════════ // 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 = getAgentDir(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 * @param {string} [agentDir] 记忆所在目录。执行链路(prompt-builder)已经从 * agent-locator 算出了公司态目录,直接传进来省一次计算;不传则由 getAgentDir * 算出同一个目录(两者都走 locator.companyStateDir,算不出同一个的表现是 * 「记忆是空的」而不是报错)。 * @returns {string} 格式化的近期记忆文本 */ function loadRecentMemory(agentId, agentDir) { try { const shortTermDir = path.join(agentDir || getAgentDir(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 自动追加 // ════════════════════════════════════════ /** 「原始记录」段的标题。dream 的提炼**只读这一段**,`## 关键事实` 留给提炼后的产物。 */ const RAW_SECTION = '## 原始记录(未提炼)'; /** * 一条会话记录值不值得留? * * 过滤掉三类**确定没有信息量**的: * · 结果只是一个工具调用标记(`🔧 [TOOL_CALL] Bash (ID: toolu_...)`)—— * 实测 `cto/_global/memory/memory.md` 里三条「关键事实」全是这个; * · 结果为空 / 只有省略号; * · 任务本身只有一个心跳标记、没有正文。 * * 判据刻意窄:**只滤明确的噪声,不猜「哪条重要」**。猜重要性需要理解内容, * 那正是 dream 该干的活(29-DREAM 决策 A),在这里做等于把提炼下放到一个正则里。 * * ⚠️ 初版还有一条「任务与结果都短于 8 个字符就丢」,**已删**: * 那是按英文长度想出来的阈值,中文 8 个字是一句完整的话(「结果甲甲甲甲甲」7 字直接被丢)。 * 凡是拿长度当信息量代理的判据,都要先问一句「这是按哪种文字算的」。 */ function isNoiseEntry(task, result) { const r = (result || '').trim(); if (!r || /^[..…]+$/.test(r)) return true; if (/^🔧?\s*\[TOOL_CALL\]/.test(r)) return true; if (/^\[?(SYSTEM HEARTBEAT|HEARTBEAT)\]?$/i.test((task || '').trim())) return true; return false; } /** * 把一次会话记进 memory.md 的**原始记录**段。 * * ── 这个函数原来干的事,以及为什么改 ──────────────────────────────── * 原实现取任务与结果的**第一行、砍到 80/100 字符**拼成一条,写进 `## 关键事实`: * * const entry = `- [${ts}] ${task.split('\n')[0].slice(0,80)} → ${result...slice(0,100)}`; * * 那不是提炼,是搬运 + 截断。实测产物(`cto/_global/memory/memory.md`): * * - [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh...) * * **工具调用 id 被当成了「关键事实」**,而 memory.md 会被注入每一轮提示词。 * 28 §5.6b 点名的错误版本是「自动把原文写进共享 SKILL.md」—— * 这套已经是那个错误的单公司版,只是还没跨公司共享所以还没造成泄漏。 * * ── 改成什么 ──────────────────────────────────────────────────────── * 1. **换个段**:写进「原始记录(未提炼)」,不再冒充「关键事实」。 * `## 关键事实` 从此**只由提炼产物填**(dream,29-DREAM)—— * 读的人和 agent 都能一眼分清「这是日志」还是「这是结论」。 * 2. **滤掉确定的噪声**(isNoiseEntry),但不猜重要性 —— 猜重要性是 dream 的活。 * 3. 不再截断成半句:留完整的第一行(上限放宽到 200),截断残渣读起来像事实、 * 其实缺主语,比不留更坏。 * * 这一段同时是 **dream 的输入面**:它要提炼的就是这一段。 */ function appendToMemoryMd(agentId, task, result, meta) { if (!meta.stats?.toolCallCount || meta.stats.toolCallCount === 0) return; if (isNoiseEntry(task, result)) return; const memoryPath = path.join(getAgentDir(agentId), 'memory', 'memory.md'); let content = ''; try { content = fs.readFileSync(memoryPath, 'utf-8'); } catch { /* 文件不存在 */ } const timestamp = new Date().toISOString().substring(0, 10); const firstLine = (t) => (t || '').split('\n').map((x) => x.trim()).find(Boolean) || ''; const taskLine = firstLine(task).substring(0, 200); const resultLine = firstLine(result).substring(0, 200); const entry = `- [${timestamp}] ${taskLine} → ${resultLine}`; if (content.includes(RAW_SECTION)) { content = content.replace( new RegExp(`${RAW_SECTION}\\n([\\s\\S]*?)(?=\\n## |$)`), (match, existing) => `${RAW_SECTION}\n${existing.trim() ? `${existing.trim()}\n` : ''}${entry}\n` ); } else { // 放在末尾:「关键事实 / 已验证的模式 / 失败教训」是提炼产物的位置,原始记录排在它们后面 content = `${content.replace(/\s*$/, '')}\n\n${RAW_SECTION}\n${entry}\n`; } // 大小控制:超过限制时截断最早的条目 if (content.length > MAX_MEMORY_MD_SIZE) { content = truncateMemory(content); } fs.writeFileSync(memoryPath, content, 'utf-8'); } // ════════════════════════════════════════ // 4. Phase 2: 海马体检索 (Hippocampus) // ════════════════════════════════════════ /** * 根据任务文本检索海马体,返回匹配的小脑方案 * * @param {string} agentId * @param {string} taskText - 用户的任务文本 * @returns {string} 格式化的小脑方案文本(用于注入 prompt) */ function retrieveRelevantPlans(agentId, taskText) { try { const agentDir = getAgentDir(agentId); const index = loadHippocampusIndex(agentDir); if (index.length === 0) return ''; // 从任务文本提取关键词 const taskKeywords = extractKeywords(taskText); if (taskKeywords.length === 0) return ''; // 计算每个索引条目的匹配分数 const scored = index.map(entry => { const entryKeywords = (entry.keywords || []).map(k => k.toLowerCase()); const hits = taskKeywords.filter(kw => entryKeywords.some(ek => ek.includes(kw) || kw.includes(ek)) ); const score = entryKeywords.length > 0 ? hits.length / entryKeywords.length // 命中了 entry 的多少比例关键词 : 0; // console.log(`[HippocampusDebug] agent=${agentId} scenario="${entry.scenario}" score=${score} hits=${hits.length} taskKwLength=${taskKeywords.length} entryKws=${entryKeywords.length}`); return { ...entry, score, hits }; }); // 过滤 + 排序 const matches = scored .filter(e => e.score >= HIPPOCAMPUS_MIN_SCORE) .sort((a, b) => { // 先按分数,再按置信度,最后按使用次数 if (b.score !== a.score) return b.score - a.score; if ((b.confidence || 0) !== (a.confidence || 0)) return (b.confidence || 0) - (a.confidence || 0); return (b.useCount || 0) - (a.useCount || 0); }) .slice(0, MAX_CEREBELLUM_INJECT); if (matches.length === 0) return ''; // 加载对应的小脑方案文件 const cerebellumDir = path.join(agentDir, 'memory', 'cerebellum'); const plans = []; for (const match of matches) { const planPath = path.join(cerebellumDir, match.planFile); if (fs.existsSync(planPath)) { const planContent = fs.readFileSync(planPath, 'utf-8').trim(); plans.push(`### 经验方案: ${match.scenario} (置信度 ${Math.round((match.confidence || 0) * 100)}%, 使用 ${match.useCount || 0} 次)\n\n${planContent}`); // 更新原始索引中的使用次数 + 最后使用时间 const origEntry = index.find(e => e.planFile === match.planFile); if (origEntry) { origEntry.useCount = (origEntry.useCount || 0) + 1; origEntry.lastUsed = new Date().toISOString().substring(0, 10); } } } if (plans.length > 0) { // 保存更新后的使用统计 saveHippocampusIndex(agentDir, index); console.log(`[Memory] 🐴 海马体命中 ${plans.length} 个方案 (agent: ${agentId})`); } if (plans.length === 0) return ''; return [ `# 经验参考(海马体检索)`, '', '> 以下是你过去处理类似任务的成功经验。可以参考,但不必完全照搬。', '', ...plans, ].join('\n'); } catch (err) { console.error(`[Memory] ⚠️ 海马体检索失败:`, err.message); return ''; } } // ════════════════════════════════════════ // 5. Phase 2: 小脑固化 (Cerebellum) // ════════════════════════════════════════ /** * 保存一个成功方案到小脑,并更新海马体索引 * * @param {string} agentId * @param {object} plan - { scenario, keywords, steps, notes } * @returns {string|null} 保存成功返回文件路径 */ function saveCerebellumPlan(agentId, plan) { try { const agentDir = getAgentDir(agentId); const cerebellumDir = path.join(agentDir, 'memory', 'cerebellum'); if (!fs.existsSync(cerebellumDir)) { fs.mkdirSync(cerebellumDir, { recursive: true }); } // 生成文件名: 基于 scenario 的 slug const slug = (plan.scenario || 'plan') .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-') .replace(/^-|-$/g, '') .substring(0, 40); const filename = `${slug}.md`; const filepath = path.join(cerebellumDir, filename); // 构造方案内容 const content = [ `# 方案: ${plan.scenario}`, '', `> 创建时间: ${new Date().toISOString().substring(0, 10)}`, '', ]; if (plan.conditions) { content.push('## 适用条件', ...plan.conditions.map(c => `- ${c}`), ''); } if (plan.steps) { content.push('## 执行步骤', ...plan.steps.map((s, i) => `${i + 1}. ${s}`), ''); } if (plan.notes) { content.push('## 注意事项', ...plan.notes.map(n => `- ${n}`), ''); } if (plan.rawContent) { content.push('## 详细内容', plan.rawContent, ''); } fs.writeFileSync(filepath, content.join('\n'), 'utf-8'); // 更新海马体索引 const index = loadHippocampusIndex(agentDir); // 检查是否已有同名条目 const existing = index.findIndex(e => e.planFile === filename); const entry = { scenario: plan.scenario, keywords: plan.keywords || [], planFile: filename, confidence: plan.confidence || 0.7, useCount: 0, lastUsed: null, createdAt: new Date().toISOString().substring(0, 10), }; if (existing >= 0) { // 更新已有条目(保留使用次数) entry.useCount = index[existing].useCount || 0; entry.lastUsed = index[existing].lastUsed; index[existing] = entry; } else { index.push(entry); } saveHippocampusIndex(agentDir, index); // 同步更新 hippocampus.md (人类可读版) syncHippocampusMd(agentDir, index); console.log(`[Memory] 🧩 小脑方案已固化: ${agentId}/cerebellum/${filename}`); return filepath; } catch (err) { console.error(`[Memory] ⚠️ 小脑方案保存失败:`, err.message); return null; } } // ════════════════════════════════════════ // 海马体索引 JSON 读写 // ════════════════════════════════════════ /** * 加载海马体索引 (JSON 格式,比 md 表格更可靠) * 文件: memory/hippocampus-index.json */ function loadHippocampusIndex(agentDir) { const indexPath = path.join(agentDir, 'memory', 'hippocampus-index.json'); try { return JSON.parse(fs.readFileSync(indexPath, 'utf-8')); } catch { return []; } } /** 保存海马体索引 */ function saveHippocampusIndex(agentDir, index) { const indexPath = path.join(agentDir, 'memory', 'hippocampus-index.json'); const dir = path.dirname(indexPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf-8'); } /** * 同步 hippocampus.md — 人类可读的 markdown 表格 * (JSON 是程序真正使用的,md 只是给人看) */ function syncHippocampusMd(agentDir, index) { const mdPath = path.join(agentDir, 'memory', 'hippocampus.md'); const rows = index.map((e, i) => `| ${i + 1} | ${e.scenario} | ${(e.keywords || []).join(', ')} | ${e.planFile} | ${e.confidence} | ${e.useCount || 0} | ${e.lastUsed || '-'} |` ); const content = [ '# 海马体 — 经验记忆索引', '', '> 记录过往处理过的情景模式。遇到相似任务时,优先调用小脑中的固化方案。', '> ⚠️ 此文件由系统自动生成,勿手动编辑。真实数据在 hippocampus-index.json。', '', '| # | 情景模式 | 匹配关键词 | 方案文件 | 置信度 | 使用次数 | 上次使用 |', '|---|---------|-----------|---------|--------|---------|----------|', ...(rows.length > 0 ? rows : ['| | (暂无经验记录) | | | | | |']), '', ].join('\n'); fs.writeFileSync(mdPath, content, 'utf-8'); } // ════════════════════════════════════════ // 关键词提取 // ════════════════════════════════════════ /** 中文停用词表(常见无意义词) */ const STOP_WORDS = new Set([ '的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都', '一', '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', '看', '好', '自己', '这', '他', '她', '它', '那', '些', '所以', '因为', '但是', '而且', '或者', '如果', '虽然', '所有', '可以', '应该', '需要', '能够', '已经', '正在', '请', '把', '被', '让', '给', '从', '对', '将', '于', '之', '等', '以', '及', '与', 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'can', 'shall', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'about', 'and', 'or', 'but', 'not', 'this', 'that', 'it', 'its', 'my', 'your', 'his', 'her', ]); /** * 从文本中提取关键词 * 取前 1500 字符,优先处理中文分词(重要任务内容),再处理英文,避免英文占位符号挤占中文。 */ function extractKeywords(text) { if (!text || text.length < 2) return []; text = text.trim().substring(0, 1500); // 截断避免太长 const keywords = new Set(); // 1. 中文:提取连续中文字符序列,再分割成 2-4 字的 ngram const chineseSegments = text.match(/[\u4e00-\u9fff]{2,}/g) || []; for (const seg of chineseSegments) { if (seg.length <= 6 && !STOP_WORDS.has(seg)) keywords.add(seg); for (let i = 0; i <= seg.length - 2; i++) { const bi = seg.substring(i, i + 2); if (!STOP_WORDS.has(bi)) keywords.add(bi); } if (seg.length >= 3) { for (let i = 0; i <= seg.length - 3; i++) { const tri = seg.substring(i, i + 3); if (!STOP_WORDS.has(tri)) keywords.add(tri); } } } // 2. 英文词 (≥2字母) const englishWords = text.match(/[a-zA-Z]{2,}/g) || []; englishWords.forEach(w => { const lower = w.toLowerCase(); if (!STOP_WORDS.has(lower) && lower.length >= 3) keywords.add(lower); }); return [...keywords].slice(0, 50); // 最多 50 个关键词 } // ════════════════════════════════════════ // Phase 1 内部工具函数 // ════════════════════════════════════════ /** 从 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(getAgentDir(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'); } // ════════════════════════════════════════ // 6. Phase 3: 自动反思引擎 (Reflect) // ════════════════════════════════════════ const REFLECT_MIN_TOOLS = 3; // 至少 N 个 tool call 才触发反思 const REFLECT_MIN_DURATION = 60; // 或至少持续 N 秒 const SHORT_TERM_MAX_AGE_DAYS = 7; // short-term 超过 N 天合并到 long-term /** * 自动反思:分析对话结果,提取可复用的模式和教训 * 无需额外 LLM 调用 — 纯规则提取 * * @param {string} agentId * @param {string} convId * @param {object} meta - { status, stats, createdAt, updatedAt, title } * @param {string} output - Agent 最终输出 * @returns {object|null} 反思结果 { patterns, lessons, plan } */ function autoReflect(agentId, convId, meta, output) { try { const toolCount = meta.stats?.toolCallCount || 0; const createdAt = new Date(meta.createdAt); const updatedAt = new Date(meta.updatedAt || Date.now()); const durationSec = Math.round((updatedAt.getTime() - createdAt.getTime()) / 1000); // 判断是否值得反思 if (toolCount < REFLECT_MIN_TOOLS && durationSec < REFLECT_MIN_DURATION) { return null; // 太简单的对话不反思 } const agentDir = getAgentDir(agentId); const task = extractTask(meta.title || '', agentId, convId); const isSuccess = meta.status === 'completed'; const isFailed = meta.status === 'failed'; const reflection = { timestamp: new Date().toISOString(), convId, task: task.substring(0, 200), status: meta.status, toolCount, durationSec, patterns: [], lessons: [], plan: null, }; // ── 提取模式 (成功对话) ── if (isSuccess && toolCount >= 3) { // 检测: 文件创建模式 const createdFiles = extractMentionedFiles(output); if (createdFiles.length > 0) { reflection.patterns.push(`成功创建/修改了 ${createdFiles.length} 个文件: ${createdFiles.slice(0, 3).join(', ')}`); } // 检测: 多步骤执行模式 if (toolCount >= 5) { reflection.patterns.push(`完成了 ${toolCount} 步工具调用的复杂任务 (${durationSec}秒)`); } // 检测: dispatch 任务模式 if (output.includes('dispatch') || output.includes('task/dispatch')) { reflection.patterns.push('涉及向其他 Agent 分发子任务'); } // 检测: 训练/进化模式 if (output.includes('benchmark') || output.includes('score') || output.includes('evolve')) { reflection.patterns.push('涉及技能训练/评分/进化流程'); } } // ── 提取教训 (失败对话) ── if (isFailed) { const errorMatch = output.match(/(?:error|错误|失败|Error)[::]\s*(.{10,200})/i); if (errorMatch) { reflection.lessons.push(`错误: ${errorMatch[1].trim()}`); } reflection.lessons.push(`任务失败 (${toolCount} tool calls, ${durationSec}秒): ${task.substring(0, 100)}`); } // ── 自动固化方案 (成功 + 高复杂度) ── if (isSuccess && toolCount >= 5) { // 从任务和输出中提取关键词作为场景 const keywords = extractKeywords(task); const topKeywords = keywords.slice(0, 8); // 尝试从输出中提取步骤 const steps = extractStepsFromOutput(output); if (steps.length >= 2 && topKeywords.length >= 2) { const scenario = task.split('\n')[0].substring(0, 50); reflection.plan = { scenario, keywords: topKeywords, steps, confidence: Math.min(0.6 + (toolCount * 0.02), 0.9), // 工具越多,置信度越高 }; // 自动保存到小脑 saveCerebellumPlan(agentId, { ...reflection.plan, conditions: [`类似任务: ${scenario}`], notes: [`自动从对话 ${convId} 提取`, `耗时 ${durationSec}秒, ${toolCount} 次工具调用`], }); } } // ── 写入反思日志 ── if (reflection.patterns.length > 0 || reflection.lessons.length > 0) { writeReflectionLog(agentDir, reflection); // 更新 memory.md 的"已验证模式"和"失败教训"段 updateMemoryMdSections(agentId, reflection); // ── Know-how 自动更新: 将经验教训写回 SKILL.md ── updateAgentKnowhow(agentId, reflection); console.log(`[Memory] 🪞 反思完成: ${agentId} (${reflection.patterns.length} 模式, ${reflection.lessons.length} 教训${reflection.plan ? ', +1 方案固化' : ''})`); } return reflection; } catch (err) { console.error(`[Memory] ⚠️ 反思失败:`, err.message); return null; } } /** 从输出中提取步骤(通过数字列表匹配) */ function extractStepsFromOutput(output) { if (!output) return []; // 匹配 "1. xxx" "2. xxx" 格式的步骤 const stepMatches = output.match(/^\d+\.\s+.{5,100}$/gm) || []; return stepMatches.slice(0, 8).map(s => s.replace(/^\d+\.\s+/, '').trim()); } /** 写入反思日志文件 */ function writeReflectionLog(agentDir, reflection) { const logDir = path.join(agentDir, 'memory', 'reflections'); if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true }); const filename = `reflect-${Date.now()}.json`; fs.writeFileSync(path.join(logDir, filename), JSON.stringify(reflection, null, 2), 'utf-8'); // 只保留最近 20 个反思日志 const files = fs.readdirSync(logDir).filter(f => f.startsWith('reflect-')).sort(); while (files.length > 20) { fs.unlinkSync(path.join(logDir, files.shift())); } } /** 更新 memory.md 的 "已验证模式" 和 "失败教训" 段 */ function updateMemoryMdSections(agentId, reflection) { const memoryPath = path.join(getAgentDir(agentId), 'memory', 'memory.md'); let content = ''; try { content = fs.readFileSync(memoryPath, 'utf-8'); } catch { return; } const date = new Date().toISOString().substring(0, 10); // 更新"已验证的模式" if (reflection.patterns.length > 0) { const patternEntries = reflection.patterns.map(p => `- [${date}] ${p}`).join('\n'); if (content.includes('## 已验证的模式')) { content = content.replace( /## 已验证的模式\n([\s\S]*?)(?=\n## |$)/, (match, existing) => { const trimmed = existing.trim(); if (trimmed === '(尚无记录)') return `## 已验证的模式\n${patternEntries}\n`; return `## 已验证的模式\n${trimmed}\n${patternEntries}\n`; } ); } } // 更新"失败教训" if (reflection.lessons.length > 0) { const lessonEntries = reflection.lessons.map(l => `- [${date}] ${l}`).join('\n'); if (content.includes('## 失败教训')) { content = content.replace( /## 失败教训\n([\s\S]*?)(?=\n## |$)/, (match, existing) => { const trimmed = existing.trim(); if (trimmed === '(尚无记录)') return `## 失败教训\n${lessonEntries}\n`; return `## 失败教训\n${trimmed}\n${lessonEntries}\n`; } ); } } // 大小控制 if (content.length > MAX_MEMORY_MD_SIZE) { content = truncateMemory(content); } fs.writeFileSync(memoryPath, content, 'utf-8'); } // ════════════════════════════════════════ // 7. Phase 3: 记忆合并 (Consolidate) // ════════════════════════════════════════ /** * 合并过期的 short-term 到 long-term * 适合通过 CronCreate 每天凌晨调用 * * @param {string} agentId * @returns {object} 合并统计 */ function consolidateMemory(agentId) { try { const agentDir = getAgentDir(agentId); const shortTermDir = path.join(agentDir, 'memory', 'short-term'); const longTermDir = path.join(agentDir, 'memory', 'long-term'); if (!fs.existsSync(shortTermDir)) return { merged: 0 }; if (!fs.existsSync(longTermDir)) fs.mkdirSync(longTermDir, { recursive: true }); const now = Date.now(); const maxAge = SHORT_TERM_MAX_AGE_DAYS * 24 * 60 * 60 * 1000; const files = fs.readdirSync(shortTermDir) .filter(f => f.startsWith('session-') && f.endsWith('.md')) .sort(); // 找出过期文件 const expired = files.filter(f => { const ts = parseInt(f.replace('session-', '').replace('.md', '')); return !isNaN(ts) && (now - ts) > maxAge; }); if (expired.length === 0) return { merged: 0 }; // 读取所有过期文件内容 const summaries = expired.map(f => { const content = fs.readFileSync(path.join(shortTermDir, f), 'utf-8'); return compactSession(content); }); // 提取反复出现的关键词 (简单频率统计) const allKeywords = []; for (const s of summaries) { allKeywords.push(...extractKeywords(s)); } const kwFreq = {}; allKeywords.forEach(kw => { kwFreq[kw] = (kwFreq[kw] || 0) + 1; }); const frequentKW = Object.entries(kwFreq) .filter(([_, count]) => count >= 2) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([kw, count]) => `${kw} (x${count})`); // 写入 long-term 合并记录 const consolidateDate = new Date().toISOString().substring(0, 10); const ltContent = [ `# 长期记忆合并 — ${consolidateDate}`, '', `> 合并了 ${expired.length} 个过期 session (>${SHORT_TERM_MAX_AGE_DAYS} 天)`, '', frequentKW.length > 0 ? `## 高频关键词\n${frequentKW.map(k => `- ${k}`).join('\n')}` : '', '', '## 合并的对话摘要', '', ...summaries, ].filter(Boolean).join('\n'); const ltFilename = `consolidate-${consolidateDate}.md`; fs.writeFileSync(path.join(longTermDir, ltFilename), ltContent, 'utf-8'); // 更新 long-term/patterns.md (追加高频模式) if (frequentKW.length > 0) { const patternsPath = path.join(longTermDir, 'patterns.md'); let patterns = ''; try { patterns = fs.readFileSync(patternsPath, 'utf-8'); } catch {} if (!patterns) patterns = '# 长期模式\n\n> 从 short-term 多次出现的模式沉淀而来\n\n'; patterns += `\n## ${consolidateDate}\n${frequentKW.map(k => `- ${k}`).join('\n')}\n`; fs.writeFileSync(patternsPath, patterns, 'utf-8'); } // 删除已合并的 short-term 文件 for (const f of expired) { fs.unlinkSync(path.join(shortTermDir, f)); } console.log(`[Memory] 📦 记忆合并: ${agentId} (${expired.length} sessions → long-term/${ltFilename})`); return { merged: expired.length, file: ltFilename, keywords: frequentKW }; } catch (err) { console.error(`[Memory] ⚠️ 记忆合并失败:`, err.message); return { merged: 0, error: err.message }; } } // ════════════════════════════════════════ // 8. Phase 3: 日报生成 (Daily Report) // ════════════════════════════════════════ /** * 生成当天的日报摘要 * * @param {string} agentId * @returns {string|null} 日报文件路径 */ function generateDailyReport(agentId) { try { const agentDir = getAgentDir(agentId); const shortTermDir = path.join(agentDir, 'memory', 'short-term'); const dailyDir = path.join(agentDir, 'memory', 'daily'); const reflectDir = path.join(agentDir, 'memory', 'reflections'); if (!fs.existsSync(dailyDir)) fs.mkdirSync(dailyDir, { recursive: true }); const today = new Date().toISOString().substring(0, 10); // 收集今天的 session 摘要 const todaySessions = []; if (fs.existsSync(shortTermDir)) { const files = fs.readdirSync(shortTermDir).filter(f => f.startsWith('session-')); for (const f of files) { const content = fs.readFileSync(path.join(shortTermDir, f), 'utf-8'); if (content.includes(today)) { todaySessions.push(compactSession(content)); } } } // 收集今天的反思 const todayReflections = []; if (fs.existsSync(reflectDir)) { const files = fs.readdirSync(reflectDir).filter(f => f.startsWith('reflect-')); for (const f of files) { try { const r = JSON.parse(fs.readFileSync(path.join(reflectDir, f), 'utf-8')); if (r.timestamp && r.timestamp.startsWith(today)) { todayReflections.push(r); } } catch {} } } if (todaySessions.length === 0 && todayReflections.length === 0) { return null; // 今天没活动 } // 统计 const totalPatterns = todayReflections.reduce((sum, r) => sum + (r.patterns?.length || 0), 0); const totalLessons = todayReflections.reduce((sum, r) => sum + (r.lessons?.length || 0), 0); const totalPlans = todayReflections.filter(r => r.plan).length; const report = [ `# 日报 — ${today}`, '', `## 概览`, `- 对话数: ${todaySessions.length}`, `- 反思数: ${todayReflections.length}`, `- 新模式: ${totalPatterns}`, `- 新教训: ${totalLessons}`, `- 固化方案: ${totalPlans}`, '', ]; if (todaySessions.length > 0) { report.push('## 对话记录', '', ...todaySessions); } if (todayReflections.length > 0) { report.push('## 反思摘要', ''); for (const r of todayReflections) { if (r.patterns?.length) report.push(...r.patterns.map(p => `- ✅ ${p}`)); if (r.lessons?.length) report.push(...r.lessons.map(l => `- ❌ ${l}`)); } report.push(''); } const reportPath = path.join(dailyDir, `${today}.md`); fs.writeFileSync(reportPath, report.join('\n'), 'utf-8'); console.log(`[Memory] 📰 日报已生成: ${agentId}/daily/${today}.md`); return reportPath; } catch (err) { console.error(`[Memory] ⚠️ 日报生成失败:`, err.message); return null; } } // ════════════════════════════════════════ // 9. Phase 3: Know-how 自动更新 // ════════════════════════════════════════ /** * 将反思产出的经验教训沉淀到**公司态** `{cid}/memory/knowhow.md` * (文件里维护一个 "## 经验教训 (自动沉淀)" 段落) * * 为什么不再写本体的 `.claude/skills/main_skill/SKILL.md`(这里原先走 bodyDir): * 批次 1 合并之后本体是**全系统唯一一份**,把某一家公司的对话内容(错误原文 + * 任务标题)写进去,等于让所有公司调这个 agent 时都读到它 —— 合并前每家公司各拷 * 一份本体,泄漏面是 1 家;合并后是全系统。而这条链路是 agent-chat 每轮对话收尾 * 无条件调的,写入还被 try/catch 包着,全程不报错。本体又是 git 跟踪的,改它还会让 * graph-engine 的边界守卫把**别人**正在跑的节点判成 boundary_escape。 * * 所以:运行期本体一律只读,唯一允许写本体的是 evolver / agent-creator 那条显式的 * 进化链路。落在 memory/ 下而不是公司态根目录,是因为 memory-service 写的所有东西 * (memory.md / short-term / cerebellum / reflections / daily)都在 memory/ 里, * knowhow 也是记忆的一种,摆到根上会让「这个 agent 的记忆在哪」有两个答案。 * * @param {string} agentId - 寻址名(`cid:aid` 或裸 aid,后者落 `_global`) * @param {object} reflection - { patterns: string[], lessons: string[] } */ function updateAgentKnowhow(agentId, reflection) { try { if (!reflection.patterns.length && !reflection.lessons.length) return; const knowhowPath = path.join(getAgentDir(agentId), 'memory', 'knowhow.md'); // 读不到就当首次沉淀:公司态目录是空起步的,没有「文件不在就跳过」的余地 let content = ''; try { content = fs.readFileSync(knowhowPath, 'utf-8'); } catch { /* 首次沉淀 */ } const date = new Date().toISOString().substring(0, 10); // 构建新条目 const entries = []; for (const p of reflection.patterns) { entries.push(`- ✅ [${date}] ${p}`); } for (const l of reflection.lessons) { entries.push(`- ⚠️ [${date}] ${l}`); } const newBlock = entries.join('\n'); // 查找或创建 "经验教训" 段 const SECTION_HEADER = '## 经验教训 (自动沉淀)'; if (content.includes(SECTION_HEADER)) { // 追加到已有段落 content = content.replace( new RegExp(`(${SECTION_HEADER.replace(/[()]/g, '\\$&')}\\n)([\\s\\S]*?)(?=\\n## |$)`), (match, header, existing) => { const trimmed = existing.trim(); // 限制最大条目数(保留最近 20 条) const lines = (trimmed + '\n' + newBlock).split('\n').filter(l => l.startsWith('- ')); const limited = lines.slice(-20).join('\n'); return `${header}${limited}\n`; } ); } else { // 新增段落 content = `${content}\n\n${SECTION_HEADER}\n\n> 以下经验由系统自动从历史对话中提取沉淀,帮助你避免重复犯错。\n\n${newBlock}\n`.trimStart(); } fs.mkdirSync(path.dirname(knowhowPath), { recursive: true }); fs.writeFileSync(knowhowPath, content, 'utf-8'); console.log(`[Memory] 📚 Know-how 已更新: ${agentId} memory/knowhow.md (+${entries.length} 条经验)`); } catch (err) { console.error(`[Memory] ⚠️ Know-how 更新失败:`, err.message); } } module.exports = { // Phase 1 saveSessionSummary, loadRecentMemory, appendToMemoryMd, // Phase 2 retrieveRelevantPlans, saveCerebellumPlan, extractKeywords, // Phase 3 autoReflect, consolidateMemory, generateDailyReport, // 内部 (测试用) loadHippocampusIndex, saveHippocampusIndex, };