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 全项留档。
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 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<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_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<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user