/** * Memory Maintainer — MEMORY.md 维护闭环(v0.8.1 P1-2) * * 根治背景:MemoryConsolidator 纯 append-only —— ① 去重盲区:固化 prompt 只带 * 全文前 3000 字符,超出部分对 LLM 不可见,重复写入无法避免;② 只增不减: * 过期/被推翻的条目无任何回收路径,MEMORY.md 随使用无限膨胀(>50KB 后固化 * prompt 与 system 注入双双劣化)。 * * 本模块实现两阶段维护闭环(分析与应用分离,应用前必须经用户确认): * 1. analyze():LLM 读取"分区条目摘要"(纯条目行,无全文截断盲区)→ 产出 * 结构化建议 {deletes[], updates[]}(去重 / 合并 / 清理过期); * 2. apply():按用户勾选的动作改写 MEMORY.md(WorkspaceService.rewriteMemory, * 唯一合法写入口)并同步删除/更新 semantic_memories 对应行(双轨一致)。 * * 安全边界:仅追加白名单分区、单次动作数上限、条目精确匹配(防 LLM 幻觉改写 * 无关内容)、全部动作写入 audit_logs。 */ import { nanoid } from 'nanoid'; import log from 'electron-log'; import type Database from 'better-sqlite3'; import type { IMetonaProviderAdapter } from '../types/metona-adapter'; import type { MetonaRequest } from '../types'; import type { WorkspaceService } from '../../services/workspace.service'; /** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE / Consolidator 对齐) */ const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const; /** 动作数上限(防 LLM 过度建议) */ const MAX_ACTIONS = 30; /** 单条目在 prompt 中的截断长度 */ const ENTRY_PROMPT_CHARS = 160; /** 条目摘要总预算(字符)—— 纯条目行远小于全文,同预算下覆盖完整文件 */ const DIGEST_BUDGET_CHARS = 8000; /** 一条维护动作(用户确认的输入/输出单元) */ export interface MemoryMaintenanceAction { /** 动作类型:delete = 删除整行;merge = 用 newEntry 替换该行(合并多条时产生多条 update 指向同一 newEntry) */ action: 'delete' | 'update'; section: string; /** MEMORY.md 中该条目的当前完整文本(不含 "- " 前缀;精确匹配锚点) */ entry: string; /** action=update 时的替换文本(合并后的新条目) */ newEntry?: string; /** LLM 给出的理由(UI 展示) */ reason?: string; } export interface MemoryMaintenanceProposal { actions: MemoryMaintenanceAction[]; /** 当前文件条目总数(UI 展示上下文) */ totalEntries: number; /** * v0.8.1 review (O2): 各分区当前条目数 —— 供维护弹框计算"应用后变空的分区" * 并向用户提示(空分区保留分区头,条目区将显示为空)。 */ sectionEntryCounts: Record; } /** 解析后的分区结构(模块级类型 —— parseEntries/apply 共用) */ interface ParsedSection { section: string; entries: string[]; } /** 解析 MEMORY.md 的分区与条目(模块级工具 —— Maintainer 与 Consolidator 共用) */ export function parseMemoryEntries(memory: string): ParsedSection[] { const sections: ParsedSection[] = []; let current: ParsedSection | null = null; let inHead = true; for (const line of memory.split('\n')) { if (inHead) { if (line.startsWith('## ')) inHead = false; else continue; } const m = line.match(/^## (.+)$/); if (m) { current = { section: m[1].trim(), entries: [] }; sections.push(current); continue; } const em = line.match(/^- (.+)$/); if (em && current) { current.entries.push(em[1].trim()); } } return sections; } /** * 构建"分区条目摘要"(纯条目行,消除全文截断盲区)。 * Consolidator 固化去重与 Maintainer 分析共用:同预算(8000 字符)下纯条目 * 形态可覆盖完整文件,而旧的全文截断(3000 字符)会让 LLM 看不到尾部条目、 * 去重失效 → 重复写入。 */ export function buildMemoryEntriesDigest(sections: ParsedSection[]): string { const parts: string[] = []; let used = 0; for (const s of sections) { if (s.entries.length === 0) continue; const lines: string[] = [`## ${s.section}`]; for (const e of s.entries) { const clipped = e.length > ENTRY_PROMPT_CHARS ? `${e.slice(0, ENTRY_PROMPT_CHARS)}...` : e; lines.push(`- ${clipped}`); } const block = lines.join('\n'); if (used + block.length > DIGEST_BUDGET_CHARS) break; parts.push(block); used += block.length; } return parts.join('\n\n'); } export class MemoryMaintainer { constructor( private getAdapter: () => IMetonaProviderAdapter, private workspaceService: WorkspaceService, private getDB: () => Database.Database, ) {} /** 分析当前 MEMORY.md,产出维护建议(不改任何文件/DB) */ async analyze(): Promise { const memory = this.workspaceService.getFiles().memory ?? ''; const sections = this.parseEntries(memory); const totalEntries = sections.reduce((n, s) => n + s.entries.length, 0); const sectionEntryCounts: Record = {}; for (const s of sections) { sectionEntryCounts[s.section] = s.entries.length; } if (totalEntries === 0) { return { actions: [], totalEntries: 0, sectionEntryCounts }; } const digest = this.buildDigest(sections); const raw = await this.callLLM(digest); const actions = this.parseActions(raw, sections); return { actions, totalEntries, sectionEntryCounts }; } /** 应用用户确认的动作(只处理精确命中当前文件内容的动作,防幻觉改写) */ apply(actions: MemoryMaintenanceAction[]): { applied: number; skipped: number } { const memory = this.workspaceService.getFiles().memory ?? ''; const sections = this.parseEntries(memory); // 精确匹配校验:entry 必须原样存在于对应分区(LLM 响应与文件状态之间的一致性锚点) const valid: MemoryMaintenanceAction[] = []; for (const a of actions.slice(0, MAX_ACTIONS)) { const section = sections.find((s) => s.section === a.section); const exists = section?.entries.includes(a.entry) ?? false; if (!exists) continue; if (a.action === 'update' && (!a.newEntry || !a.newEntry.trim())) continue; valid.push(a); } if (valid.length === 0) return { applied: 0, skipped: actions.length }; // 应用到内存结构:delete 直接删;update 替换文本 for (const a of valid) { const section = sections.find((s) => s.section === a.section); if (!section) continue; if (a.action === 'delete') { section.entries = section.entries.filter((e) => e !== a.entry); } else { section.entries = section.entries.map((e) => (e === a.entry ? a.newEntry!.trim() : e)); } } // 序列化回 Markdown(保留原文件头;分区结构重建) const head = this.extractHead(memory); const body = sections .map((s) => `## ${s.section}\n${s.entries.map((e) => `- ${e}`).join('\n')}`) .filter((s) => !s.endsWith('## ') && s.split('\n').length > 1) .join('\n\n'); this.workspaceService.rewriteMemory(`${head}${body}\n`); // 双轨一致:同步 semantic_memories(content 以 entry 写入 —— Consolidator 同口径) const db = this.getDB(); const delStmt = db.prepare('DELETE FROM semantic_memories WHERE content = ?'); const updStmt = db.prepare( 'UPDATE semantic_memories SET content = ?, summary = ? WHERE content = ?', ); let dbOps = 0; for (const a of valid) { try { if (a.action === 'delete') { dbOps += delStmt.run(a.entry).changes; } else { dbOps += updStmt.run( a.newEntry!.trim(), `[${a.section}] ${a.newEntry!.trim().slice(0, 60)}`, a.entry, ).changes; } } catch (err) { // DB 同步失败不影响 MEMORY.md 已写入结果(与 Consolidator 同语义) log.warn('[MemoryMaintainer] semantic_memories sync failed:', (err as Error).message); } } log.info( `[MemoryMaintainer] applied ${valid.length} action(s) (db rows touched: ${dbOps}, skipped: ${actions.length - valid.length})`, ); return { applied: valid.length, skipped: actions.length - valid.length }; } // ===== 私有方法 ===== /** 解析 MEMORY.md 为 {section, entries[]} 结构(跳过元数据头;复用模块级工具) */ private parseEntries(memory: string): ParsedSection[] { return parseMemoryEntries(memory); } /** 提取文件头(H1 + > 元数据区),供重建时保留 */ private extractHead(memory: string): string { const lines = memory.split('\n'); let headEnd = 0; for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('## ')) { headEnd = i; break; } } const headLines = lines.slice(0, headEnd).join('\n').trimEnd(); return headLines.length > 0 ? `${headLines}\n\n` : ''; } /** 构建"分区条目摘要"(纯条目行,消除全文截断盲区) */ private buildDigest(sections: ParsedSection[]): string { const parts: string[] = []; let used = 0; for (const s of sections) { if (s.entries.length === 0) continue; const lines: string[] = [`## ${s.section}`]; for (const e of s.entries) { const clipped = e.length > ENTRY_PROMPT_CHARS ? `${e.slice(0, ENTRY_PROMPT_CHARS)}...` : e; lines.push(`- ${clipped}`); } const block = lines.join('\n'); if (used + block.length > DIGEST_BUDGET_CHARS) break; parts.push(block); used += block.length; } return parts.join('\n\n'); } /** LLM 分析(结构化 JSON 输出,30s 超时与 Consolidator 同口径) */ private async callLLM(digest: string): Promise { const sectionsList = ALLOWED_SECTIONS.map((s) => `"${s}"`).join(', '); const request: MetonaRequest = { meta: { sessionId: 'memory-maintenance', iteration: 0, requestId: `mm_${nanoid(12)}`, timestamp: Date.now(), agentVersion: '1.0.0', }, systemPrompt: { roleDefinition: "You are a memory curator maintaining the agent's long-term memory file (MEMORY.md).", outputConstraints: [ 'Analyze the memory entries below and propose maintenance actions:', '- "delete": remove stale, superseded, duplicated, or completed entries', '- "update": merge two or more duplicate/similar entries into ONE consolidated entry', 'Keep valuable, still-valid information — do NOT delete aggressively.', `Every action must reference an existing entry EXACTLY as written (section must be one of ${sectionsList}).`, '', 'Output ONLY a JSON array, no markdown fences:', '[{"action":"delete","section":"...","entry":"...","reason":"..."},', ' {"action":"update","section":"...","entry":"old entry","newEntry":"merged entry","reason":"..."}]', 'If nothing needs maintenance, output []', ].join('\n'), safetyGuidelines: 'Never propose deleting user preference facts without a clear reason.', }, messages: [ { role: 'user', content: `## Current MEMORY.md entries:\n\n${digest}\n\n## Task:\nPropose maintenance actions. Output JSON array only.`, timestamp: Date.now(), }, ], params: { maxTokens: 2048, temperature: 0.0, stream: false, thinkingEnabled: false, thinkingEffort: 'low', }, }; try { let timer: ReturnType | undefined; try { const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('maintenance analysis timeout')), 30_000); }); const response = await Promise.race([this.getAdapter().send(request), timeoutPromise]); return response.content.trim(); } finally { if (timer) clearTimeout(timer); } } catch (error) { log.warn('[MemoryMaintainer] LLM call failed:', (error as Error).message); return null; } } /** 解析 LLM 建议(丢弃非法 section / 空条目 / 超限动作) */ private parseActions(raw: string | null, sections: ParsedSection[]): MemoryMaintenanceAction[] { if (!raw) return []; let cleaned = raw.trim(); if (cleaned.startsWith('```')) { cleaned = cleaned .replace(/^```(?:json)?\s*/i, '') .replace(/\s*```$/, '') .trim(); } let parsed: unknown; try { parsed = JSON.parse(cleaned); } catch { log.warn('[MemoryMaintainer] failed to parse LLM response as JSON:', cleaned.slice(0, 200)); return []; } if (!Array.isArray(parsed)) return []; const validSections = new Set(ALLOWED_SECTIONS); // 仅允许引用当前文件中真实存在的条目(先过滤一轮,双保险在 apply 中再做精确校验) const existing = new Set(); for (const s of sections) { for (const e of s.entries) existing.add(e); } const out: MemoryMaintenanceAction[] = []; for (const item of parsed.slice(0, MAX_ACTIONS)) { if (!item || typeof item !== 'object') continue; const a = item as Record; const action = a.action; const section = typeof a.section === 'string' ? a.section.trim() : ''; const entry = typeof a.entry === 'string' ? a.entry.trim() : ''; if (action !== 'delete' && action !== 'update') continue; if (!validSections.has(section) || !entry || !existing.has(entry)) continue; if (action === 'update' && (typeof a.newEntry !== 'string' || !a.newEntry.trim())) continue; out.push({ action, section, entry, newEntry: action === 'update' ? (a.newEntry as string).trim() : undefined, reason: typeof a.reason === 'string' ? a.reason.slice(0, 200) : undefined, }); } return out; } }