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);
});
});
@@ -43,7 +43,8 @@ function createMemorySchema(db: any): void {
importance REAL DEFAULT 0.5,
created_at INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER,
tf_cache TEXT
tf_cache TEXT,
embedding BLOB
);
CREATE TABLE semantic_memories (
id TEXT PRIMARY KEY,
@@ -55,7 +56,8 @@ function createMemorySchema(db: any): void {
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0,
access_count INTEGER DEFAULT 0,
tf_cache TEXT
tf_cache TEXT,
embedding BLOB
);
CREATE TABLE working_memories (
id TEXT PRIMARY KEY,
@@ -349,10 +351,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — store 三层记忆', () => {
).toThrow(/Unknown memory type/);
});
it('store 使 IDF 缓存失效(cacheUpdatedAt 重置)', () => {
it('store 使 IDF 缓存失效(cacheUpdatedAt 重置)', async () => {
// v0.7.4 强化断言: 若 IDF 缓存未失效/检索不扫描新行,store 后 search 返回空即失败。
mgr.store({ type: 'episodic', content: 'hello world', source: 'user_input', importance: 0.7 });
mgr.search('hello'); // 建立 IDF 缓存
await mgr.search('hello'); // 建立 IDF 缓存
// 再 store 一条 → 缓存应失效,新内容可被检索
mgr.store({
type: 'episodic',
@@ -360,10 +362,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — store 三层记忆', () => {
source: 'user_input',
importance: 0.7,
});
const results = mgr.search('another');
const results = await mgr.search('another');
expect(results.some((r) => r.content === 'another content')).toBe(true);
// 双向验证:缓存重建后旧内容仍可检索(不因重建丢失)
const oldResults = mgr.search('hello');
const oldResults = await mgr.search('hello');
expect(oldResults.some((r) => r.content === 'hello world')).toBe(true);
});
@@ -401,7 +403,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
}
});
it('相同关键词:得分高者排前(内容重复度越高得分越高)', () => {
it('相同关键词:得分高者排前(内容重复度越高得分越高)', async () => {
mgr.store({
type: 'episodic',
content: 'memory hello world test',
@@ -416,7 +418,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
importance: 0.7,
});
const results = mgr.search('hello world');
const results = await mgr.search('hello world');
expect(results.length).toBeGreaterThan(0);
expect(results.every((r) => r.score > 0)).toBe(true);
// 两条命中的按分数降序
@@ -424,7 +426,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
expect([...scores].sort((a, b) => b - a)).toEqual(scores);
});
it('时间衰减:同内容越新得分越高(30 天半衰期)', () => {
it('时间衰减:同内容越新得分越高(30 天半衰期)', async () => {
mgr.store({
type: 'episodic',
content: '关键 bug 修复方案',
@@ -455,7 +457,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
newId,
);
const results = mgr.search('关键 bug');
const results = await mgr.search('关键 bug');
expect(results.length).toBeGreaterThan(0);
const newResult = results.find((r) => r.id === newId);
const oldResult = results.find((r) => r.id === oldId);
@@ -464,7 +466,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
expect(newResult!.score).toBeGreaterThan(oldResult!.score);
});
it('半衰期数学:30 天衰减系数恰为 0.5(score 相对无衰减×0.5', () => {
it('半衰期数学:30 天衰减系数恰为 0.5(score 相对无衰减×0.5', async () => {
// 新鲜记录(0 天)
const freshId = mgr.store({
type: 'episodic',
@@ -484,7 +486,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
agedId,
);
const results = mgr.search('衰减数学验证内容');
const results = await mgr.search('衰减数学验证内容');
const fresh = results.find((r) => r.id === freshId)!;
const aged = results.find((r) => r.id === agedId)!;
// score = cosine * decay * importanceFactor;两记录余弦与 importance 相同
@@ -492,7 +494,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
expect(aged.score / fresh.score).toBeCloseTo(0.5, 1);
});
it('importance 权重:0.5 + importance*0.5 缩放(importance=1 得分为 0 的 2 倍)', () => {
it('importance 权重:0.5 + importance*0.5 缩放(importance=1 得分为 0 的 2 倍)', async () => {
const lowId = mgr.store({
type: 'episodic',
content: '重要性权重验证',
@@ -506,24 +508,24 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
importance: 1,
});
// 同一时间创建,重要性不同 → factor = 0.5+0*0.5 vs 0.5+1*0.5
const results = mgr.search('重要性权重验证');
const results = await mgr.search('重要性权重验证');
const low = results.find((r) => r.id === lowId)!;
const high = results.find((r) => r.id === highId)!;
expect(high.score / low.score).toBeCloseTo(2.0, 1);
});
it('semantic 记忆可被检索(key+value 参与分词)', () => {
it('semantic 记忆可被检索(key+value 参与分词)', async () => {
mgr.store({
type: 'semantic',
content: '用户偏好深色主题',
source: 'imported',
importance: 0.5,
});
const results = mgr.search('偏好');
const results = await mgr.search('偏好');
expect(results.some((r) => r.type === 'semantic')).toBe(true);
});
it('working 记忆可被检索(key+value 参与分词,importance 固定 0.5', () => {
it('working 记忆可被检索(key+value 参与分词,importance 固定 0.5', async () => {
mgr.store({
type: 'working',
content: '当前任务文件',
@@ -531,11 +533,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
importance: 0.5,
source: 'agent_thought',
});
const results = mgr.search('当前任务');
const results = await mgr.search('当前任务');
expect(results.some((r) => r.type === 'working')).toBe(true);
});
it('type 过滤:仅返回指定类型', () => {
it('type 过滤:仅返回指定类型', async () => {
mgr.store({
type: 'episodic',
content: 'typefilter 内容',
@@ -549,13 +551,13 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
importance: 0.5,
});
const episodic = mgr.search('typefilter', { type: 'episodic' });
const episodic = await mgr.search('typefilter', { type: 'episodic' });
expect(episodic.every((r) => r.type === 'episodic')).toBe(true);
const semantic = mgr.search('typefilter', { type: 'semantic' });
const semantic = await mgr.search('typefilter', { type: 'semantic' });
expect(semantic.every((r) => r.type === 'semantic')).toBe(true);
});
it('topK 限制返回条数', () => {
it('topK 限制返回条数', async () => {
for (let i = 0; i < 8; i++) {
mgr.store({
type: 'episodic',
@@ -564,22 +566,22 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减',
importance: 0.7,
});
}
const results = mgr.search('topk 内容');
const results = await mgr.search('topk 内容');
expect(results.length).toBeLessThanOrEqual(5); // 默认 topK=5
const results2 = mgr.search('topk 内容', { topK: 2 });
const results2 = await mgr.search('topk 内容', { topK: 2 });
expect(results2.length).toBeLessThanOrEqual(2);
});
it('minImportance 过滤低重要性记忆', () => {
it('minImportance 过滤低重要性记忆', async () => {
mgr.store({ type: 'episodic', content: '低重要内容', source: 'user_input', importance: 0.1 });
mgr.store({ type: 'episodic', content: '高重要内容', source: 'user_input', importance: 0.9 });
const results = mgr.search('重要', { minImportance: 0.5 });
const results = await mgr.search('重要', { minImportance: 0.5 });
expect(results.every((r) => r.importance >= 0.5)).toBe(true);
});
it('score 字段为 finalScore = cosine * decay * (0.5+importance*0.5)>0 才返回)', () => {
it('score 字段为 finalScore = cosine * decay * (0.5+importance*0.5)>0 才返回)', async () => {
mgr.store({ type: 'episodic', content: 'score 数学', source: 'user_input', importance: 0.7 });
const results = mgr.search('score 数学');
const results = await mgr.search('score 数学');
expect(results.length).toBeGreaterThan(0);
for (const r of results) {
expect(r.score).toBeGreaterThan(0);
@@ -604,19 +606,19 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
}
});
it('空查询与纯空白查询返回空数组', () => {
expect(mgr.search('')).toEqual([]);
expect(mgr.search(' ')).toEqual([]);
expect(mgr.search('', { topK: 3 })).toEqual([]);
it('空查询与纯空白查询返回空数组', async () => {
expect(await mgr.search('')).toEqual([]);
expect(await mgr.search(' ')).toEqual([]);
expect(await mgr.search('', { topK: 3 })).toEqual([]);
});
it('无匹配关键词返回空数组(不抛错)', () => {
it('无匹配关键词返回空数组(不抛错)', async () => {
mgr.store({ type: 'episodic', content: '存在的关键词', source: 'user_input', importance: 0.7 });
// "完全无关联" 的 bigram 与文档无重叠 → TF-IDF 0 命中;LIKE 也无子串 → []
expect(mgr.search('完全无关联')).toEqual([]);
expect(await mgr.search('完全无关联')).toEqual([]);
});
it('英文无命中时回退 LIKE 子串搜索', () => {
it('英文无命中时回退 LIKE 子串搜索', async () => {
mgr.store({
type: 'episodic',
content: 'hello world network',
@@ -625,11 +627,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
});
// query "lo wo" 分词为 ['lo','wo'],与文档 token 无重叠 → TF-IDF 0 命中
// 但 "%lo wo%" 是 "hello world" 的连续子串 → LIKE 回退命中
const results = mgr.search('lo wo');
const results = await mgr.search('lo wo');
expect(results.some((r) => r.content.includes('hello world'))).toBe(true);
});
it('LIKE 回退时 LIKE 通配符 % 与 _ 被转义(不当作通配符)', () => {
it('LIKE 回退时 LIKE 通配符 % 与 _ 被转义(不当作通配符)', async () => {
mgr.store({
type: 'episodic',
content: '使用 50% 折扣 与 under_score',
@@ -643,15 +645,15 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
importance: 0.7,
});
// 查询 "%":分词为空 → 强制走 LIKE;若 % 未转义会匹配所有记录
const pct = mgr.search('%');
const pct = await mgr.search('%');
expect(pct.some((r) => r.content.includes('50%'))).toBe(true);
expect(pct.some((r) => r.content === '完全无关的内容')).toBe(false);
// 查询 "_":若未转义会匹配任意单字符 → 误命中无关记录
const underscore = mgr.search('_');
const underscore = await mgr.search('_');
expect(underscore.some((r) => r.content === '完全无关的内容')).toBe(false);
});
it('LIKE 回退时反斜杠被转义(Windows 路径不报错)', () => {
it('LIKE 回退时反斜杠被转义(Windows 路径不报错)', async () => {
mgr.store({
type: 'episodic',
content: '路径 C:\\Users\\test',
@@ -659,10 +661,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
importance: 0.7,
});
// 反斜杠单独作为查询 → tokenize 为空 → LIKE 路径;不转义会导致 SQLite 报错
expect(() => mgr.search('\\')).not.toThrow();
await expect(mgr.search('\\')).resolves.toBeInstanceOf(Array);
});
it('search 的 topK 同时作用于回退路径', () => {
it('search 的 topK 同时作用于回退路径', async () => {
for (let i = 0; i < 6; i++) {
mgr.store({
type: 'episodic',
@@ -671,11 +673,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
importance: 0.7,
});
}
const results = mgr.search('backup', { topK: 3 });
const results = await mgr.search('backup', { topK: 3 });
expect(results.length).toBeLessThanOrEqual(3);
});
it('search 无结果时回退 LIKE 的 score = importance * timeDecay', () => {
it('search 无结果时回退 LIKE 的 score = importance * timeDecay', async () => {
mgr.store({
type: 'episodic',
content: 'fallbackscore 内容',
@@ -683,16 +685,16 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () =>
importance: 0.8,
});
// "allbackscor" 分词不在文档 token 中 → TF-IDF 0 命中;LIKE %allbackscor% 命中
const results = mgr.search('allbackscor');
const results = await mgr.search('allbackscor');
expect(results.length).toBeGreaterThan(0);
// 新记录 timeDecay≈1 → score≈importance=0.8
expect(results[0].score).toBeCloseTo(0.8, 1);
});
it('LIKE 回退:episodic 按 importance 降序返回', () => {
it('LIKE 回退:episodic 按 importance 降序返回', async () => {
mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.2 });
mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.9 });
const results = mgr.search('排序验证');
const results = await mgr.search('排序验证');
expect(results[0].importance).toBe(0.9);
});
});
@@ -844,3 +846,141 @@ describe.skipIf(!dbAvailable)('MemoryManager — cleanupExpired', () => {
expect(db.prepare('SELECT COUNT(*) AS c FROM episodic_memories').get().c).toBe(1);
});
});
// ===== v0.8.1: P0-2 生命周期 + P1-1 向量混合检索 =====
describe.skipIf(!dbAvailable)('MemoryManager — v0.8.1 生命周期与混合检索', () => {
let db: any;
let mgr: MemoryManager;
beforeAll(() => {
if (!dbAvailable) return;
db = new Database(':memory:');
createMemorySchema(db);
mgr = new MemoryManager(() => db);
mgr.initialize();
});
afterAll(() => {
if (db) db.close();
});
afterEach(() => {
db.exec('DELETE FROM episodic_memories');
db.exec('DELETE FROM semantic_memories');
db.exec('DELETE FROM working_memories');
mgr.setEmbedder(null);
});
it('P0-2: store 接受 expiresAt 并写入 episodic_memories.expires_at', () => {
const ttl = Date.now() + 1000;
mgr.store({
type: 'episodic',
content: 'TTL 验证内容',
source: 'tool_result',
importance: 0.6,
expiresAt: ttl,
});
const row = db
.prepare('SELECT expires_at FROM episodic_memories WHERE content = ?')
.get('TTL 验证内容') as {
expires_at: number | null;
};
expect(row.expires_at).toBe(ttl);
});
it('P0-2: semantic 检索命中后 access_count 递增', async () => {
mgr.store({
type: 'semantic',
content: '用户偏好简洁回答',
summary: 'pref-brief',
source: 'agent_thought',
importance: 0.9,
});
const before = (
db.prepare('SELECT access_count FROM semantic_memories WHERE key = ?').get('pref-brief') as {
access_count: number;
}
).access_count;
await mgr.search('偏好简洁');
const after = (
db.prepare('SELECT access_count FROM semantic_memories WHERE key = ?').get('pref-brief') as {
access_count: number;
}
).access_count;
expect(after).toBeGreaterThan(before);
});
it('P1-1: 注入 embedder 后混合检索命中同义改写(TF-IDF 单路召回不到的查询)', async () => {
// 文档:"回复要短" — 查询"我喜欢简洁回答"(同义改写,无字面重叠)
mgr.store({
type: 'semantic',
content: '回复要短',
summary: 'style-rule',
source: 'agent_thought',
importance: 0.9,
});
// 词表不重叠 → TF-IDF 嵌入向量正交 → 纯 TF-IDF 0 分
const tfidfOnly = await mgr.search('我喜欢简洁回答');
expect(tfidfOnly).toHaveLength(0);
// 注入固定向量的 embedder:同义改写在向量空间中余弦 > 0
const VECTORS: Record<string, number[]> = {
'回复要短 style-rule': [1, 0.9, 0],
: [0.95, 1, 0.1],
xyz: [0, 0.1, 1],
};
mgr.setEmbedder({
embed: async (text) => {
for (const [k, v] of Object.entries(VECTORS)) {
if (text.includes(k)) return v;
}
return [0, 0, 1];
},
});
// 首次检索触发存量记忆的惰性向量回填(本轮仍走 TF-IDF → 0 命中)
await mgr.search('我喜欢简洁回答');
await new Promise((r) => setTimeout(r, 10));
// 回填完成后,向量路径生效 → 同义改写命中
const hybrid = await mgr.search('我喜欢简洁回答');
expect(hybrid.length).toBeGreaterThan(0);
expect(hybrid[0].content).toBe('回复要短');
expect(hybrid[0].score).toBeGreaterThan(0);
});
it('P1-1: embedder 抛错/返回 null → 回退纯 TF-IDF(行为兼容)', async () => {
mgr.store({
type: 'semantic',
content: 'TF-IDF 兜底验证',
summary: 'fallback-vec',
source: 'agent_thought',
importance: 0.9,
});
mgr.setEmbedder({
embed: async () => {
throw new Error('embed down');
},
});
const results = await mgr.search('TF-IDF 兜底验证');
expect(results.length).toBeGreaterThan(0);
mgr.setEmbedder({ embed: async () => null });
const results2 = await mgr.search('TF-IDF 兜底验证');
expect(results2.length).toBeGreaterThan(0);
});
it('P1-1: store 写入异步回填 embedding BLOB', async () => {
mgr.setEmbedder({ embed: async () => [0.5, 0.5, 0.5] });
mgr.store({
type: 'semantic',
content: '向量化回填验证',
summary: 'vec-backfill',
source: 'agent_thought',
importance: 0.8,
});
await new Promise((r) => setTimeout(r, 10));
const row = db
.prepare('SELECT embedding FROM semantic_memories WHERE key = ?')
.get('vec-backfill') as { embedding: Buffer | null };
expect(row.embedding).not.toBeNull();
expect(row.embedding!.length % 4).toBe(0);
});
});