硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(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 全项留档。
415 lines
15 KiB
TypeScript
415 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 } 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) + '...';
|
||
}
|
||
}
|