本次升级基于完整代码审查,修复 Critical/High/Medium/Low 四级共 96 项问题, 并通过返工审计修复 10 项遗留问题,tsc 双端类型检查零错误。 Critical (10/10 完成): - C-4: command.ts 接入 shell-quote 进行 token-level 注入检测,替代原有正则匹配 可防御 r"m" -rf /、$'rm'、$(echo rm) 等字符串拼接绕过 High (11/11 完成): - 竞态保护、Promise.allSettled、AbortController 资源泄漏、IPC 参数校验等 Medium (55/55 完成): - 事务保护、敏感数据脱敏、枚举校验、MUI v9 Stack prop 迁移、 React 组件 cancelled 标志、类型收窄等 Low (20/20 完成): - 辅助方法提取(flushToolCallBuffer/scoreAndPushMemory/tryAddColumn 等) - nanoid 统一替代 Date.now()+Math.random() - confirm() 替换为 MUI Dialog、useMemo 缓存、魔法数字命名化等 返工审计修复 (10/10 完成): - L-11: LogsSettings 残留的原生 confirm()/alert() 全部替换为 MUI Dialog/Alert - M-53: MemoryViewer handleSearch 独立 ref,修复 searching 状态卡死 - M-42: 脱敏短值(length <= 4)泄露修复 - M-47: tasks:update 补全 title/description 类型校验 - L-9: ollama.adapter 非流式路径 nanoid 统一 - M-45: audit:query limit 策略与 memory:listAll 一致化 - SettingsModal handleConfirmRemove 补全 try/catch + loadServers cleanup - L-15: CommandPalette useMemo 补全 sessions 响应式依赖 - useAgentStream 事件类型补全 seq/timestamp 字段 新增依赖: shell-quote + @types/shell-quote 版本号: 0.3.0 -> 0.3.1
289 lines
10 KiB
TypeScript
289 lines
10 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';
|
|
|
|
/** 允许写入的 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 {
|
|
constructor(
|
|
private adapter: IMetonaProviderAdapter,
|
|
private workspaceService: WorkspaceService,
|
|
) {}
|
|
|
|
/**
|
|
* 热切换 Adapter(配置变更时由 main.ts 调用)
|
|
*/
|
|
setAdapter(adapter: IMetonaProviderAdapter): void {
|
|
this.adapter = adapter;
|
|
}
|
|
|
|
/**
|
|
* 固化本次对话的重要记忆到 MEMORY.md
|
|
*
|
|
* @param userMessage 用户原始消息
|
|
* @param assistantAnswer Agent 最终回答
|
|
* @param iterations Agent Loop 迭代步骤(用于提取工具调用摘要)
|
|
* @returns 固化结果
|
|
*/
|
|
async consolidate(
|
|
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 });
|
|
} 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;
|
|
}
|
|
|
|
/**
|
|
* 截断字符串
|
|
*/
|
|
private truncate(text: string, maxLen: number): string {
|
|
if (text.length <= maxLen) return text;
|
|
return text.slice(0, maxLen) + '...';
|
|
}
|
|
}
|