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