memory-service.js 10.8 KB
/**
 * 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,
};