feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(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:
@@ -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 / buildMemoryEntriesDigest(v0.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 — sectionEntryCounts(v0.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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user