Files
metona-ai-desktop/electron/harness/memory/manager.ts
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

1095 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Memory Manager — 记忆管理器
*
* 基于 SQLitebetter-sqlite3)的三层记忆系统。
* 表结构由 DatabaseService 统一创建,此处不再重复。
*
* v0.2.0 增强:
* - TF-IDF 语义检索替代 LIKE 关键词搜索
* - 时间衰减策略:老旧记忆权重降低
* - IDF 缓存:避免每次搜索重新计算
*
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第六章
* @see standard/开发规范.md — 使用 better-sqlite3(禁止自写数据库层)
*/
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';
export interface MemoryItem {
id: string;
type: MemoryType;
content: string;
summary?: string;
source: MemorySource;
importance: number;
sessionId?: string;
createdAt: number;
expiresAt?: number;
}
export interface SearchResult extends MemoryItem {
score: number;
}
interface MemorySearchOptions {
topK?: number;
sessionId?: string;
type?: MemoryType;
minImportance?: number;
}
/**
* 分词:将文本拆分为词项(支持中英文)
*
* v0.3.18 修复: 改进中文分词策略,减少 bigram 噪声
* 之前对整段文本连续取 bigram,会跨越标点边界产生无意义组合
* (如"开发规范。下一句"会产生"范。"和"。下"这类噪声 bigram),
* 降低 IDF 区分度。
* 现在先按中英文标点切分子句,再在每个子句内做 bigram,
* 避免跨句组合,提升检索准确度。
*/
function tokenize(text: string): string[] {
// 转小写
const lower = text.toLowerCase();
// 英文词
const words = lower.match(/[a-z][a-z0-9_-]{1,}/g) ?? [];
// v0.3.18 修复: 按中英文标点切分子句,再在每个子句内做 bigram
// 标点包括:中文句号/逗号/顿号/分号/感叹/问号 + 英文 .,;!?()
const sentences = lower.split(/[。,、;!?.,;!?()\n\r\t]/);
const bigrams: string[] = [];
for (const sentence of sentences) {
// 提取子句内的 CJK 字符(覆盖 CJK 统一表意、扩展 A、平假名/片假名、谚文)
const cjkChars = sentence.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/g);
if (!cjkChars || cjkChars.length === 0) continue;
for (let i = 0; i < cjkChars.length - 1; i++) {
bigrams.push(cjkChars[i] + cjkChars[i + 1]);
}
// 单字 CJK 子句补 unigram(避免单字文档无 token
if (cjkChars.length === 1) {
bigrams.push(cjkChars[0]);
}
}
return [...words, ...bigrams];
}
/** 计算词频(TF */
function computeTF(tokens: string[]): Map<string, number> {
const tf = new Map<string, number>();
for (const token of tokens) {
tf.set(token, (tf.get(token) ?? 0) + 1);
}
// 归一化
const total = tokens.length || 1;
for (const [key, val] of tf) {
tf.set(key, val / total);
}
return tf;
}
/** 计算余弦相似度的点积部分 */
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);
if (freq2 !== undefined) {
const idfVal = idf.get(term) ?? 1;
sum += freq1 * freq2 * idfVal * idfVal;
}
}
return sum;
}
/** 计算向量模长 */
function vectorNorm(tf: Map<string, number>, idf: Map<string, number>): number {
let sum = 0;
for (const [term, freq] of tf) {
const idfVal = idf.get(term) ?? 1;
sum += (freq * idfVal) ** 2;
}
return Math.sqrt(sum);
}
/** 时间衰减权重:30 天半衰期(age=30 时 weight=0.5 */
function timeDecayWeight(createdAt: number, now: number = Date.now()): number {
const ageDays = Math.max(0, (now - createdAt) / (24 * 60 * 60 * 1000));
const halfLifeDays = 30;
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));
}
/**
* 记忆管理器
*/
export class MemoryManager {
/** IDF 缓存:词项 -> 文档频率 */
private idfCache = new Map<string, number>();
/** 缓存的记忆总数 */
private cachedDocCount = 0;
/** 缓存最后更新时间 */
private cacheUpdatedAt = 0;
/** 缓存有效期(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 创建)
*/
initialize(): void {
log.info('MemoryManager initialized (v0.2.0: TF-IDF enabled)');
}
/**
* 更新 IDF 缓存
*
* v0.3.0 增强:
* - 原子替换缓存(先构建新数据再替换,避免中间不一致状态)
* - 错误处理(数据库查询失败时保留旧缓存,不更新时间戳)
*/
private updateIdfCache(): void {
const now = Date.now();
if (now - this.cacheUpdatedAt < this.CACHE_TTL && this.cachedDocCount > 0) {
return; // 缓存未过期
}
const db = this.getDB();
try {
// v0.3.0: 先构建新缓存数据,再原子替换
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 allDocs = [
...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')),
...semanticRows.map((r) => r.value),
...workingRows.map((r) => r.value),
];
const newDocCount = allDocs.length;
const docFreq = new Map<string, number>();
for (const doc of allDocs) {
const tokens = new Set(tokenize(doc));
for (const token of tokens) {
docFreq.set(token, (docFreq.get(token) ?? 0) + 1);
}
}
// IDF = log((N+1)/(df+1)) + 1Sklearn 风格平滑),确保非负
for (const [term, df] of docFreq) {
newIdfCache.set(term, Math.log((newDocCount + 1) / (df + 1)) + 1);
}
// v0.3.0: 原子替换 — 只有新数据完全准备好后才替换旧缓存
this.idfCache = newIdfCache;
this.cachedDocCount = newDocCount;
this.cacheUpdatedAt = now;
} catch (error) {
// v0.3.0: 数据库查询失败时保留旧缓存,不更新 cacheUpdatedAt
// 这样下次 search() 会再次尝试更新
log.error('MemoryManager: Failed to update IDF cache, keeping stale cache:', error);
}
}
/**
* P2-12: 从 tf_cache 列读取缓存的分词结果;缓存缺失/损坏时回退实时分词
*
* tf_cache 在 store() 写入(JSON 序列化的 token 数组),避免每次检索对
* 全部候选文档重复执行 CJK bigram 正则分词(记忆量上千条时明显退化)。
*/
private cachedTokens(cache: string | null | undefined, docText: string): string[] {
if (cache) {
try {
const t = JSON.parse(cache) as unknown;
if (Array.isArray(t) && t.every((x) => typeof x === 'string')) return t as string[];
} catch {
// 缓存损坏 → 回退实时分词
}
}
return tokenize(docText);
}
/**
* L-5 修复: 提取 scoreAndPushMemory 辅助函数
*
* 计算 TF-IDF 余弦相似度并应用时间衰减和重要度权重,
* 将分数 > 0 的记忆 push 到 results 数组。
*
* 三种记忆类型(episodic/semantic/working)的评分逻辑统一调用此函数,
* 仅在调用前构造 docTokens/createdAt/importance 等参数。
* P2-12: docText → docTokens(分词结果由调用方通过 tf_cache 提供,避免重复分词)
*
* @param params - 评分参数
* @param results - 结果数组(push 到此数组)
*/
private scoreAndPushMemory(
params: {
docTokens: string[];
createdAt: number;
importance: number;
id: string;
type: MemoryType;
content: string;
summary?: string;
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,
now: number,
results: SearchResult[],
): void {
const docTF = computeTF(params.docTokens);
const docNorm = vectorNorm(docTF, this.idfCache);
if (docNorm === 0 && !(params.docVec && params.queryVec)) return;
// 时间衰减
const decayWeight = timeDecayWeight(params.createdAt, now);
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,
score: finalScore,
});
}
}
/**
* TF-IDF 相似度搜索(v0.8.1 P1-1: 可选向量混合评分)
*
* @param queryVec 查询向量(embedder 未注入/失败时为 null → 纯 TF-IDF
*/
private tfidfSearch(
query: string,
options: MemorySearchOptions,
queryVec: number[] | null,
): SearchResult[] {
const db = this.getDB();
this.updateIdfCache();
const queryTokens = tokenize(query);
if (queryTokens.length === 0) return [];
const queryTF = computeTF(queryTokens);
const queryNorm = vectorNorm(queryTF, this.idfCache);
if (queryNorm === 0) return [];
const { topK = 5, type, minImportance = 0 } = options;
const results: SearchResult[] = [];
const now = Date.now();
// L-5 修复: 三段搜索统一调用 scoreAndPushMemory,消除重复的 tokenize/computeTF/vectorNorm/dotProduct 逻辑
// 搜索 episodic 记忆
if (!type || type === 'episodic') {
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;
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,
// v0.8.2 P3-1: 走模型指纹校验的解码(不匹配 → 视为缺失 + 惰性重算)
docVec: this.decodeEmbeddingRow(
(row as { embedding?: unknown }).embedding,
(row as { embedding_model?: string | null }).embedding_model ?? null,
row.content + ' ' + (row.summary ?? ''),
),
queryVec,
},
queryTF,
queryNorm,
now,
results,
);
}
this.backfillMissingEmbeddings(
'episodic',
rows.map((r) => ({
id: r.id,
embedding: (r as { embedding?: unknown }).embedding,
embedding_model: (r as { embedding_model?: string | null }).embedding_model ?? null,
text: r.content + ' ' + (r.summary ?? ''),
})),
);
}
// 搜索 semantic 记忆
if (!type || type === 'semantic') {
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;
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,
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,
embedding_model: (r as { embedding_model?: string | null }).embedding_model ?? null,
text: r.key + ' ' + r.value,
})),
);
}
// 搜索 working 记忆
if (!type || type === 'working') {
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;
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,
);
}
}
return results.sort((a, b) => b.score - a.score).slice(0, topK);
}
/**
* 存储记忆
*
* v0.3.0 修复:
* - switch 添加 default 分支,未知 type 抛错而非静默失败
* - working 类型使用 item.id(若提供)或生成唯一 key,避免同 session 多次存储互相覆盖
* - semantic 类型使用 item.summary 作为 key(若提供),支持更新已有记忆
*/
store(item: Omit<MemoryItem, 'id' | 'createdAt'> & { id?: string }): string {
const db = this.getDB();
const id = item.id ?? `mem_${nanoid(12)}`;
const now = Date.now();
const importance = item.importance ?? this.calculateImportance(item);
switch (item.type) {
case 'episodic':
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 ?? ''))),
);
break;
case 'semantic':
// v0.3.0 修复:使用 summary 作为 key(若提供),支持更新已有语义记忆
// #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重
// v0.3.0 用 id 作为 key 时,因 id 每次新生成,INSERT OR REPLACE 永远不触发 REPLACE
// 导致重复 store 同一内容会创建多条记忆。改为 contentHash 后,相同内容自动 REPLACE。
//
// v0.8.2 P3-1 根治: INSERT OR REPLACE → ON CONFLICT DO UPDATE。
// REPLACE 是"删旧行 + 插新行"—— 新 id 替换旧 id**access_count 归零**
// 热门记忆被同内容更新后 LRU 排序权重凭空丢失。现以 key 为冲突目标做
// 真正的 upsert:更新内容侧字段,保留 access_count 与旧 embedding
//(同 key 意味着内容相同,旧向量仍然有效;模型更换由指纹校验自愈)。
db.prepare(
`
INSERT INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count, tf_cache)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
category = excluded.category,
confidence = excluded.confidence,
source_session = excluded.source_session,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
tf_cache = excluded.tf_cache
`,
).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(
`
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,
JSON.stringify(tokenize((item.summary ?? '') + ' ' + item.content)),
);
break;
default:
// v0.3.0 修复:未知 type 抛错而非静默失败
throw new Error(`Unknown memory type: ${(item as { type: string }).type}`);
}
// 使 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.8.1 P1-1: 异步生成并回填 embedding BLOB。
* 失败静默(降级 TF-IDF),不阻塞写入方(工具执行/记忆固化均不等待)。
*/
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;
// v0.8.2 P3-1: 同步写入模型指纹(检索时按指纹校验,模型更换后惰性重算)
this.getDB()
.prepare(`UPDATE ${table} SET embedding = ?, embedding_model = ? WHERE id = ?`)
.run(float32ToBlob(vec), embedder.modelName ?? null, id);
})
.catch((err) => {
log.debug(`MemoryManager: embedding enrichment skipped: ${(err as Error).message}`);
})
.finally(() => {
this.embeddingBackfillInFlight.delete(key);
});
}
/**
* v0.8.2 P3-1: BLOB 解码 + 模型指纹校验。
* ① 字节/维度损坏 → null(回退 TF-IDF);② 当前嵌入器声明了模型指纹且行内
* 指纹不匹配(更换过 embedding 模型 / 旧版本写入的无指纹行)→ 视为缺失并
* 触发惰性重算 —— 旧实现跨模型余弦为 0(降级)或产生噪声分数,且无自愈路径。
*/
private decodeEmbeddingRow(
blob: unknown,
rowModel: string | null,
_text: string,
): number[] | null {
const vec = blobToFloat32(blob);
if (!vec) return null;
const currentModel = this.embedder?.modelName;
if (currentModel && (rowModel ?? null) !== currentModel) {
// 指纹不匹配:本轮按"无向量"处理(回退 TF-IDF),同时排队用当前模型重算
return null;
}
return vec;
}
/**
* v0.8.2 P3-1: 向量路独立召回(与重要度预过滤解耦)。
*
* 旧实现混合检索的向量余弦只在 `ORDER BY importance DESC LIMIT topK*3` 的
* 候选池内计算 —— 低重要度但语义高度相关的记忆永远进不了向量路,"同义改写
* 召回"(P1-1 立项目标)被结构性钳制。现当查询向量可用时,从 embedding 命中
* 的行中按时间取最近 VECTOR_RECALL_POOL 条独立召回(importance 过滤仍生效),
* 以纯向量余弦 × 衰减 × 重要度权重评分,由 search() 与 TF-IDF 路合并去重。
*/
private static readonly VECTOR_RECALL_POOL = 200;
private vectorRecall(
queryVec: number[],
options: MemorySearchOptions,
now: number,
): SearchResult[] {
const db = this.getDB();
const { topK = 5, type, minImportance = 0 } = options;
const pool = Math.max(topK * 10, MemoryManager.VECTOR_RECALL_POOL);
const results: SearchResult[] = [];
const importanceWeight = (importance: number): number => 0.5 + importance * 0.5;
if (!type || type === 'episodic') {
const rows = db
.prepare(
`
SELECT id, session_id, content, summary, source, importance, created_at, expires_at, embedding, embedding_model
FROM episodic_memories
WHERE importance >= ? AND embedding IS NOT NULL
ORDER BY created_at DESC LIMIT ?
`,
)
.all(minImportance, pool) as Array<{
id: string;
session_id: string | null;
content: string;
summary: string | null;
source: string;
importance: number;
created_at: number;
expires_at: number | null;
embedding: unknown;
embedding_model: string | null;
}>;
for (const row of rows) {
const docVec = this.decodeEmbeddingRow(row.embedding, row.embedding_model, row.content);
if (!docVec) {
// 指纹不匹配(有旧向量但不被当前模型接受)→ 惰性重算
if (row.embedding != null) this.enrichEmbedding('episodic', row.id, row.content);
continue;
}
const sim = cosineSimilarity(queryVec, docVec);
if (sim <= 0) continue;
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,
score: sim * timeDecayWeight(row.created_at, now) * importanceWeight(row.importance),
});
}
}
if (!type || type === 'semantic') {
const rows = db
.prepare(
`
SELECT id, key, value, confidence, source_session, created_at, embedding, embedding_model
FROM semantic_memories
WHERE confidence >= ? AND embedding IS NOT NULL
ORDER BY updated_at DESC LIMIT ?
`,
)
.all(minImportance, pool) as Array<{
id: string;
key: string;
value: string;
confidence: number;
source_session: string | null;
created_at: number;
embedding: unknown;
embedding_model: string | null;
}>;
for (const row of rows) {
const docVec = this.decodeEmbeddingRow(
row.embedding,
row.embedding_model,
row.key + ' ' + row.value,
);
if (!docVec) {
if (row.embedding != null)
this.enrichEmbedding('semantic', row.id, row.key + ' ' + row.value);
continue;
}
const sim = cosineSimilarity(queryVec, docVec);
if (sim <= 0) continue;
results.push({
id: row.id,
type: 'semantic',
content: row.value,
source: 'imported',
importance: row.confidence,
sessionId: row.source_session ?? undefined,
createdAt: row.created_at,
score: sim * timeDecayWeight(row.created_at, now) * importanceWeight(row.confidence),
});
}
}
return results.sort((a, b) => b.score - a.score).slice(0, topK);
}
/**
* v0.8.1 P1-1: 存量记忆向量惰性回填 —— 嵌入功能开启前写入的记忆(embedding
* IS NULL)在参与检索时排队补算:本轮查询仍走 TF-IDF,后续查询即可命中向量
* 路径。无阻塞、无独立迁移任务,收敛速度随检索频次自然提升;嵌入器不可用
* 时零开销(直接返回)。
*/
private backfillMissingEmbeddings(
type: MemoryType,
rows: Array<{ id: string; embedding?: unknown; embedding_model?: string | null; text: string }>,
): void {
if (!this.embedder) return;
const currentModel = this.embedder.modelName;
for (const row of rows) {
if (row.embedding == null) {
this.enrichEmbedding(type, row.id, row.text);
continue;
}
// v0.8.2 P3-1: 指纹不匹配(更换过 embedding 模型 / 旧版本无指纹行)→
// 用当前模型重算,旧向量在重算完成前不参与向量评分
if (currentModel && (row.embedding_model ?? null) !== currentModel) {
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.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 now = Date.now();
const tfidfResults = this.tfidfSearch(query, options, queryVec);
// v0.8.2 P3-1: 向量路独立召回合并 —— 低重要度但语义相关的记忆不再被
// importance 预过滤的候选池钳制(tfidfSearch 的向量分量只在重要度池内算)。
let merged = tfidfResults;
if (queryVec) {
const vectorResults = this.vectorRecall(queryVec, options, now);
if (vectorResults.length > 0) {
const byId = new Map<string, SearchResult>();
for (const r of [...tfidfResults, ...vectorResults]) {
const prev = byId.get(r.id);
if (!prev || r.score > prev.score) byId.set(r.id, r);
}
merged = [...byId.values()].sort((a, b) => b.score - a.score).slice(0, topK);
}
}
if (merged.length > 0) {
this.bumpAccessCounts(merged);
return merged;
}
// 回退:如果 TF-IDF 没有结果(如 IDF 缓存为空),使用 LIKE 关键词搜索
// 转义 LIKE 通配符,避免用户输入的 % 和 _ 影响匹配
// v0.3.0 修复: 反斜杠也需转义,否则含 \ 的搜索(如 Windows 路径)会导致 SQLite LIKE 报错
const escapedQuery = query.replace(/[%_\\]/g, '\\$&');
const pattern = `%${escapedQuery}%`;
const results: SearchResult[] = [];
// 搜索情节记忆
if (!type || type === 'episodic') {
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;
}>;
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,
score: row.importance * timeDecayWeight(row.created_at),
});
}
}
// 搜索语义记忆
if (!type || type === 'semantic') {
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;
}>;
for (const row of rows) {
results.push({
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),
});
}
}
// 搜索工作记忆
// v0.3.0 修复:LIKE 回退路径也需添加 !type 分支(与 tfidfSearch 保持一致)
if (!type || type === 'working') {
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;
}>;
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,
score: 0.3 * timeDecayWeight(row.updated_at),
});
}
}
const finalResults = results.sort((a, b) => b.score - a.score).slice(0, topK);
this.bumpAccessCounts(finalResults);
return finalResults;
}
/**
* 获取工作记忆
*/
getWorkingMemory(sessionId: string, taskId: string = 'default'): Map<string, string> {
const db = this.getDB();
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 }>;
return new Map(rows.map((r) => [r.key, r.value]));
}
/**
* 更新工作记忆
*/
setWorkingMemory(sessionId: string, taskId: string, key: string, value: string): void {
const db = this.getDB();
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());
}
/**
* 清除工作记忆
*/
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,
);
} else {
db.prepare('DELETE FROM working_memories WHERE session_id = ?').run(sessionId);
}
}
/**
* 清理过期记忆
*/
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());
return result.changes;
}
private calculateImportance(item: Omit<MemoryItem, 'id' | 'createdAt'>): number {
let score = 0.5;
if (item.source === 'user_input') score += 0.2;
if (item.source === 'tool_result') score += 0.1;
if (item.content.length > 200) score += 0.1;
return Math.min(1, Math.max(0, score));
}
/**
* #32 修复: 计算 content 的 SHA-256 hash(取前 16 字符),用于基于内容的去重
* 当 store 未提供 summary 时,用 contentHash 作为 semantic/working 的 key
* 使 INSERT OR REPLACE 能基于内容触发 REPLACE,避免重复存储相同内容。
*/
private contentHash(content: string): string {
return createHash('sha256').update(content, 'utf-8').digest('hex').slice(0, 16);
}
}