feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
+195 -10
View File
@@ -414,7 +414,12 @@ export class MemoryManager {
source: row.source as MemorySource,
sessionId: row.session_id ?? undefined,
expiresAt: row.expires_at ?? undefined,
docVec: blobToFloat32((row as { embedding?: unknown }).embedding),
// 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,
@@ -428,6 +433,7 @@ export class MemoryManager {
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 ?? ''),
})),
);
@@ -478,6 +484,7 @@ export class MemoryManager {
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,
})),
);
@@ -565,10 +572,24 @@ export class MemoryManager {
// #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 OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count, tf_cache)
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,
@@ -635,9 +656,10 @@ export class MemoryManager {
.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 = ? WHERE id = ?`)
.run(float32ToBlob(vec), id);
.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}`);
@@ -647,6 +669,144 @@ export class MemoryManager {
});
}
/**
* 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,后续查询即可命中向量
@@ -655,12 +815,20 @@ export class MemoryManager {
*/
private backfillMissingEmbeddings(
type: MemoryType,
rows: Array<{ id: string; embedding?: unknown; text: string }>,
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) continue;
this.enrichEmbedding(type, row.id, row.text);
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);
}
}
}
@@ -716,10 +884,27 @@ export class MemoryManager {
}
// v0.2.0: 优先使用语义搜索(TF-IDF ± 向量混合)
const now = Date.now();
const tfidfResults = this.tfidfSearch(query, options, queryVec);
if (tfidfResults.length > 0) {
this.bumpAccessCounts(tfidfResults);
return tfidfResults;
// 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 关键词搜索