/** * MemoryManager 测试(v0.2.0 TF-IDF 检索 —— 此前零测试) * * 锁定记忆管理器核心契约: * 1. tokenize:英文词/CJK bigram/单字 CJK 子句/跨标点不组合/小写归一 * 2. tfidfSearch:余弦打分排序/时间衰减(30 天半衰期)/重要性权重/type 过滤/topK * 3. search:TF-IDF 无命中回退 LIKE/ESCAPE 转义/topK/threshold/sessionId 无效 * 4. store:三层记忆/importance 默认计算/semantic contentHash 去重/working 覆盖/tf_cache * 5. working memory CRUD:get/set/clear * 6. cleanupExpired * * 运行要求:better-sqlite3 为 Electron ABI 构建(test:electron 模式),系统 Node 下自动跳过。 */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); let dbAvailable = true; let Database: typeof import('better-sqlite3'); try { // eslint-disable-next-line @typescript-eslint/no-require-imports Database = require('better-sqlite3'); const probe = new Database(':memory:'); probe.close(); } catch { dbAvailable = false; } import { MemoryManager } from '../manager'; // 与 DatabaseService.createTables 一致的记忆三表 schema(含 tf_cache 列) function createMemorySchema(db: any): void { db.exec(` CREATE TABLE episodic_memories ( id TEXT PRIMARY KEY, session_id TEXT, content TEXT NOT NULL, summary TEXT, source TEXT NOT NULL, importance REAL DEFAULT 0.5, created_at INTEGER NOT NULL DEFAULT 0, expires_at INTEGER, tf_cache TEXT, embedding BLOB ); CREATE TABLE semantic_memories ( id TEXT PRIMARY KEY, key TEXT NOT NULL UNIQUE, value TEXT NOT NULL, category TEXT, confidence REAL DEFAULT 0.8, source_session TEXT, created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0, access_count INTEGER DEFAULT 0, tf_cache TEXT, embedding BLOB ); CREATE TABLE working_memories ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, task_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT 0, tf_cache TEXT, UNIQUE(session_id, task_id, key) ); `); } describe.skipIf(!dbAvailable)('MemoryManager — tokenize 分词', () => { let mgr: MemoryManager; beforeAll(() => { const db = new Database(':memory:'); createMemorySchema(db); mgr = new MemoryManager(() => db); }); afterAll(() => { (mgr as unknown as { getDB(): any }).getDB().close(); }); // 通过 store + 读取 tf_cache 间接验证分词(tokenize 为模块私有) // tf_cache = JSON.stringify(tokenize(content + ' ' + summary)) function tokensOf(content: string, summary = ''): string[] { mgr.store({ type: 'episodic', content, summary, source: 'user_input', importance: 0.7, sessionId: 'tok', }); const db = (mgr as unknown as { getDB(): any }).getDB(); const row = db .prepare('SELECT tf_cache FROM episodic_memories ORDER BY rowid DESC LIMIT 1') .get() as { tf_cache: string }; return JSON.parse(row.tf_cache) as string[]; } it('英文小写归一且按单词切分(含数字与连字符/下划线)', () => { const tokens = tokensOf('Hello World AI-agent v2.0'); expect(tokens).toContain('hello'); expect(tokens).toContain('world'); expect(tokens).toContain('ai-agent'); expect(tokens).toContain('v2'); expect(tokens).not.toContain('Hello'); // 大小写归一 expect(tokens).not.toContain('hello world'); // 不产生英文 bigram }); it('CJK 子句内 bigram:两字子句整体为一个 bigram', () => { const tokens = tokensOf('学习'); expect(tokens).toContain('学习'); }); it('CJK 长子句产生相邻 bigram(连续窗口)', () => { const tokens = tokensOf('深度学习模型'); expect(tokens).toContain('深度'); expect(tokens).toContain('度学'); expect(tokens).toContain('学习'); expect(tokens).toContain('习模'); expect(tokens).toContain('模型'); }); it('CJK 三字文本产生两个相邻 bigram(不含整串)', () => { const tokens = tokensOf('人工智能'); expect(tokens).toContain('人工'); expect(tokens).toContain('工智'); expect(tokens).toContain('智能'); expect(tokens).not.toContain('人工智能'); }); it('跨标点边界不产生噪声 bigram(v0.3.18 修复)', () => { const tokens = tokensOf('开发规范。下一句'); expect(tokens).not.toContain('范。'); expect(tokens).not.toContain('。下'); expect(tokens).toContain('开发'); expect(tokens).toContain('规范'); expect(tokens).toContain('下一'); expect(tokens).toContain('一句'); }); it('单字 CJK 子句补 unigram(避免单字文档无 token)', () => { const tokens = tokensOf('AI,好'); expect(tokens).toContain('好'); }); it('英文标点 .;!?() 同样切分子句', () => { const tokens = tokensOf('第一句.第二句;第三句'); expect(tokens).not.toContain('句.'); expect(tokens).not.toContain('.第'); expect(tokens).not.toContain('句;'); expect(tokens).toContain('第一'); }); it('数字/纯符号文本产生空 token 列表(search 返回空)', () => { const tokens = tokensOf('12345 !@#'); expect(Array.isArray(tokens)).toBe(true); // v0.8.0 P3: 补强断言 —— 旧断言只验数组类型未验为空 expect(tokens).toHaveLength(0); }); }); describe.skipIf(!dbAvailable)('MemoryManager — store 三层记忆', () => { let db: any; let mgr: MemoryManager; beforeEach(() => { db = new Database(':memory:'); createMemorySchema(db); mgr = new MemoryManager(() => db); }); afterEach(() => { try { db.close(); } catch { /* ignore */ } }); it('episodic store:默认 importance 按 source 计算(user_input=0.7)', () => { // store() 的类型签名要求 importance 必填,但运行时 `item.importance ?? calculateImportance` // 视其为可缺省;本用例锁定默认计算路径,故显式传 undefined 触发回退(见 manager.ts store())。 const id = mgr.store({ type: 'episodic', content: '用户说喜欢深色主题', source: 'user_input', sessionId: 's1', importance: undefined as unknown as number, }); const row = db.prepare('SELECT * FROM episodic_memories WHERE id = ?').get(id) as Record< string, unknown >; expect(row.content).toBe('用户说喜欢深色主题'); expect(row.session_id).toBe('s1'); expect(row.source).toBe('user_input'); expect(row.importance).toBe(0.7); // 0.5 + 0.2 }); it('importance 显式传入时不被覆盖', () => { const id = mgr.store({ type: 'episodic', content: '重要事实', source: 'agent_thought', importance: 0.95, }); const row = db.prepare('SELECT importance FROM episodic_memories WHERE id = ?').get(id) as { importance: number; }; expect(row.importance).toBe(0.95); }); it('episodic importance 计算:tool_result=0.6、长内容 +0.1、上限 1', () => { // 同上:显式 undefined 触发运行时默认计算路径 const id = mgr.store({ type: 'episodic', content: 'x'.repeat(250), source: 'tool_result', importance: undefined as unknown as number, }); const row = db.prepare('SELECT importance FROM episodic_memories WHERE id = ?').get(id) as { importance: number; }; expect(row.importance).toBe(0.7); // 0.5 + 0.1(tool_result) + 0.1(长内容) }); it('episodic store 写入 tf_cache(JSON token 数组)', () => { mgr.store({ type: 'episodic', content: '缓存分词验证', source: 'user_input', importance: 0.7 }); const row = db .prepare('SELECT tf_cache FROM episodic_memories ORDER BY rowid DESC LIMIT 1') .get() as { tf_cache: string }; const tokens = JSON.parse(row.tf_cache) as string[]; expect(tokens).toContain('缓存'); expect(tokens).toContain('分词'); expect(tokens).toContain('验证'); }); it('semantic store:未提供 summary 时用 contentHash 作为 key(内容去重)', () => { mgr.store({ type: 'semantic', content: '项目采用 SQLite', source: 'imported', importance: 0.5, }); mgr.store({ type: 'semantic', content: '项目采用 SQLite', source: 'imported', importance: 0.5, }); const rows = db.prepare('SELECT * FROM semantic_memories').all() as Array<{ key: string; value: string; }>; expect(rows).toHaveLength(1); // 相同内容 REPLACE 为一条 expect(rows[0].value).toBe('项目采用 SQLite'); expect(rows[0].key).not.toContain('mem_'); // key 是 content hash 而非随机 id }); it('semantic store:提供 summary 时以 summary 为 key(更新已有记忆)', () => { mgr.store({ type: 'semantic', content: '旧值', summary: '用户昵称', source: 'imported', importance: 0.5, }); mgr.store({ type: 'semantic', content: '新值', summary: '用户昵称', source: 'imported', importance: 0.5, }); const rows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ value: string; }>; expect(rows).toHaveLength(1); expect(rows[0].value).toBe('新值'); }); it('semantic store:category 固定为 general、confidence=importance', () => { const id = mgr.store({ type: 'semantic', content: '事实', source: 'imported', importance: 0.88, }); const row = db .prepare('SELECT category, confidence FROM semantic_memories WHERE id = ?') .get(id) as { category: string; confidence: number }; expect(row.category).toBe('general'); expect(row.confidence).toBe(0.88); }); it('working store:未提供 summary 用 contentHash 为 key,同内容同 session 覆盖', () => { mgr.store({ type: 'working', content: '正在处理的文件', sessionId: 's1', importance: 0.5, source: 'agent_thought', }); mgr.store({ type: 'working', content: '正在处理的文件', sessionId: 's1', importance: 0.5, source: 'agent_thought', }); const rows = db .prepare("SELECT * FROM working_memories WHERE session_id = 's1'") .all() as unknown[]; expect(rows).toHaveLength(1); }); it('working store:不同 session 同内容互不影响', () => { mgr.store({ type: 'working', content: '共享内容', sessionId: 's1', importance: 0.5, source: 'agent_thought', }); mgr.store({ type: 'working', content: '共享内容', sessionId: 's2', importance: 0.5, source: 'agent_thought', }); const rows = db.prepare('SELECT * FROM working_memories').all() as unknown[]; expect(rows).toHaveLength(2); }); it('未知 type 抛错而非静默失败(v0.3.0 修复)', () => { expect(() => mgr.store({ type: 'bogus' as 'episodic', content: 'x', source: 'user_input', importance: 0.7, }), ).toThrow(/Unknown memory type/); }); it('store 使 IDF 缓存失效(cacheUpdatedAt 重置)', async () => { // v0.7.4 强化断言: 若 IDF 缓存未失效/检索不扫描新行,store 后 search 返回空即失败。 mgr.store({ type: 'episodic', content: 'hello world', source: 'user_input', importance: 0.7 }); await mgr.search('hello'); // 建立 IDF 缓存 // 再 store 一条 → 缓存应失效,新内容可被检索 mgr.store({ type: 'episodic', content: 'another content', source: 'user_input', importance: 0.7, }); const results = await mgr.search('another'); expect(results.some((r) => r.content === 'another content')).toBe(true); // 双向验证:缓存重建后旧内容仍可检索(不因重建丢失) const oldResults = await mgr.search('hello'); expect(oldResults.some((r) => r.content === 'hello world')).toBe(true); }); it('返回的 id 可被后续读取(id 稳定)', () => { const id = mgr.store({ type: 'episodic', content: 'stable', source: 'user_input', importance: 0.7, }); const row = db.prepare('SELECT id FROM episodic_memories WHERE id = ?').get(id); expect(row).toBeDefined(); }); }); describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', () => { let db: any; let mgr: MemoryManager; let clock: number; beforeEach(() => { db = new Database(':memory:'); createMemorySchema(db); mgr = new MemoryManager(() => db); // 固定"现在",避免真实时间漂移造成 flaky clock = Date.now(); vi.spyOn(Date, 'now').mockImplementation(() => clock); }); afterEach(() => { vi.restoreAllMocks(); try { db.close(); } catch { /* ignore */ } }); it('相同关键词:得分高者排前(内容重复度越高得分越高)', async () => { mgr.store({ type: 'episodic', content: 'memory hello world test', source: 'user_input', importance: 0.7, }); mgr.store({ type: 'episodic', content: 'hello world', source: 'user_input', importance: 0.7 }); mgr.store({ type: 'episodic', content: '完全无关的内容', source: 'user_input', importance: 0.7, }); const results = await mgr.search('hello world'); expect(results.length).toBeGreaterThan(0); expect(results.every((r) => r.score > 0)).toBe(true); // 两条命中的按分数降序 const scores = results.map((r) => r.score); expect([...scores].sort((a, b) => b - a)).toEqual(scores); }); it('时间衰减:同内容越新得分越高(30 天半衰期)', async () => { mgr.store({ type: 'episodic', content: '关键 bug 修复方案', source: 'user_input', importance: 0.7, }); const newId = mgr.store({ type: 'episodic', content: '关键 bug 修复方案', source: 'user_input', importance: 0.7, }); // 使旧记录过期 30 天 const oldId = mgr.store({ type: 'episodic', content: '关键 bug 修复方案', source: 'user_input', importance: 0.7, }); // 直接改 created_at:两条旧、一条新 db.prepare('UPDATE episodic_memories SET created_at = ? WHERE id = ?').run( clock - 60 * 24 * 3600 * 1000, // 60 天前 oldId, ); db.prepare('UPDATE episodic_memories SET created_at = ? WHERE id = ?').run( clock - 30 * 24 * 3600 * 1000, // 30 天前(衰减 0.5) newId, ); 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); expect(newResult).toBeDefined(); expect(oldResult).toBeDefined(); expect(newResult!.score).toBeGreaterThan(oldResult!.score); }); it('半衰期数学:30 天衰减系数恰为 0.5(score 相对无衰减×0.5)', async () => { // 新鲜记录(0 天) const freshId = mgr.store({ type: 'episodic', content: '衰减数学验证内容', source: 'user_input', importance: 0.7, }); // 30 天记录 const agedId = mgr.store({ type: 'episodic', content: '衰减数学验证内容', source: 'user_input', importance: 0.7, }); db.prepare('UPDATE episodic_memories SET created_at = ? WHERE id = ?').run( clock - 30 * 24 * 3600 * 1000, agedId, ); 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 相同 // 故 aged.score / fresh.score ≈ 0.5(允许浮点误差) expect(aged.score / fresh.score).toBeCloseTo(0.5, 1); }); it('importance 权重:0.5 + importance*0.5 缩放(importance=1 得分为 0 的 2 倍)', async () => { const lowId = mgr.store({ type: 'episodic', content: '重要性权重验证', source: 'user_input', importance: 0, }); const highId = mgr.store({ type: 'episodic', content: '重要性权重验证', source: 'user_input', importance: 1, }); // 同一时间创建,重要性不同 → factor = 0.5+0*0.5 vs 0.5+1*0.5 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 参与分词)', async () => { mgr.store({ type: 'semantic', content: '用户偏好深色主题', source: 'imported', importance: 0.5, }); const results = await mgr.search('偏好'); expect(results.some((r) => r.type === 'semantic')).toBe(true); }); it('working 记忆可被检索(key+value 参与分词,importance 固定 0.5)', async () => { mgr.store({ type: 'working', content: '当前任务文件', sessionId: 's1', importance: 0.5, source: 'agent_thought', }); const results = await mgr.search('当前任务'); expect(results.some((r) => r.type === 'working')).toBe(true); }); it('type 过滤:仅返回指定类型', async () => { mgr.store({ type: 'episodic', content: 'typefilter 内容', source: 'user_input', importance: 0.7, }); mgr.store({ type: 'semantic', content: 'typefilter 内容', source: 'imported', importance: 0.5, }); const episodic = await mgr.search('typefilter', { type: 'episodic' }); expect(episodic.every((r) => r.type === 'episodic')).toBe(true); const semantic = await mgr.search('typefilter', { type: 'semantic' }); expect(semantic.every((r) => r.type === 'semantic')).toBe(true); }); it('topK 限制返回条数', async () => { for (let i = 0; i < 8; i++) { mgr.store({ type: 'episodic', content: `topk 内容 ${i}`, source: 'user_input', importance: 0.7, }); } const results = await mgr.search('topk 内容'); expect(results.length).toBeLessThanOrEqual(5); // 默认 topK=5 const results2 = await mgr.search('topk 内容', { topK: 2 }); expect(results2.length).toBeLessThanOrEqual(2); }); 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 = 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 才返回)', async () => { mgr.store({ type: 'episodic', content: 'score 数学', source: 'user_input', importance: 0.7 }); const results = await mgr.search('score 数学'); expect(results.length).toBeGreaterThan(0); for (const r of results) { expect(r.score).toBeGreaterThan(0); expect(r.score).toBeLessThanOrEqual(1.5); // 理论最大值 } }); }); describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () => { let db: any; let mgr: MemoryManager; beforeEach(() => { db = new Database(':memory:'); createMemorySchema(db); mgr = new MemoryManager(() => db); }); afterEach(() => { try { db.close(); } catch { /* ignore */ } }); it('空查询与纯空白查询返回空数组', async () => { expect(await mgr.search('')).toEqual([]); expect(await mgr.search(' ')).toEqual([]); expect(await mgr.search('', { topK: 3 })).toEqual([]); }); it('无匹配关键词返回空数组(不抛错)', async () => { mgr.store({ type: 'episodic', content: '存在的关键词', source: 'user_input', importance: 0.7 }); // "完全无关联" 的 bigram 与文档无重叠 → TF-IDF 0 命中;LIKE 也无子串 → [] expect(await mgr.search('完全无关联')).toEqual([]); }); it('英文无命中时回退 LIKE 子串搜索', async () => { mgr.store({ type: 'episodic', content: 'hello world network', source: 'user_input', importance: 0.7, }); // query "lo wo" 分词为 ['lo','wo'],与文档 token 无重叠 → TF-IDF 0 命中 // 但 "%lo wo%" 是 "hello world" 的连续子串 → LIKE 回退命中 const results = await mgr.search('lo wo'); expect(results.some((r) => r.content.includes('hello world'))).toBe(true); }); it('LIKE 回退时 LIKE 通配符 % 与 _ 被转义(不当作通配符)', async () => { mgr.store({ type: 'episodic', content: '使用 50% 折扣 与 under_score', source: 'user_input', importance: 0.7, }); mgr.store({ type: 'episodic', content: '完全无关的内容', source: 'user_input', importance: 0.7, }); // 查询 "%":分词为空 → 强制走 LIKE;若 % 未转义会匹配所有记录 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 = await mgr.search('_'); expect(underscore.some((r) => r.content === '完全无关的内容')).toBe(false); }); it('LIKE 回退时反斜杠被转义(Windows 路径不报错)', async () => { mgr.store({ type: 'episodic', content: '路径 C:\\Users\\test', source: 'user_input', importance: 0.7, }); // 反斜杠单独作为查询 → tokenize 为空 → LIKE 路径;不转义会导致 SQLite 报错 await expect(mgr.search('\\')).resolves.toBeInstanceOf(Array); }); it('search 的 topK 同时作用于回退路径', async () => { for (let i = 0; i < 6; i++) { mgr.store({ type: 'episodic', content: `backup${i} 数据`, source: 'user_input', importance: 0.7, }); } const results = await mgr.search('backup', { topK: 3 }); expect(results.length).toBeLessThanOrEqual(3); }); it('search 无结果时回退 LIKE 的 score = importance * timeDecay', async () => { mgr.store({ type: 'episodic', content: 'fallbackscore 内容', source: 'user_input', importance: 0.8, }); // "allbackscor" 分词不在文档 token 中 → TF-IDF 0 命中;LIKE %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 降序返回', 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 = await mgr.search('排序验证'); expect(results[0].importance).toBe(0.9); }); }); describe.skipIf(!dbAvailable)('MemoryManager — working memory CRUD', () => { let db: any; let mgr: MemoryManager; beforeEach(() => { db = new Database(':memory:'); createMemorySchema(db); mgr = new MemoryManager(() => db); }); afterEach(() => { try { db.close(); } catch { /* ignore */ } }); it('setWorkingMemory 后可 getWorkingMemory 读回', () => { mgr.setWorkingMemory('s1', 'task1', 'currentFile', '/src/a.ts'); const wm = mgr.getWorkingMemory('s1', 'task1'); expect(wm.get('currentFile')).toBe('/src/a.ts'); }); it('getWorkingMemory 不同 task 隔离', () => { mgr.setWorkingMemory('s1', 'task1', 'k', 'v1'); mgr.setWorkingMemory('s1', 'task2', 'k', 'v2'); expect(mgr.getWorkingMemory('s1', 'task1').get('k')).toBe('v1'); expect(mgr.getWorkingMemory('s1', 'task2').get('k')).toBe('v2'); }); it('setWorkingMemory 同 key 覆盖(INSERT OR REPLACE)', () => { mgr.setWorkingMemory('s1', 'task1', 'k', 'old'); mgr.setWorkingMemory('s1', 'task1', 'k', 'new'); expect(mgr.getWorkingMemory('s1', 'task1').get('k')).toBe('new'); expect(mgr.getWorkingMemory('s1', 'task1').size).toBe(1); }); it('getWorkingMemory 无记录返回空 Map', () => { expect(mgr.getWorkingMemory('nobody')).toEqual(new Map()); }); it('clearWorkingMemory 按 session 清除全部 task', () => { mgr.setWorkingMemory('s1', 'task1', 'a', '1'); mgr.setWorkingMemory('s1', 'task2', 'b', '2'); mgr.setWorkingMemory('s2', 'task1', 'c', '3'); mgr.clearWorkingMemory('s1'); expect(mgr.getWorkingMemory('s1', 'task1').size).toBe(0); expect(mgr.getWorkingMemory('s1', 'task2').size).toBe(0); // 其他会话不受影响 expect(mgr.getWorkingMemory('s2', 'task1').get('c')).toBe('3'); }); it('clearWorkingMemory 指定 taskId 只清该 task', () => { mgr.setWorkingMemory('s1', 'task1', 'a', '1'); mgr.setWorkingMemory('s1', 'task2', 'b', '2'); mgr.clearWorkingMemory('s1', 'task1'); expect(mgr.getWorkingMemory('s1', 'task1').size).toBe(0); expect(mgr.getWorkingMemory('s1', 'task2').get('b')).toBe('2'); }); it('clearWorkingMemory 对空会话不抛错', () => { expect(() => mgr.clearWorkingMemory('ghost')).not.toThrow(); }); }); describe.skipIf(!dbAvailable)('MemoryManager — cleanupExpired', () => { let db: any; let mgr: MemoryManager; let clock: number; beforeEach(() => { db = new Database(':memory:'); createMemorySchema(db); mgr = new MemoryManager(() => db); clock = Date.now(); vi.spyOn(Date, 'now').mockImplementation(() => clock); }); afterEach(() => { vi.restoreAllMocks(); try { db.close(); } catch { /* ignore */ } }); // 注意:manager.store() 的 episodic INSERT 不含 expires_at 列(源码已知缺口, // main.ts 注释亦确认"expires_at 无写入方")—— 本组用例直接经 SQL 写入 // expires_at 模拟真实过期行,锁定 cleanupExpired 自身的删除契约。 const insertExpiring = (id: string, expiresAt: number, content = '带过期记忆'): void => { db.prepare( `INSERT INTO episodic_memories (id, content, source, importance, created_at, expires_at) VALUES (?, ?, 'user_input', 0.5, ?, ?)`, ).run(id, content, clock, expiresAt); }; it('删除已过期 episodic 记忆并返回删除条数', () => { insertExpiring('expired', clock - 1000); insertExpiring('live', clock + 1000); const deleted = mgr.cleanupExpired(); expect(deleted).toBe(1); expect( db.prepare('SELECT id FROM episodic_memories WHERE id = ?').get('expired'), ).toBeUndefined(); expect(db.prepare('SELECT id FROM episodic_memories WHERE id = ?').get('live')).toBeDefined(); }); it('无 expires_at 的记忆永不过期(不受 cleanup 影响)', () => { const id = mgr.store({ type: 'episodic', content: '永久记忆', source: 'user_input', importance: 0.7, }); expect(mgr.cleanupExpired()).toBe(0); expect(db.prepare('SELECT id FROM episodic_memories WHERE id = ?').get(id)).toBeDefined(); }); it('expires_at 恰等于当前时间视为未过期(< 严格小于)', () => { insertExpiring('boundary', clock); expect(mgr.cleanupExpired()).toBe(0); }); it('空表 cleanupExpired 返回 0', () => { expect(mgr.cleanupExpired()).toBe(0); }); it('semantic/working 不参与 cleanup(仅 episodic 有过期语义)', () => { mgr.store({ type: 'semantic', content: '语义记忆', source: 'imported', importance: 0.5 }); mgr.store({ type: 'working', content: '工作记忆', sessionId: 's1', importance: 0.5, source: 'agent_thought', }); expect(mgr.cleanupExpired()).toBe(0); }); it('多条过期记忆一次性全部清理(返回删除总数)', () => { insertExpiring('e1', clock - 10); insertExpiring('e2', clock - 100); insertExpiring('e3', clock - 1000); insertExpiring('live', clock + 1); expect(mgr.cleanupExpired()).toBe(3); 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 = { '回复要短 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); }); });