feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。
P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层
P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}
P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)
Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。
验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
This commit is contained in:
+430
-104
@@ -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 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 记忆管理器
|
||||
*/
|
||||
@@ -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_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<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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user