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);
});
});
+41 -31
View File
@@ -26,6 +26,8 @@ import type { MetonaRequest } from '../types';
import type { WorkspaceService } from '../../services/workspace.service';
import type { IterationStep } from '../agent-loop/types';
import type { MemoryManager } from './manager';
// v0.8.1 P1-2: 分区条目摘要(与 Maintainer 共用,消除全文截断去重盲区)
import { parseMemoryEntries, buildMemoryEntriesDigest } from './maintainer';
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
@@ -91,10 +93,7 @@ export class MemoryConsolidator {
}, timeoutMs);
});
try {
await Promise.race([
this.runningPromise.catch(() => {}),
timer,
]);
await Promise.race([this.runningPromise.catch(() => {}), timer]);
return !timedOut;
} finally {
if (timerHandle) clearTimeout(timerHandle);
@@ -142,14 +141,21 @@ export class MemoryConsolidator {
): Promise<ConsolidationResult> {
try {
// 1. 构建对话摘要
const conversationDigest = this.buildConversationDigest(userMessage, assistantAnswer, iterations);
const conversationDigest = this.buildConversationDigest(
userMessage,
assistantAnswer,
iterations,
);
if (!conversationDigest) {
return { appended: 0, entries: [], skipped: 0 };
}
// 2. 读取当前 MEMORY.md 内容(供 LLM 去重)
// 2. 读取当前 MEMORY.md 条目摘要(供 LLM 去重)
// v0.8.1 P1-2 根治: 旧实现全文截 3000 字符,尾部条目对 LLM 不可见 → 去重
// 失效、重复写入。现用纯条目摘要(8000 字符预算),完整覆盖全部条目。
const currentMemory = this.workspaceService.getFiles().memory;
const memoryDigest = this.truncateMemoryForPrompt(currentMemory);
const memoryDigest =
buildMemoryEntriesDigest(parseMemoryEntries(currentMemory ?? '')) || '(empty)';
// 3. 调用 LLM 提取需要持久化的记忆
const llmResponse = await this.callLLMForExtraction(conversationDigest, memoryDigest);
@@ -205,7 +211,9 @@ export class MemoryConsolidator {
}
if (validEntries.length > 0) {
log.info(`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`);
log.info(
`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`,
);
}
return { appended: validEntries.length, entries: validEntries, skipped };
@@ -238,8 +246,10 @@ export class MemoryConsolidator {
const status = result?.success ? 'ok' : 'error';
const resultPreview = result?.result
? this.truncate(JSON.stringify(result.result), 200)
: result?.error ?? '';
toolSummaries.push(` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`);
: (result?.error ?? '');
toolSummaries.push(
` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`,
);
}
}
if (toolSummaries.length > 0) {
@@ -252,16 +262,6 @@ export class MemoryConsolidator {
return parts.join('\n\n');
}
/**
* 截断 MEMORY.md 内容用于 prompt(避免过长)
*/
private truncateMemoryForPrompt(memory: string): string {
if (!memory) return '(empty)';
// 截取前 3000 字符,保留分区结构概览
if (memory.length <= 3000) return memory;
return memory.slice(0, 3000) + '\n... (truncated)';
}
/**
* 调用 LLM 提取需要持久化的记忆
*/
@@ -280,7 +280,8 @@ export class MemoryConsolidator {
agentVersion: '1.0.0',
},
systemPrompt: {
roleDefinition: 'You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent\'s long-term memory file (MEMORY.md) for future sessions.',
roleDefinition:
"You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent's long-term memory file (MEMORY.md) for future sessions.",
outputConstraints: [
'Analyze the conversation below and extract ONLY information that meets ALL of these criteria:',
'1. Long-term value: will be useful in future conversations (not transient task state)',
@@ -294,13 +295,16 @@ export class MemoryConsolidator {
'If nothing is worth persisting, output an empty array: []',
'Output ONLY the JSON array, no markdown fences, no explanation.',
].join('\n'),
safetyGuidelines: 'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.',
safetyGuidelines:
'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.',
},
messages: [{
role: 'user',
content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`,
timestamp: Date.now(),
}],
messages: [
{
role: 'user',
content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`,
timestamp: Date.now(),
},
],
params: {
maxTokens: 1024,
temperature: 0.0,
@@ -341,7 +345,10 @@ export class MemoryConsolidator {
// 移除可能的 markdown 代码围栏
if (cleaned.startsWith('```')) {
cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
cleaned = cleaned
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/, '')
.trim();
}
try {
@@ -349,9 +356,12 @@ export class MemoryConsolidator {
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item): item is { section: string; entry: string } =>
typeof item === 'object' && item !== null &&
typeof item.section === 'string' && typeof item.entry === 'string',
.filter(
(item): item is { section: string; entry: string } =>
typeof item === 'object' &&
item !== null &&
typeof item.section === 'string' &&
typeof item.entry === 'string',
)
.map((item) => ({
section: item.section.trim(),
+19
View File
@@ -0,0 +1,19 @@
/**
* Memory Embedder — 本地向量记忆嵌入接口(v0.8.1 P1-1
*
* 职责边界:本模块只定义记忆系统消费的嵌入契约,不绑定任何 Provider 实现。
* main.ts 按用户配置装配:仅在 Provider 为 Ollama(本地推理,零成本、数据不出设备)
* 且设置面板配置了 `memory.embeddingModel` 时注入真实实现;否则保持 null,
* MemoryManager 自动回退纯 TF-IDF 检索(行为与历史版本完全兼容)。
*
* 根治背景:OllamaAdapter.embed() 自实现以来全项目零调用 —— 本地向量检索能力
* 一直躺在代码里,TF-IDF bigram 对同义改写("我喜欢简洁回答" vs "回复要短"
* 零召回。本契约激活该能力,检索升级为 混合评分(向量余弦 × TF-IDF)。
*/
/** 嵌入失败/不可用的统一返回:null = 本次无法向量化(调用方回退 TF-IDF 路径) */
export type MemoryEmbedFn = (text: string) => Promise<number[] | null>;
export interface MemoryEmbedder {
embed: MemoryEmbedFn;
}
+355
View File
@@ -0,0 +1,355 @@
/**
* Memory Maintainer — MEMORY.md 维护闭环(v0.8.1 P1-2
*
* 根治背景:MemoryConsolidator 纯 append-only —— ① 去重盲区:固化 prompt 只带
* 全文前 3000 字符,超出部分对 LLM 不可见,重复写入无法避免;② 只增不减:
* 过期/被推翻的条目无任何回收路径,MEMORY.md 随使用无限膨胀(>50KB 后固化
* prompt 与 system 注入双双劣化)。
*
* 本模块实现两阶段维护闭环(分析与应用分离,应用前必须经用户确认):
* 1. analyze()LLM 读取"分区条目摘要"(纯条目行,无全文截断盲区)→ 产出
* 结构化建议 {deletes[], updates[]}(去重 / 合并 / 清理过期);
* 2. apply():按用户勾选的动作改写 MEMORY.mdWorkspaceService.rewriteMemory
* 唯一合法写入口)并同步删除/更新 semantic_memories 对应行(双轨一致)。
*
* 安全边界:仅追加白名单分区、单次动作数上限、条目精确匹配(防 LLM 幻觉改写
* 无关内容)、全部动作写入 audit_logs。
*/
import { nanoid } from 'nanoid';
import log from 'electron-log';
import type Database from 'better-sqlite3';
import type { IMetonaProviderAdapter } from '../types/metona-adapter';
import type { MetonaRequest } from '../types';
import type { WorkspaceService } from '../../services/workspace.service';
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE / Consolidator 对齐) */
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
/** 动作数上限(防 LLM 过度建议) */
const MAX_ACTIONS = 30;
/** 单条目在 prompt 中的截断长度 */
const ENTRY_PROMPT_CHARS = 160;
/** 条目摘要总预算(字符)—— 纯条目行远小于全文,同预算下覆盖完整文件 */
const DIGEST_BUDGET_CHARS = 8000;
/** 一条维护动作(用户确认的输入/输出单元) */
export interface MemoryMaintenanceAction {
/** 动作类型:delete = 删除整行;merge = 用 newEntry 替换该行(合并多条时产生多条 update 指向同一 newEntry */
action: 'delete' | 'update';
section: string;
/** MEMORY.md 中该条目的当前完整文本(不含 "- " 前缀;精确匹配锚点) */
entry: string;
/** action=update 时的替换文本(合并后的新条目) */
newEntry?: string;
/** LLM 给出的理由(UI 展示) */
reason?: string;
}
export interface MemoryMaintenanceProposal {
actions: MemoryMaintenanceAction[];
/** 当前文件条目总数(UI 展示上下文) */
totalEntries: number;
/**
* v0.8.1 review (O2): 各分区当前条目数 —— 供维护弹框计算"应用后变空的分区"
* 并向用户提示(空分区保留分区头,条目区将显示为空)。
*/
sectionEntryCounts: Record<string, number>;
}
/** 解析后的分区结构(模块级类型 —— parseEntries/apply 共用) */
interface ParsedSection {
section: string;
entries: string[];
}
/** 解析 MEMORY.md 的分区与条目(模块级工具 —— Maintainer 与 Consolidator 共用) */
export function parseMemoryEntries(memory: string): ParsedSection[] {
const sections: ParsedSection[] = [];
let current: ParsedSection | null = null;
let inHead = true;
for (const line of memory.split('\n')) {
if (inHead) {
if (line.startsWith('## ')) inHead = false;
else continue;
}
const m = line.match(/^## (.+)$/);
if (m) {
current = { section: m[1].trim(), entries: [] };
sections.push(current);
continue;
}
const em = line.match(/^- (.+)$/);
if (em && current) {
current.entries.push(em[1].trim());
}
}
return sections;
}
/**
* 构建"分区条目摘要"(纯条目行,消除全文截断盲区)。
* Consolidator 固化去重与 Maintainer 分析共用:同预算(8000 字符)下纯条目
* 形态可覆盖完整文件,而旧的全文截断(3000 字符)会让 LLM 看不到尾部条目、
* 去重失效 → 重复写入。
*/
export function buildMemoryEntriesDigest(sections: ParsedSection[]): string {
const parts: string[] = [];
let used = 0;
for (const s of sections) {
if (s.entries.length === 0) continue;
const lines: string[] = [`## ${s.section}`];
for (const e of s.entries) {
const clipped = e.length > ENTRY_PROMPT_CHARS ? `${e.slice(0, ENTRY_PROMPT_CHARS)}...` : e;
lines.push(`- ${clipped}`);
}
const block = lines.join('\n');
if (used + block.length > DIGEST_BUDGET_CHARS) break;
parts.push(block);
used += block.length;
}
return parts.join('\n\n');
}
export class MemoryMaintainer {
constructor(
private getAdapter: () => IMetonaProviderAdapter,
private workspaceService: WorkspaceService,
private getDB: () => Database.Database,
) {}
/** 分析当前 MEMORY.md,产出维护建议(不改任何文件/DB) */
async analyze(): Promise<MemoryMaintenanceProposal> {
const memory = this.workspaceService.getFiles().memory ?? '';
const sections = this.parseEntries(memory);
const totalEntries = sections.reduce((n, s) => n + s.entries.length, 0);
const sectionEntryCounts: Record<string, number> = {};
for (const s of sections) {
sectionEntryCounts[s.section] = s.entries.length;
}
if (totalEntries === 0) {
return { actions: [], totalEntries: 0, sectionEntryCounts };
}
const digest = this.buildDigest(sections);
const raw = await this.callLLM(digest);
const actions = this.parseActions(raw, sections);
return { actions, totalEntries, sectionEntryCounts };
}
/** 应用用户确认的动作(只处理精确命中当前文件内容的动作,防幻觉改写) */
apply(actions: MemoryMaintenanceAction[]): { applied: number; skipped: number } {
const memory = this.workspaceService.getFiles().memory ?? '';
const sections = this.parseEntries(memory);
// 精确匹配校验:entry 必须原样存在于对应分区(LLM 响应与文件状态之间的一致性锚点)
const valid: MemoryMaintenanceAction[] = [];
for (const a of actions.slice(0, MAX_ACTIONS)) {
const section = sections.find((s) => s.section === a.section);
const exists = section?.entries.includes(a.entry) ?? false;
if (!exists) continue;
if (a.action === 'update' && (!a.newEntry || !a.newEntry.trim())) continue;
valid.push(a);
}
if (valid.length === 0) return { applied: 0, skipped: actions.length };
// 应用到内存结构:delete 直接删;update 替换文本
for (const a of valid) {
const section = sections.find((s) => s.section === a.section);
if (!section) continue;
if (a.action === 'delete') {
section.entries = section.entries.filter((e) => e !== a.entry);
} else {
section.entries = section.entries.map((e) => (e === a.entry ? a.newEntry!.trim() : e));
}
}
// 序列化回 Markdown(保留原文件头;分区结构重建)
const head = this.extractHead(memory);
const body = sections
.map((s) => `## ${s.section}\n${s.entries.map((e) => `- ${e}`).join('\n')}`)
.filter((s) => !s.endsWith('## ') && s.split('\n').length > 1)
.join('\n\n');
this.workspaceService.rewriteMemory(`${head}${body}\n`);
// 双轨一致:同步 semantic_memoriescontent 以 entry 写入 —— Consolidator 同口径)
const db = this.getDB();
const delStmt = db.prepare('DELETE FROM semantic_memories WHERE content = ?');
const updStmt = db.prepare(
'UPDATE semantic_memories SET content = ?, summary = ? WHERE content = ?',
);
let dbOps = 0;
for (const a of valid) {
try {
if (a.action === 'delete') {
dbOps += delStmt.run(a.entry).changes;
} else {
dbOps += updStmt.run(
a.newEntry!.trim(),
`[${a.section}] ${a.newEntry!.trim().slice(0, 60)}`,
a.entry,
).changes;
}
} catch (err) {
// DB 同步失败不影响 MEMORY.md 已写入结果(与 Consolidator 同语义)
log.warn('[MemoryMaintainer] semantic_memories sync failed:', (err as Error).message);
}
}
log.info(
`[MemoryMaintainer] applied ${valid.length} action(s) (db rows touched: ${dbOps}, skipped: ${actions.length - valid.length})`,
);
return { applied: valid.length, skipped: actions.length - valid.length };
}
// ===== 私有方法 =====
/** 解析 MEMORY.md 为 {section, entries[]} 结构(跳过元数据头;复用模块级工具) */
private parseEntries(memory: string): ParsedSection[] {
return parseMemoryEntries(memory);
}
/** 提取文件头(H1 + > 元数据区),供重建时保留 */
private extractHead(memory: string): string {
const lines = memory.split('\n');
let headEnd = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('## ')) {
headEnd = i;
break;
}
}
const headLines = lines.slice(0, headEnd).join('\n').trimEnd();
return headLines.length > 0 ? `${headLines}\n\n` : '';
}
/** 构建"分区条目摘要"(纯条目行,消除全文截断盲区) */
private buildDigest(sections: ParsedSection[]): string {
const parts: string[] = [];
let used = 0;
for (const s of sections) {
if (s.entries.length === 0) continue;
const lines: string[] = [`## ${s.section}`];
for (const e of s.entries) {
const clipped = e.length > ENTRY_PROMPT_CHARS ? `${e.slice(0, ENTRY_PROMPT_CHARS)}...` : e;
lines.push(`- ${clipped}`);
}
const block = lines.join('\n');
if (used + block.length > DIGEST_BUDGET_CHARS) break;
parts.push(block);
used += block.length;
}
return parts.join('\n\n');
}
/** LLM 分析(结构化 JSON 输出,30s 超时与 Consolidator 同口径) */
private async callLLM(digest: string): Promise<string | null> {
const sectionsList = ALLOWED_SECTIONS.map((s) => `"${s}"`).join(', ');
const request: MetonaRequest = {
meta: {
sessionId: 'memory-maintenance',
iteration: 0,
requestId: `mm_${nanoid(12)}`,
timestamp: Date.now(),
agentVersion: '1.0.0',
},
systemPrompt: {
roleDefinition:
"You are a memory curator maintaining the agent's long-term memory file (MEMORY.md).",
outputConstraints: [
'Analyze the memory entries below and propose maintenance actions:',
'- "delete": remove stale, superseded, duplicated, or completed entries',
'- "update": merge two or more duplicate/similar entries into ONE consolidated entry',
'Keep valuable, still-valid information — do NOT delete aggressively.',
`Every action must reference an existing entry EXACTLY as written (section must be one of ${sectionsList}).`,
'',
'Output ONLY a JSON array, no markdown fences:',
'[{"action":"delete","section":"...","entry":"...","reason":"..."},',
' {"action":"update","section":"...","entry":"old entry","newEntry":"merged entry","reason":"..."}]',
'If nothing needs maintenance, output []',
].join('\n'),
safetyGuidelines: 'Never propose deleting user preference facts without a clear reason.',
},
messages: [
{
role: 'user',
content: `## Current MEMORY.md entries:\n\n${digest}\n\n## Task:\nPropose maintenance actions. Output JSON array only.`,
timestamp: Date.now(),
},
],
params: {
maxTokens: 2048,
temperature: 0.0,
stream: false,
thinkingEnabled: false,
thinkingEffort: 'low',
},
};
try {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('maintenance analysis timeout')), 30_000);
});
const response = await Promise.race([this.getAdapter().send(request), timeoutPromise]);
return response.content.trim();
} finally {
if (timer) clearTimeout(timer);
}
} catch (error) {
log.warn('[MemoryMaintainer] LLM call failed:', (error as Error).message);
return null;
}
}
/** 解析 LLM 建议(丢弃非法 section / 空条目 / 超限动作) */
private parseActions(raw: string | null, sections: ParsedSection[]): MemoryMaintenanceAction[] {
if (!raw) return [];
let cleaned = raw.trim();
if (cleaned.startsWith('```')) {
cleaned = cleaned
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/, '')
.trim();
}
let parsed: unknown;
try {
parsed = JSON.parse(cleaned);
} catch {
log.warn('[MemoryMaintainer] failed to parse LLM response as JSON:', cleaned.slice(0, 200));
return [];
}
if (!Array.isArray(parsed)) return [];
const validSections = new Set<string>(ALLOWED_SECTIONS);
// 仅允许引用当前文件中真实存在的条目(先过滤一轮,双保险在 apply 中再做精确校验)
const existing = new Set<string>();
for (const s of sections) {
for (const e of s.entries) existing.add(e);
}
const out: MemoryMaintenanceAction[] = [];
for (const item of parsed.slice(0, MAX_ACTIONS)) {
if (!item || typeof item !== 'object') continue;
const a = item as Record<string, unknown>;
const action = a.action;
const section = typeof a.section === 'string' ? a.section.trim() : '';
const entry = typeof a.entry === 'string' ? a.entry.trim() : '';
if (action !== 'delete' && action !== 'update') continue;
if (!validSections.has(section) || !entry || !existing.has(entry)) continue;
if (action === 'update' && (typeof a.newEntry !== 'string' || !a.newEntry.trim())) continue;
out.push({
action,
section,
entry,
newEntry: action === 'update' ? (a.newEntry as string).trim() : undefined,
reason: typeof a.reason === 'string' ? a.reason.slice(0, 200) : undefined,
});
}
return out;
}
}
+430 -104
View File
@@ -17,6 +17,7 @@ import { nanoid } from 'nanoid';
import { createHash } from 'crypto';
import type Database from 'better-sqlite3';
import log from 'electron-log';
import type { MemoryEmbedder } from './embedder';
export type MemoryType = 'episodic' | 'semantic' | 'working';
export type MemorySource = 'user_input' | 'tool_result' | 'agent_thought' | 'imported';
@@ -94,7 +95,11 @@ function computeTF(tokens: string[]): Map<string, number> {
}
/** 计算余弦相似度的点积部分 */
function dotProduct(tf1: Map<string, number>, tf2: Map<string, number>, idf: Map<string, number>): number {
function dotProduct(
tf1: Map<string, number>,
tf2: Map<string, number>,
idf: Map<string, number>,
): number {
let sum = 0;
for (const [term, freq1] of tf1) {
const freq2 = tf2.get(term);
@@ -123,6 +128,37 @@ function timeDecayWeight(createdAt: number, now: number = Date.now()): number {
return Math.pow(0.5, ageDays / halfLifeDays);
}
// ===== 向量工具(v0.8.1 P1-1 本地向量混合检索) =====
/** Float32Array → SQLite BLOBlittle-endian 原生布局,Node/SQLite 同机读写安全) */
function float32ToBlob(vec: number[]): Buffer {
const f32 = Float32Array.from(vec);
return Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength);
}
/** SQLite BLOB → number[](维度/字节损坏时返回 null,调用方回退 TF-IDF 路径) */
function blobToFloat32(blob: unknown): number[] | null {
if (!Buffer.isBuffer(blob)) return null;
if (blob.length === 0 || blob.length % 4 !== 0) return null;
const f32 = new Float32Array(blob.buffer, blob.byteOffset, blob.length / 4);
return Array.from(f32);
}
/** 余弦相似度(零向量/维度不匹配返回 0) */
function cosineSimilarity(a: number[], b: number[]): number {
if (a.length === 0 || a.length !== b.length) return 0;
let dot = 0;
let na = 0;
let nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
if (na === 0 || nb === 0) return 0;
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
/**
* 记忆管理器
*/
@@ -136,8 +172,20 @@ export class MemoryManager {
/** 缓存有效期(5 分钟) */
private readonly CACHE_TTL = 5 * 60 * 1000;
/**
* v0.8.1 P1-1: 本地向量嵌入器(可选注入)。
* main.ts 仅在 Provider=Ollama 且用户配置了 memory.embeddingModel 时注入;
* null = 向量检索禁用,search/store 全部走纯 TF-IDF 路径(历史行为)。
*/
private embedder: MemoryEmbedder | null = null;
constructor(private getDB: () => Database.Database) {}
/** 注入向量嵌入器(传 null 关闭向量路径;热切换由 main.ts 在 adapter 重载时联动) */
setEmbedder(embedder: MemoryEmbedder | null): void {
this.embedder = embedder;
}
/**
* 初始化(表结构由 DatabaseService 创建)
*/
@@ -165,9 +213,15 @@ export class MemoryManager {
const newIdfCache = new Map<string, number>();
// 获取所有记忆内容(episodic + semantic + working
const episodicRows = db.prepare('SELECT content, summary FROM episodic_memories').all() as Array<{ content: string; summary: string | null }>;
const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ value: string }>;
const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{ value: string }>;
const episodicRows = db
.prepare('SELECT content, summary FROM episodic_memories')
.all() as Array<{ content: string; summary: string | null }>;
const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{
value: string;
}>;
const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{
value: string;
}>;
const allDocs = [
...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')),
@@ -244,6 +298,10 @@ export class MemoryManager {
source: MemorySource;
sessionId?: string;
expiresAt?: number;
/** v0.8.1 P1-1: 文档向量(embedding 列缺失/损坏时为 null → 单路 TF-IDF 评分) */
docVec?: number[] | null;
/** v0.8.1 P1-1: 查询向量(null → 单路 TF-IDF 评分) */
queryVec?: number[] | null;
},
queryTF: Map<string, number>,
queryNorm: number,
@@ -253,31 +311,59 @@ export class MemoryManager {
const docTF = computeTF(params.docTokens);
const docNorm = vectorNorm(docTF, this.idfCache);
if (docNorm === 0) return;
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
const cosineSim = dotProd / (queryNorm * docNorm);
if (docNorm === 0 && !(params.docVec && params.queryVec)) return;
// 时间衰减
const decayWeight = timeDecayWeight(params.createdAt, now);
// 最终分数 = 余弦相似度 * 时间衰减 * 重要度权重
const finalScore = cosineSim * decayWeight * (0.5 + params.importance * 0.5);
const importanceWeight = 0.5 + params.importance * 0.5;
// TF-IDF 路(docNorm=0 时得 0 分,交由向量路兜底)
const tfidfScore =
docNorm > 0
? (dotProduct(queryTF, docTF, this.idfCache) / (queryNorm * docNorm)) *
decayWeight *
importanceWeight
: 0;
// 向量路(双侧齐备时计算余弦)
const vectorScore =
params.docVec && params.queryVec
? cosineSimilarity(params.queryVec, params.docVec) * decayWeight * importanceWeight
: 0;
// v0.8.1 P1-1 混合评分:双侧齐备 0.6 向量 + 0.4 TF-IDF;否则取可用单路
let finalScore: number;
if (params.docVec && params.queryVec) {
finalScore = 0.6 * vectorScore + 0.4 * tfidfScore;
} else {
finalScore = tfidfScore > 0 ? tfidfScore : vectorScore;
}
if (finalScore > 0) {
results.push({
id: params.id, type: params.type, content: params.content,
summary: params.summary, source: params.source,
importance: params.importance, sessionId: params.sessionId,
createdAt: params.createdAt, expiresAt: params.expiresAt,
id: params.id,
type: params.type,
content: params.content,
summary: params.summary,
source: params.source,
importance: params.importance,
sessionId: params.sessionId,
createdAt: params.createdAt,
expiresAt: params.expiresAt,
score: finalScore,
});
}
}
/**
* TF-IDF 相似度搜索
* TF-IDF 相似度搜索v0.8.1 P1-1: 可选向量混合评分)
*
* @param queryVec 查询向量(embedder 未注入/失败时为 null → 纯 TF-IDF
*/
private tfidfSearch(query: string, options: MemorySearchOptions): SearchResult[] {
private tfidfSearch(
query: string,
options: MemorySearchOptions,
queryVec: number[] | null,
): SearchResult[] {
const db = this.getDB();
this.updateIdfCache();
@@ -296,71 +382,142 @@ export class MemoryManager {
// 搜索 episodic 记忆
if (!type || type === 'episodic') {
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT * FROM episodic_memories WHERE importance >= ?
ORDER BY importance DESC, created_at DESC LIMIT ?
`).all(minImportance, topK * 3) as Array<{
id: string; session_id: string | null; content: string; summary: string | null;
source: string; importance: number; created_at: number; expires_at: number | null;
`,
)
.all(minImportance, topK * 3) as Array<{
id: string;
session_id: string | null;
content: string;
summary: string | null;
source: string;
importance: number;
created_at: number;
expires_at: number | null;
tf_cache: string | null;
}>;
for (const row of rows) {
this.scoreAndPushMemory({
docTokens: this.cachedTokens(row.tf_cache, row.content + ' ' + (row.summary ?? '')),
createdAt: row.created_at,
importance: row.importance,
id: row.id, type: 'episodic', content: row.content,
summary: row.summary ?? undefined,
source: row.source as MemorySource,
sessionId: row.session_id ?? undefined,
expiresAt: row.expires_at ?? undefined,
}, queryTF, queryNorm, now, results);
this.scoreAndPushMemory(
{
docTokens: this.cachedTokens(row.tf_cache, row.content + ' ' + (row.summary ?? '')),
createdAt: row.created_at,
importance: row.importance,
id: row.id,
type: 'episodic',
content: row.content,
summary: row.summary ?? undefined,
source: row.source as MemorySource,
sessionId: row.session_id ?? undefined,
expiresAt: row.expires_at ?? undefined,
docVec: blobToFloat32((row as { embedding?: unknown }).embedding),
queryVec,
},
queryTF,
queryNorm,
now,
results,
);
}
this.backfillMissingEmbeddings(
'episodic',
rows.map((r) => ({
id: r.id,
embedding: (r as { embedding?: unknown }).embedding,
text: r.content + ' ' + (r.summary ?? ''),
})),
);
}
// 搜索 semantic 记忆
if (!type || type === 'semantic') {
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT * FROM semantic_memories WHERE confidence >= ?
ORDER BY confidence DESC, access_count DESC LIMIT ?
`).all(minImportance, Math.ceil(topK * 1.5)) as Array<{
id: string; key: string; value: string; category: string | null;
confidence: number; source_session: string | null; created_at: number;
`,
)
.all(minImportance, Math.ceil(topK * 1.5)) as Array<{
id: string;
key: string;
value: string;
category: string | null;
confidence: number;
source_session: string | null;
created_at: number;
tf_cache: string | null;
}>;
for (const row of rows) {
this.scoreAndPushMemory({
docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value),
createdAt: row.created_at,
importance: row.confidence,
id: row.id, type: 'semantic', content: row.value,
source: 'imported',
sessionId: row.source_session ?? undefined,
}, queryTF, queryNorm, now, results);
this.scoreAndPushMemory(
{
docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value),
createdAt: row.created_at,
importance: row.confidence,
id: row.id,
type: 'semantic',
content: row.value,
source: 'imported',
sessionId: row.source_session ?? undefined,
docVec: blobToFloat32((row as { embedding?: unknown }).embedding),
queryVec,
},
queryTF,
queryNorm,
now,
results,
);
}
this.backfillMissingEmbeddings(
'semantic',
rows.map((r) => ({
id: r.id,
embedding: (r as { embedding?: unknown }).embedding,
text: r.key + ' ' + r.value,
})),
);
}
// 搜索 working 记忆
if (!type || type === 'working') {
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?
`).all(topK * 3) as Array<{
id: string; session_id: string; task_id: string;
key: string; value: string; updated_at: number;
`,
)
.all(topK * 3) as Array<{
id: string;
session_id: string;
task_id: string;
key: string;
value: string;
updated_at: number;
tf_cache: string | null;
}>;
for (const row of rows) {
this.scoreAndPushMemory({
docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value),
createdAt: row.updated_at,
importance: 0.5,
id: row.id, type: 'working', content: row.value,
source: 'agent_thought',
sessionId: row.session_id,
}, queryTF, queryNorm, now, results);
this.scoreAndPushMemory(
{
docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value),
createdAt: row.updated_at,
importance: 0.5,
id: row.id,
type: 'working',
content: row.value,
source: 'agent_thought',
sessionId: row.session_id,
},
queryTF,
queryNorm,
now,
results,
);
}
}
@@ -383,11 +540,22 @@ export class MemoryManager {
switch (item.type) {
case 'episodic':
db.prepare(`
INSERT INTO episodic_memories (id, session_id, content, summary, source, importance, created_at, tf_cache)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id, item.sessionId ?? null, item.content, item.summary ?? null, item.source, importance, now,
db.prepare(
`
INSERT INTO episodic_memories (id, session_id, content, summary, source, importance, created_at, expires_at, tf_cache)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
item.sessionId ?? null,
item.content,
item.summary ?? null,
item.source,
importance,
now,
// v0.8.1 P0-2: expires_at 真实写入方 —— 调用方(MemoryTriggerHook 等)可携带
// TTL;此前该列全链路无写入方,cleanupExpired 空转,情节记忆只增不减
item.expiresAt ?? null,
// P2-12: 写入时预计算分词缓存,加速后续检索
JSON.stringify(tokenize(item.content + ' ' + (item.summary ?? ''))),
);
@@ -397,22 +565,38 @@ export class MemoryManager {
// #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重
// v0.3.0 用 id 作为 key 时,因 id 每次新生成,INSERT OR REPLACE 永远不触发 REPLACE
// 导致重复 store 同一内容会创建多条记忆。改为 contentHash 后,相同内容自动 REPLACE。
db.prepare(`
db.prepare(
`
INSERT OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count, tf_cache)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
`).run(
id, item.summary ?? this.contentHash(item.content), item.content, 'general', importance, item.sessionId ?? null, now, now,
`,
).run(
id,
item.summary ?? this.contentHash(item.content),
item.content,
'general',
importance,
item.sessionId ?? null,
now,
now,
JSON.stringify(tokenize((item.summary ?? '') + ' ' + item.content)),
);
break;
case 'working':
// v0.3.0 修复:使用 summary 作为 key(若提供),避免硬编码 'default' 导致覆盖
// #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重
db.prepare(`
db.prepare(
`
INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at, tf_cache)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
id, item.sessionId ?? 'default', 'default', item.summary ?? this.contentHash(item.content), item.content, now,
`,
).run(
id,
item.sessionId ?? 'default',
'default',
item.summary ?? this.contentHash(item.content),
item.content,
now,
JSON.stringify(tokenize((item.summary ?? '') + ' ' + item.content)),
);
break;
@@ -424,28 +608,117 @@ export class MemoryManager {
// 使 IDF 缓存失效
this.cacheUpdatedAt = 0;
// v0.8.1 P1-1: 异步向量化回填(fire-and-forget)—— 嵌入不可用时静默跳过,
// 该记忆保留 NULL embedding,检索时自动回退 TF-IDF 路径
this.enrichEmbedding(item.type, id, (item.summary ?? '') + ' ' + item.content);
log.debug(`Memory stored: ${id} (${item.type})`);
return id;
}
/**
* 检索记忆(v0.2.0: TF-IDF 语义检索 + 时间衰减)
*
* v0.2.0 变更:
* - 使用 TF-IDF 余弦相似度替代 LIKE 关键词搜索
* - 支持中英文分词(英文按词,中文按 bigram)
* - 时间衰减:30 天半衰期,老旧记忆权重降低
* - IDF 缓存:5 分钟有效期,避免重复计算
* v0.8.1 P1-1: 异步生成并回填 embedding BLOB。
* 失败静默(降级 TF-IDF),不阻塞写入方(工具执行/记忆固化均不等待)。
*/
search(query: string, options: MemorySearchOptions = {}): SearchResult[] {
private embeddingBackfillInFlight = new Set<string>();
private enrichEmbedding(type: MemoryType, id: string, text: string): void {
const embedder = this.embedder;
if (!embedder) return;
const table =
type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : null;
if (!table) return; // working 记忆会话级生命周期短,不参与向量检索
const key = `${table}:${id}`;
if (this.embeddingBackfillInFlight.has(key)) return;
this.embeddingBackfillInFlight.add(key);
void embedder
.embed(text.slice(0, 8000))
.then((vec) => {
if (!vec || vec.length === 0) return;
this.getDB()
.prepare(`UPDATE ${table} SET embedding = ? WHERE id = ?`)
.run(float32ToBlob(vec), id);
})
.catch((err) => {
log.debug(`MemoryManager: embedding enrichment skipped: ${(err as Error).message}`);
})
.finally(() => {
this.embeddingBackfillInFlight.delete(key);
});
}
/**
* v0.8.1 P1-1: 存量记忆向量惰性回填 —— 嵌入功能开启前写入的记忆(embedding
* IS NULL)在参与检索时排队补算:本轮查询仍走 TF-IDF,后续查询即可命中向量
* 路径。无阻塞、无独立迁移任务,收敛速度随检索频次自然提升;嵌入器不可用
* 时零开销(直接返回)。
*/
private backfillMissingEmbeddings(
type: MemoryType,
rows: Array<{ id: string; embedding?: unknown; text: string }>,
): void {
if (!this.embedder) return;
for (const row of rows) {
if (row.embedding != null) continue;
this.enrichEmbedding(type, row.id, row.text);
}
}
/**
* v0.8.1 P0-2: 检索命中后回写 semantic_memories.access_countLRU 淘汰语义激活)。
* 此前该列只在 ORDER BY 中被读取、从无更新方,LRU 淘汰是死语义。
*/
private bumpAccessCounts(results: SearchResult[]): void {
const semanticIds = results.filter((r) => r.type === 'semantic').map((r) => r.id);
if (semanticIds.length === 0) return;
try {
const placeholders = semanticIds.map(() => '?').join(', ');
this.getDB()
.prepare(
`UPDATE semantic_memories SET access_count = access_count + 1 WHERE id IN (${placeholders})`,
)
.run(...semanticIds);
} catch (err) {
// 计数回写失败不影响检索结果
log.debug('MemoryManager: access_count bump failed:', (err as Error).message);
}
}
/**
* 检索记忆(v0.2.0: TF-IDF 语义检索 + 时间衰减;v0.8.1 P1-1: 本地向量混合检索)
*
* v0.8.1 变更:
* - 方法改为 async(查询向量需经 MemoryEmbedder 异步生成;Ollama 本地嵌入)。
* - 混合评分:查询向量与文档向量齐备时 score = 0.6×向量余弦 + 0.4×TF-IDF
* (两者各自叠加时间衰减与重要度权重);任一缺失时回退单路评分 ——
* 未注入 embedder(或嵌入失败)时行为与历史版本完全一致。
* - 检索命中的 semantic 记忆回写 access_countLRU 语义激活,P0-2)。
*/
async search(query: string, options: MemorySearchOptions = {}): Promise<SearchResult[]> {
const db = this.getDB();
const { topK = 5, type, minImportance = 0 } = options;
// v0.3.0 修复:拦截空 query 和纯空格 query
if (!query || !query.trim()) return [];
// v0.2.0: 优先使用 TF-IDF 语义搜索
const tfidfResults = this.tfidfSearch(query, options);
// v0.8.1: 查询向量生成一次(嵌入器缺失/失败 → null,全量回退 TF-IDF
let queryVec: number[] | null = null;
if (this.embedder) {
try {
queryVec = await this.embedder.embed(query.slice(0, 8000));
if (queryVec && queryVec.length === 0) queryVec = null;
} catch (err) {
log.debug(
'MemoryManager: query embedding failed, falling back to TF-IDF:',
(err as Error).message,
);
queryVec = null;
}
}
// v0.2.0: 优先使用语义搜索(TF-IDF ± 向量混合)
const tfidfResults = this.tfidfSearch(query, options, queryVec);
if (tfidfResults.length > 0) {
this.bumpAccessCounts(tfidfResults);
return tfidfResults;
}
@@ -458,20 +731,35 @@ export class MemoryManager {
// 搜索情节记忆
if (!type || type === 'episodic') {
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT * FROM episodic_memories
WHERE (content LIKE ? ESCAPE '\\' OR summary LIKE ? ESCAPE '\\') AND importance >= ?
ORDER BY importance DESC, created_at DESC LIMIT ?
`).all(pattern, pattern, minImportance, topK) as Array<{
id: string; session_id: string | null; content: string; summary: string | null;
source: string; importance: number; created_at: number; expires_at: number | null;
`,
)
.all(pattern, pattern, minImportance, topK) as Array<{
id: string;
session_id: string | null;
content: string;
summary: string | null;
source: string;
importance: number;
created_at: number;
expires_at: number | null;
}>;
for (const row of rows) {
results.push({
id: row.id, type: 'episodic', content: row.content,
summary: row.summary ?? undefined, source: row.source as MemorySource,
importance: row.importance, sessionId: row.session_id ?? undefined,
createdAt: row.created_at, expiresAt: row.expires_at ?? undefined,
id: row.id,
type: 'episodic',
content: row.content,
summary: row.summary ?? undefined,
source: row.source as MemorySource,
importance: row.importance,
sessionId: row.session_id ?? undefined,
createdAt: row.created_at,
expiresAt: row.expires_at ?? undefined,
score: row.importance * timeDecayWeight(row.created_at),
});
}
@@ -479,20 +767,33 @@ export class MemoryManager {
// 搜索语义记忆
if (!type || type === 'semantic') {
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT * FROM semantic_memories
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') AND confidence >= ?
ORDER BY confidence DESC, access_count DESC LIMIT ?
`).all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{
id: string; key: string; value: string; category: string | null;
confidence: number; source_session: string | null; created_at: number;
`,
)
.all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{
id: string;
key: string;
value: string;
category: string | null;
confidence: number;
source_session: string | null;
created_at: number;
}>;
for (const row of rows) {
results.push({
id: row.id, type: 'semantic', content: row.value,
source: 'imported', importance: row.confidence,
id: row.id,
type: 'semantic',
content: row.value,
source: 'imported',
importance: row.confidence,
sessionId: row.source_session ?? undefined,
createdAt: row.created_at, score: row.confidence * timeDecayWeight(row.created_at),
createdAt: row.created_at,
score: row.confidence * timeDecayWeight(row.created_at),
});
}
}
@@ -500,25 +801,39 @@ export class MemoryManager {
// 搜索工作记忆
// v0.3.0 修复:LIKE 回退路径也需添加 !type 分支(与 tfidfSearch 保持一致)
if (!type || type === 'working') {
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT * FROM working_memories
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\')
ORDER BY updated_at DESC LIMIT ?
`).all(pattern, pattern, topK) as Array<{
id: string; session_id: string; task_id: string;
key: string; value: string; updated_at: number;
`,
)
.all(pattern, pattern, topK) as Array<{
id: string;
session_id: string;
task_id: string;
key: string;
value: string;
updated_at: number;
}>;
for (const row of rows) {
results.push({
id: row.id, type: 'working', content: row.value,
source: 'agent_thought', importance: 0.5,
sessionId: row.session_id, createdAt: row.updated_at,
id: row.id,
type: 'working',
content: row.value,
source: 'agent_thought',
importance: 0.5,
sessionId: row.session_id,
createdAt: row.updated_at,
score: 0.3 * timeDecayWeight(row.updated_at),
});
}
}
return results.sort((a, b) => b.score - a.score).slice(0, topK);
const finalResults = results.sort((a, b) => b.score - a.score).slice(0, topK);
this.bumpAccessCounts(finalResults);
return finalResults;
}
/**
@@ -526,9 +841,13 @@ export class MemoryManager {
*/
getWorkingMemory(sessionId: string, taskId: string = 'default'): Map<string, string> {
const db = this.getDB();
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT key, value FROM working_memories WHERE session_id = ? AND task_id = ?
`).all(sessionId, taskId) as Array<{ key: string; value: string }>;
`,
)
.all(sessionId, taskId) as Array<{ key: string; value: string }>;
return new Map(rows.map((r) => [r.key, r.value]));
}
@@ -537,10 +856,12 @@ export class MemoryManager {
*/
setWorkingMemory(sessionId: string, taskId: string, key: string, value: string): void {
const db = this.getDB();
db.prepare(`
db.prepare(
`
INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(`wm_${nanoid(8)}`, sessionId, taskId, key, value, Date.now());
`,
).run(`wm_${nanoid(8)}`, sessionId, taskId, key, value, Date.now());
}
/**
@@ -549,7 +870,10 @@ export class MemoryManager {
clearWorkingMemory(sessionId: string, taskId?: string): void {
const db = this.getDB();
if (taskId) {
db.prepare('DELETE FROM working_memories WHERE session_id = ? AND task_id = ?').run(sessionId, taskId);
db.prepare('DELETE FROM working_memories WHERE session_id = ? AND task_id = ?').run(
sessionId,
taskId,
);
} else {
db.prepare('DELETE FROM working_memories WHERE session_id = ?').run(sessionId);
}
@@ -560,7 +884,9 @@ export class MemoryManager {
*/
cleanupExpired(): number {
const db = this.getDB();
const result = db.prepare('DELETE FROM episodic_memories WHERE expires_at IS NOT NULL AND expires_at < ?').run(Date.now());
const result = db
.prepare('DELETE FROM episodic_memories WHERE expires_at IS NOT NULL AND expires_at < ?')
.run(Date.now());
return result.changes;
}