Files
metona-ai-desktop/electron/harness/memory/consolidator.ts
T
thzxx 9b45c445bf
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s
feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
2026-09-08 09:35:58 +08:00

415 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';
// v0.8.1 P1-2: 分区条目摘要(与 Maintainer 共用,消除全文截断去重盲区)
import { parseMemoryEntries, buildMemoryEntriesDigest } from './maintainer';
/** 允许写入的 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 去重)
// v0.8.1 P1-2 根治: 旧实现全文截 3000 字符,尾部条目对 LLM 不可见 → 去重
// 失效、重复写入。现用纯条目摘要(8000 字符预算),完整覆盖全部条目。
const currentMemory = this.workspaceService.getFiles().memory;
const memoryDigest =
buildMemoryEntriesDigest(parseMemoryEntries(currentMemory ?? '')) || '(empty)';
// 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');
}
/**
* 调用 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) + '...';
}
}