/** * Memory Consolidator — 会话结束记忆固化器 * * 每次 Agent 完成一轮对话后,调用 LLM 判断本次对话中哪些内容 * 值得持久化到工作空间根目录的 MEMORY.md。 * * 工作流程: * 1. 收集本次对话的 user message + assistant final answer + tool calls 摘要 * 2. 读取当前 MEMORY.md 内容作为参考(避免重复) * 3. 调用 LLM,要求其返回 JSON 数组 [{section, entry}] * 4. 解析响应,调用 WorkspaceService.appendMemory 追加 * * 安全约束: * - 仅追加新条目,不修改已有内容 * - section 限定为预定义的 5 个分区 * - 单次最多追加 5 条记忆,避免 LLM 过度提取 * - LLM 调用失败时静默降级(不影响主流程) * * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第六章 */ import { nanoid } from 'nanoid'; import log from 'electron-log'; import type { IMetonaProviderAdapter } from '../types/metona-adapter'; import type { MetonaRequest, MetonaMessage } from '../types'; import type { WorkspaceService } from '../../services/workspace.service'; import type { IterationStep } from '../agent-loop/types'; /** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */ const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const; type AllowedSection = typeof ALLOWED_SECTIONS[number]; /** 单次固化最多追加的条目数 */ const MAX_ENTRIES_PER_CONSOLIDATION = 5; /** 单条记忆最大长度 */ const MAX_ENTRY_LENGTH = 500; export interface ConsolidationResult { appended: number; entries: Array<{ section: string; entry: string }>; skipped: number; } export class MemoryConsolidator { constructor( private adapter: IMetonaProviderAdapter, private workspaceService: WorkspaceService, ) {} /** * 热切换 Adapter(配置变更时由 main.ts 调用) */ setAdapter(adapter: IMetonaProviderAdapter): void { this.adapter = adapter; } /** * 固化本次对话的重要记忆到 MEMORY.md * * @param userMessage 用户原始消息 * @param assistantAnswer Agent 最终回答 * @param iterations Agent Loop 迭代步骤(用于提取工具调用摘要) * @returns 固化结果 */ async consolidate( userMessage: string, assistantAnswer: string, iterations: IterationStep[], ): Promise { try { // 1. 构建对话摘要 const conversationDigest = this.buildConversationDigest(userMessage, assistantAnswer, iterations); if (!conversationDigest) { return { appended: 0, entries: [], skipped: 0 }; } // 2. 读取当前 MEMORY.md 内容(供 LLM 去重) const currentMemory = this.workspaceService.getFiles().memory; const memoryDigest = this.truncateMemoryForPrompt(currentMemory); // 3. 调用 LLM 提取需要持久化的记忆 const llmResponse = await this.callLLMForExtraction(conversationDigest, memoryDigest); if (!llmResponse) { return { appended: 0, entries: [], skipped: 0 }; } // 4. 解析 JSON 响应 const extracted = this.parseExtractionResponse(llmResponse); if (extracted.length === 0) { log.debug('[MemoryConsolidator] No memories worth persisting'); return { appended: 0, entries: [], skipped: 0 }; } // 5. 过滤 + 截断 + 追加到 MEMORY.md const validEntries: Array<{ section: string; entry: string }> = []; let skipped = 0; for (const item of extracted.slice(0, MAX_ENTRIES_PER_CONSOLIDATION)) { if (!this.isValidEntry(item)) { skipped++; continue; } const truncatedEntry = item.entry.slice(0, MAX_ENTRY_LENGTH); try { this.workspaceService.appendMemory(item.section, truncatedEntry); validEntries.push({ section: item.section, entry: truncatedEntry }); } catch (err) { log.warn(`[MemoryConsolidator] Failed to append to section "${item.section}":`, err); skipped++; } } if (validEntries.length > 0) { log.info(`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`); } return { appended: validEntries.length, entries: validEntries, skipped }; } catch (error) { log.error('[MemoryConsolidator] Consolidation failed:', error); return { appended: 0, entries: [], skipped: 0 }; } } /** * 构建对话摘要(供 LLM 分析) */ private buildConversationDigest( userMessage: string, assistantAnswer: string, iterations: IterationStep[], ): string { const parts: string[] = []; // 用户消息 parts.push(`[USER] ${this.truncate(userMessage, 2000)}`); // 工具调用摘要(如果有) const toolSummaries: string[] = []; for (const iter of iterations) { if (!iter.toolCalls?.length) continue; for (let i = 0; i < iter.toolCalls.length; i++) { const tc = iter.toolCalls[i]; const result = iter.toolResults?.find((r) => r.toolCallId === tc.id); const status = result?.success ? 'ok' : 'error'; const resultPreview = result?.result ? this.truncate(JSON.stringify(result.result), 200) : result?.error ?? ''; toolSummaries.push(` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`); } } if (toolSummaries.length > 0) { parts.push(`[TOOLS]\n${toolSummaries.join('\n')}`); } // Agent 最终回答 parts.push(`[ASSISTANT] ${this.truncate(assistantAnswer, 2000)}`); return parts.join('\n\n'); } /** * 截断 MEMORY.md 内容用于 prompt(避免过长) */ private truncateMemoryForPrompt(memory: string): string { if (!memory) return '(empty)'; // 截取前 3000 字符,保留分区结构概览 if (memory.length <= 3000) return memory; return memory.slice(0, 3000) + '\n... (truncated)'; } /** * 调用 LLM 提取需要持久化的记忆 */ private async callLLMForExtraction( conversationDigest: string, currentMemory: string, ): Promise { const sectionsList = ALLOWED_SECTIONS.map((s) => `"${s}"`).join(', '); const request: MetonaRequest = { meta: { sessionId: 'memory-consolidation', iteration: 0, requestId: `mc_${nanoid(12)}`, timestamp: Date.now(), agentVersion: '1.0.0', }, systemPrompt: { roleDefinition: 'You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent\'s long-term memory file (MEMORY.md) for future sessions.', outputConstraints: [ 'Analyze the conversation below and extract ONLY information that meets ALL of these criteria:', '1. Long-term value: will be useful in future conversations (not transient task state)', '2. Not already present in the current MEMORY.md (avoid duplicates)', '3. Concrete and actionable (not vague observations)', '', 'Good candidates: user preferences, project facts, important decisions, pending todos, known issues.', 'Bad candidates: temporary tool results, trivial conversation, already-known facts.', '', `Output a JSON array. Each element: {"section": one of ${sectionsList}, "entry": concise description (max 200 chars, in the conversation language)}`, 'If nothing is worth persisting, output an empty array: []', 'Output ONLY the JSON array, no markdown fences, no explanation.', ].join('\n'), safetyGuidelines: 'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.', }, messages: [{ role: 'user', content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`, timestamp: Date.now(), }], params: { maxTokens: 1024, temperature: 0.0, stream: false, thinkingEnabled: false, thinkingEffort: 'low', }, }; try { // v0.3.0 修复: 添加 30 秒超时保护,防止 LLM 响应缓慢导致 consolidator 任务挂起 const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('LLM extraction timeout')), 30_000), ); const response = await Promise.race([this.adapter.chat(request), timeoutPromise]); return response.content.trim(); } catch (error) { log.warn('[MemoryConsolidator] LLM call failed:', (error as Error).message); return null; } } /** * 解析 LLM 的提取响应 */ private parseExtractionResponse(raw: string): Array<{ section: string; entry: string }> { if (!raw) return []; // 尝试直接解析 JSON let cleaned = raw.trim(); // 移除可能的 markdown 代码围栏 if (cleaned.startsWith('```')) { cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim(); } try { const parsed = JSON.parse(cleaned); if (!Array.isArray(parsed)) return []; return parsed .filter((item): item is { section: string; entry: string } => typeof item === 'object' && item !== null && typeof item.section === 'string' && typeof item.entry === 'string', ) .map((item) => ({ section: item.section.trim(), entry: item.entry.trim(), })) .filter((item) => item.section && item.entry); } catch { log.warn('[MemoryConsolidator] Failed to parse LLM response as JSON:', cleaned.slice(0, 200)); return []; } } /** * 校验条目是否合法(section 在允许列表内) */ private isValidEntry(item: { section: string; entry: string }): boolean { return (ALLOWED_SECTIONS as readonly string[]).includes(item.section) && item.entry.length > 0; } /** * 截断字符串 */ private truncate(text: string, maxLen: number): string { if (text.length <= maxLen) return text; return text.slice(0, maxLen) + '...'; } }