Files
metona-ai-desktop/electron/harness/memory/consolidator.ts
T
thzxx 2230bcec3f feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复:
- API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容)
- 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级)
- error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计)
- abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止)
- run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化)
- .env 真实生效(dotenv 回退加载,应用内配置优先)

P1 工程基础:
- ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线)
- 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层)
- test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行)
- SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end)
- Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知)
- MCP 真就绪(等待全部连接完成再广播 tools:ready)
- SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态)
- CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移)

P2 架构升级:
- handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播)
- AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号)
- TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent
- 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染)
- 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI)
- Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入

P3 能力扩展:
- OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens)
- Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机)
- 设置页/Onboarding 六 Provider 全链路接入
2026-08-20 23:17:02 +08:00

405 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } from '../types';
import type { WorkspaceService } from '../../services/workspace.service';
import type { IterationStep } from '../agent-loop/types';
import type { MemoryManager } from './manager';
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
/** 单次固化最多追加的条目数 */
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 {
/** v0.3.18 修复: 跟踪进行中的 consolidate 任务,供应用退出时等待 */
private runningPromise: Promise<ConsolidationResult> | null = null;
/** v0.3.18 修复: 注入 MemoryManager,实现 DB 记忆与 MEMORY.md 双轨交叉写入 */
private memoryManager: MemoryManager | null = null;
constructor(
private adapter: IMetonaProviderAdapter,
private workspaceService: WorkspaceService,
) {}
/**
* 热切换 Adapter(配置变更时由 main.ts 调用)
*/
setAdapter(adapter: IMetonaProviderAdapter): void {
this.adapter = adapter;
}
/**
* v0.3.18 修复: 注入 MemoryManager,使 consolidate 同步写入 semantic_memories 表
*/
setMemoryManager(memoryManager: MemoryManager): void {
this.memoryManager = memoryManager;
}
/**
* v0.3.18 修复: 是否有进行中的 consolidate 任务
*/
isRunning(): boolean {
return this.runningPromise !== null;
}
/**
* v0.3.18 修复: 等待进行中的 consolidate 任务完成(应用退出时调用)
*
* @param timeoutMs 超时时间(默认 35 秒,略大于 LLM 调用的 30 秒超时)
* @returns true 表示已完成,false 表示超时
*/
async waitForCompletion(timeoutMs: number = 35_000): Promise<boolean> {
if (!this.runningPromise) return true;
let timedOut = false;
let timerHandle: ReturnType<typeof setTimeout> | undefined;
const timer = new Promise<void>((resolve) => {
timerHandle = setTimeout(() => {
timedOut = true;
resolve();
}, timeoutMs);
});
try {
await Promise.race([
this.runningPromise.catch(() => {}),
timer,
]);
return !timedOut;
} finally {
if (timerHandle) clearTimeout(timerHandle);
}
}
/**
* 固化本次对话的重要记忆到 MEMORY.md
*
* @param userMessage 用户原始消息
* @param assistantAnswer Agent 最终回答
* @param iterations Agent Loop 迭代步骤(用于提取工具调用摘要)
* @returns 固化结果
*/
async consolidate(
userMessage: string,
assistantAnswer: string,
iterations: IterationStep[],
): Promise<ConsolidationResult> {
// v0.3.18 修复: 跟踪进行中的 consolidate,供应用退出时等待
const promise = this.executeConsolidation(userMessage, assistantAnswer, iterations);
this.runningPromise = promise;
try {
return await promise;
} finally {
// 只有当前 promise 仍是自己时才清理,避免被后续调用覆盖
if (this.runningPromise === promise) {
this.runningPromise = null;
}
}
}
/**
* 实际执行固化的内部方法
*
* v0.3.18 修复: 同步写入 semantic_memories 表,实现 DB 记忆与 MEMORY.md 双轨交叉
* 之前仅写 MEMORY.md,导致 DB 检索不到用户偏好等非工具触发的重要信息。
* 现在每条固化到 MEMORY.md 的条目也同步存入 semantic_memories 表,
* 使 TF-IDF 检索能命中跨会话的用户偏好/决策/项目上下文。
*/
private async executeConsolidation(
userMessage: string,
assistantAnswer: string,
iterations: IterationStep[],
): Promise<ConsolidationResult> {
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 });
// v0.3.18 修复: 同步写入 semantic_memories 表,实现 DB 记忆与 MEMORY.md 双轨交叉
// 之前仅写 MEMORY.md,导致 DB 检索不到用户偏好等非工具触发的重要信息。
// 现在每条固化到 MEMORY.md 的条目也存入 semantic_memories
// key 用 section(如"用户偏好"),value 用 entry 内容,
// confidence 按 section 语义分级(偏好/决策最高,待办最低)
if (this.memoryManager) {
try {
const confidence = this.getSectionConfidence(item.section);
this.memoryManager.store({
type: 'semantic',
content: truncatedEntry,
summary: `[${item.section}] ${truncatedEntry.slice(0, 60)}`,
source: 'agent_thought',
importance: confidence,
});
} catch (dbErr) {
// DB 写入失败不影响 MEMORY.md 已写入的结果,仅记录日志
log.warn(`[MemoryConsolidator] Failed to sync to semantic_memories:`, dbErr);
}
}
} 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<string | null> {
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 任务挂起
// M-17 修复: 使用 try/finally 清理 setTimeout,防止每次会话结束时 timer 堆积
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('LLM extraction timeout')), 30_000);
});
const response = await Promise.race([this.adapter.send(request), timeoutPromise]);
return response.content.trim();
} finally {
// M-17 修复: LLM 调用正常完成时清理未触发的 30 秒 timer
if (timer) clearTimeout(timer);
}
} 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;
}
/**
* v0.3.18 修复: 按 MEMORY.md 分区语义返回 confidence(重要度)
*
* 用于同步写入 semantic_memories 时的 importance 字段:
* - 用户偏好/重要决策:0.9(长期有效,跨会话高价值)
* - 项目上下文/已知问题:0.7(项目级,中等价值)
* - 待办事项:0.5(短期,可能很快完成)
*/
private getSectionConfidence(section: string): number {
switch (section) {
case '用户偏好':
case '重要决策':
return 0.9;
case '项目上下文':
case '已知问题':
return 0.7;
case '待办事项':
return 0.5;
default:
return 0.6;
}
}
/**
* 截断字符串
*/
private truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen) + '...';
}
}