Files
metona-ai-desktop/electron/harness/memory/consolidator.ts
T
thzxx f3a0e751ba feat: v0.3.18 上下文压缩机制修复 + MCP 工具就绪竞态修复 + 记忆系统加固
【上下文压缩机制修复】
- engine.ts 新增 lastRealInputTokens 记录 LLM 返回的真实输入 token,压缩判断取 max(估算值, 真实值),避免估算偏低导致不压缩但 API 413
- 修复 effectiveContextWindow 缺少默认值导致 compressionThreshold 变 NaN、压缩永不触发的 bug(添加 ?? 128_000 兜底)
- compressMessages 保留区从固定 10 条改为按 token 预算动态截断(50% 上下文窗口)
- 二次截断 charsPerToken 从 2 调整为 1.0,与 CJK_TOKEN_RATIO 一致
- 压缩后重置 lastRealInputTokens,避免跨迭代污染
- 每个 run 开始时重置 lastRealInputTokens

【前端 Token 显示修复】
- 区分"累计消耗"和"上下文占用"语义——之前 totalTokens(累计) / contextWindow(单次窗口) 得出无意义百分比
- TokenUsage.tsx 上下文占用改用 lastInputTokens,新增压缩节省行(绿色,仅当 > 0 时显示)
- agent-store.ts TokenUsage 接口新增 lastInputTokens 和 lastCompressedSaved 字段,4 处初始值统一更新
- useAgentStream.ts usage 事件 lastInputTokens 替换不累加,compressed 事件通过 streamEvent 接收 savedTokens
- handlers.ts onCompressed 同时发 toast + streamEvent,解决"压缩触发但前端 token 显示不降"缺陷
- 旧数据兼容使用 ?? 0,保证历史会话加载不崩溃

【MCP 工具就绪竞态修复】
- 修复输入框永久显示"工具加载中"的竞态条件:MCP initialize 几乎立即 resolve(connectServer 不 await),tools:ready 事件在前端监听器注册前已发出
- main.ts 维护 toolsReady 标志 + 注册 tools:isReady IPC handler 查询当前状态
- preload.ts 暴露 tools.isReady() 方法
- App.tsx 注册 onReady 监听器后立即查询 isReady(),无论事件是否错过都能恢复正确状态

【记忆系统加固】
- consolidator.ts 新增 runningPromise + waitForCompletion(35s),before-quit 等待固化完成,防止退出时异步 consolidate 数据丢失
- 固化到 MEMORY.md 的同时写入 semantic_memories 表,解决双轨存储无交叉验证问题
- manager.ts tokenize 按中英文标点切分子句后再做 bigram,优化中文分词
- 清理正则冗余括号

【SOUL.md 降级处理】
- context-builder.ts SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
- 新增 fallbackRoleNotified 去重标志,仅首次降级通知,避免每次发消息都弹 toast
- SOUL.md 恢复内容时重置标志

【Token 估算调整】
- token-estimator.ts CJK_TOKEN_RATIO 从 1.5 调整为 1.0

【MCP 异步初始化】
- main.ts MCP 完成后广播 tools:ready 事件,前端 UI 据以控制输入框可用性
- preload.ts + global.d.ts 暴露 tools.onReady() 监听器
- App.tsx + ChatInput.tsx toolsReady 状态控制输入框

【版本号】
- package.json + package-lock.json 从 0.3.16 升级到 0.3.18
2026-07-22 15:17:58 +08:00

406 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, MetonaMessage } 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;
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 {
/** 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) + '...';
}
}