Files
metona-ai-desktop/electron/harness/memory/maintainer.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

356 lines
14 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 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.mdWorkspaceService.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<string, number>;
}
/** 解析后的分区结构(模块级类型 —— 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<MemoryMaintenanceProposal> {
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<string, number> = {};
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_memoriescontent 以 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<string | null> {
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<typeof setTimeout> | undefined;
try {
const timeoutPromise = new Promise<never>((_, 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<string>(ALLOWED_SECTIONS);
// 仅允许引用当前文件中真实存在的条目(先过滤一轮,双保险在 apply 中再做精确校验)
const existing = new Set<string>();
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<string, unknown>;
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;
}
}