【上下文压缩机制修复】 - 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
406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
/**
|
||
* 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) + '...';
|
||
}
|
||
}
|