/** * Memory Manager — 记忆管理器 * * 基于 SQLite(better-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 { const tf = new Map(); 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, tf2: Map, idf: Map, ): 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, idf: Map): 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 BLOB(little-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(); /** 缓存的记忆总数 */ 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(); // 获取所有记忆内容(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(); 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)) + 1(Sklearn 风格平滑),确保非负 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, 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, 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( ` 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, 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 & { 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。 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, 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(); 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_count(LRU 淘汰语义激活)。 * 此前该列只在 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_count(LRU 语义激活,P0-2)。 */ async search(query: string, options: MemorySearchOptions = {}): Promise { 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 tfidfResults = this.tfidfSearch(query, options, queryVec); if (tfidfResults.length > 0) { this.bumpAccessCounts(tfidfResults); return tfidfResults; } // 回退:如果 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 { 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): 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); } }