feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s

硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(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:
2026-09-08 09:35:58 +08:00
parent 839860083f
commit 9b45c445bf
85 changed files with 5286 additions and 1158 deletions
@@ -0,0 +1,182 @@
/**
* MemoryMaintainer 测试(v0.8.1 P1-2 MEMORY.md 维护闭环)
*
* 锁定契约:
* 1. parseMemoryEntries / buildMemoryEntriesDigest —— 分区条目摘要(纯条目行,
* 消除 Consolidator 旧全文截断的去重盲区)
* 2. apply 的精确匹配防线 —— LLM 建议的 entry 必须原样存在,防幻觉改写无关内容
* 3. delete/update 动作重写 MEMORY.md + 同步 semantic_memories 双轨一致
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { MemoryMaintainer, parseMemoryEntries, buildMemoryEntriesDigest } from '../maintainer';
import type { MemoryMaintenanceAction } from '../maintainer';
const SAMPLE = `# MEMORY.md — AI 持久记忆
> 最后更新: 2026-09-07
## 用户偏好
- [沟通风格] 用户喜欢简洁的回答
- [工具偏好] 项目使用 pnpm
## 项目上下文
- [Metona] 技术栈: Electron + React
## 待办事项
- [done] 旧待办已完成
`;
describe('parseMemoryEntries / buildMemoryEntriesDigestv0.8.1 P1-2', () => {
it('解析分区与条目(跳过元数据头)', () => {
const sections = parseMemoryEntries(SAMPLE);
expect(sections).toHaveLength(3);
expect(sections[0].section).toBe('用户偏好');
expect(sections[0].entries).toEqual([
'[沟通风格] 用户喜欢简洁的回答',
'[工具偏好] 项目使用 pnpm',
]);
});
it('digest 为纯条目行形态且条目全文可见(无 3000 字符截断盲区)', () => {
const digest = buildMemoryEntriesDigest(parseMemoryEntries(SAMPLE));
expect(digest).toContain('## 用户偏好');
expect(digest).toContain('- [沟通风格] 用户喜欢简洁的回答');
// 旧全文形态的头部元数据不进入 digest
expect(digest).not.toContain('最后更新');
// 超过旧 3000 字符预算的记忆尾部条目同样完整进入 digest
const manyEntries = Array.from({ length: 200 }, (_, i) => `- 条目 ${i} ${'x'.repeat(20)}`);
const bigMemory = `## 项目上下文\n${manyEntries.join('\n')}`;
const bigDigest = buildMemoryEntriesDigest(parseMemoryEntries(bigMemory));
expect(bigDigest).toContain('条目 199');
});
});
describe('MemoryMaintainer.apply — 精确匹配与双轨同步', () => {
function makeMaintainer(memory: string): {
maintainer: MemoryMaintainer;
getMemory: () => string;
db: { prepare(sql: string): { run(...args: unknown[]): { changes: number } } };
} {
let current = memory;
const semanticRows: Array<{ content: string }> = [{ content: '[沟通风格] 用户喜欢简洁的回答' }];
const db = {
prepare: (sql: string) => ({
run: (...args: unknown[]) => {
if (sql.startsWith('DELETE')) {
const before = semanticRows.length;
const target = semanticRows.find((r) => r.content === args[0]);
if (target) semanticRows.splice(semanticRows.indexOf(target), 1);
return { changes: before - semanticRows.length };
}
if (sql.startsWith('UPDATE')) {
const row = semanticRows.find((r) => r.content === args[2]);
if (row) {
row.content = args[0] as string;
return { changes: 1 };
}
return { changes: 0 };
}
return { changes: 0 };
},
}),
};
const maintainer = new MemoryMaintainer(
() => {
throw new Error('not used in apply');
},
{
getFiles: () => ({ soul: '', memory: current }),
rewriteMemory: (content: string) => {
current = content;
},
} as never,
() => db as never,
);
return { maintainer, getMemory: () => current, db };
}
it('delete 精确命中 → 行被移除;未命中条目被跳过(防幻觉改写)', () => {
const { maintainer, getMemory } = makeMaintainer(SAMPLE);
const actions: MemoryMaintenanceAction[] = [
{ action: 'delete', section: '待办事项', entry: '[done] 旧待办已完成' },
// 幻觉条目:文件中不存在 → 必须跳过
{ action: 'delete', section: '用户偏好', entry: '不存在的条目' },
];
const result = maintainer.apply(actions);
expect(result.applied).toBe(1);
expect(result.skipped).toBe(1);
const after = getMemory();
expect(after).not.toContain('[done] 旧待办已完成');
expect(after).toContain('用户喜欢简洁的回答');
expect(after).toContain('## 待办事项'); // 分区头保留(空分区仍保留结构)
});
it('update(合并)→ 替换条目并同步 semantic_memories', () => {
const { maintainer, getMemory, db } = makeMaintainer(SAMPLE);
const actions: MemoryMaintenanceAction[] = [
{
action: 'update',
section: '用户偏好',
entry: '[沟通风格] 用户喜欢简洁的回答',
newEntry: '[沟通风格] 用户喜欢简洁的回答,不需要过度解释',
},
];
const result = maintainer.apply(actions);
expect(result.applied).toBe(1);
expect(getMemory()).toContain('不需要过度解释');
const row = db
.prepare('SELECT * FROM semantic_memories WHERE content = ?')
.run('[沟通风格] 用户喜欢简洁的回答,不需要过度解释');
expect(row).toBeDefined();
});
it('动作数上限 30(防 LLM 过度建议)', () => {
const { maintainer } = makeMaintainer(SAMPLE);
const actions: MemoryMaintenanceAction[] = Array.from({ length: 40 }, () => ({
action: 'delete' as const,
section: '待办事项',
entry: '不存在的条目',
}));
const result = maintainer.apply(actions);
expect(result.skipped).toBe(40);
expect(result.applied).toBe(0);
});
});
describe('MemoryMaintainer.analyze — sectionEntryCountsv0.8.1 review O2', () => {
function makeAnalyzer(
memory: string,
llmReply: string,
): {
maintainer: MemoryMaintainer;
} {
const adapter = {
send: vi.fn().mockResolvedValue({ content: llmReply }),
} as never;
return {
maintainer: new MemoryMaintainer(
() => adapter,
{
getFiles: () => ({ soul: '', memory }),
rewriteMemory: () => {},
} as never,
(() => ({})) as never,
),
};
}
it('proposal 携带各分区条目数(空分区提示的数据源)', async () => {
const { maintainer } = makeAnalyzer(
SAMPLE,
'[{"action":"delete","section":"待办事项","entry":"[done] 旧待办已完成","reason":"已完成"}]',
);
const proposal = await maintainer.analyze();
expect(proposal.sectionEntryCounts['待办事项']).toBe(1);
expect(proposal.sectionEntryCounts['用户偏好']).toBe(2);
});
});