1060 lines
36 KiB
TypeScript
1060 lines
36 KiB
TypeScript
/**
|
||
* 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 / embedding /
|
||
// embedding_model 列 —— v0.8.2 P3-1 迁移 14 对齐)
|
||
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,
|
||
embedding_model TEXT
|
||
);
|
||
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,
|
||
embedding_model TEXT
|
||
);
|
||
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 */
|
||
}
|
||
});
|
||
|
||
// v0.8.2 P3-6 注释修正:v0.8.1 P0-2 已让 store() 真实写入 expires_at(本组
|
||
// 早期版本注释"INSERT 不含 expires_at 列"已过时)。此处直接经 SQL 写入仅为
|
||
// 测试夹具便利(绕过 store 的哈希/分词管线),锁定 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<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);
|
||
});
|
||
});
|
||
|
||
// ===== v0.8.2 P3-1: access_count 保留 + 模型指纹 =====
|
||
|
||
describe.skipIf(!dbAvailable)('MemoryManager — v0.8.2 P3-1', () => {
|
||
let db: InstanceType<typeof Database>;
|
||
let mgr: MemoryManager;
|
||
|
||
beforeEach(() => {
|
||
db = new Database(':memory:');
|
||
createMemorySchema(db);
|
||
mgr = new MemoryManager(() => db);
|
||
});
|
||
|
||
it('semantic 同内容重复 store → ON CONFLICT upsert,access_count 不归零', () => {
|
||
mgr.store({
|
||
type: 'semantic',
|
||
content: '用户偏好深色主题',
|
||
source: 'user_input',
|
||
importance: 0.9,
|
||
});
|
||
db.prepare('UPDATE semantic_memories SET access_count = 7').run();
|
||
// 同内容重复写入(key = contentHash,冲突命中同一行)
|
||
mgr.store({
|
||
type: 'semantic',
|
||
content: '用户偏好深色主题',
|
||
source: 'user_input',
|
||
importance: 0.9,
|
||
});
|
||
const rows = db.prepare('SELECT access_count, value FROM semantic_memories').all() as Array<{
|
||
access_count: number;
|
||
value: string;
|
||
}>;
|
||
// 旧 REPLACE 语义会删除重插 → 2 行且 access_count 归零
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0].access_count).toBe(7);
|
||
});
|
||
|
||
it('embedding_model 指纹:模型不匹配的向量按缺失处理并触发重算', async () => {
|
||
mgr.store({
|
||
type: 'semantic',
|
||
content: '指纹校验内容',
|
||
summary: 'fp-check',
|
||
source: 'agent_thought',
|
||
importance: 0.8,
|
||
});
|
||
await new Promise((r) => setTimeout(r, 10));
|
||
// 模拟"用户更换了 embedding 模型":旧向量标记为旧模型
|
||
db.prepare(
|
||
"UPDATE semantic_memories SET embedding_model = 'old-model' WHERE key = 'fp-check'",
|
||
).run();
|
||
let reembedCalls = 0;
|
||
mgr.setEmbedder({
|
||
modelName: 'new-model',
|
||
embed: async () => {
|
||
reembedCalls += 1;
|
||
return [0.1, 0.2, 0.3];
|
||
},
|
||
});
|
||
const results = await mgr.search('指纹校验内容');
|
||
// 检索本身可用(回退 TF-IDF);旧向量被排队用新模型重算
|
||
expect(results.length).toBeGreaterThan(0);
|
||
await new Promise((r) => setTimeout(r, 10));
|
||
expect(reembedCalls).toBeGreaterThanOrEqual(1);
|
||
const row = db
|
||
.prepare("SELECT embedding, embedding_model FROM semantic_memories WHERE key = 'fp-check'")
|
||
.get() as { embedding: Buffer | null; embedding_model: string | null };
|
||
expect(row.embedding_model).toBe('new-model');
|
||
expect(row.embedding).not.toBeNull();
|
||
});
|
||
});
|