Files
metona-ai-desktop/electron/harness/memory/manager.ts
T
thzxx 9b45c445bf
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s
feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
2026-09-08 09:35:58 +08:00

910 lines
30 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,
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<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。
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<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.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<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);
}
}