v0.13.0: 记忆系统重构 — MEMORY.md 文件化 + 路径保护 + 自动提取优化

核心变更:
- 删除 SQLite memories 表 + FTS5 + IVF 向量存储引擎 (~1300行)
- 4 个记忆工具合并为 1 个 memory 工具 (5 action)
- 记忆存储改为工作空间 MEMORY.md 单文件,严格格式校验
- 路径保护: checkPathAllowed 拦截所有工具,仅 memory 专用 IPC 通道可访问
- 应用启动/工作空间切换时自动校验并初始化 MEMORY.md(格式错误自动备份重建)
- 自动记忆提取重建: 对话结束时触发,多层质量过滤(内容/泛化/去重/安全/importance门槛)
- 工具总数: 42→40,UI 全面更新(帮助面板、工具面板、设置面板、README)
- 版本号更新: 0.12.11 → 0.13.0
This commit is contained in:
紫影233
2026-06-24 14:49:09 +08:00
parent dcaf5982fc
commit 0903d740da
26 changed files with 1207 additions and 2364 deletions
+26 -29
View File
@@ -15,7 +15,8 @@ import {
formatPlanStatus,
clearPlanTracker,
} from './tool-registry.js';
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
import { search, formatMemoryContext, loadAllEntries } from './memory-service.js';
import { showToast } from '../components/toast.js';
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
@@ -126,7 +127,7 @@ const VALID_TOOL_NAMES = new Set([
'delete_file', 'run_command', 'move_file', 'copy_file', 'web_fetch', 'web_search',
'edit_file', 'get_file_info', 'tree', 'download_file', 'diff_files',
'replace_in_files', 'read_multiple_files', 'git', 'compress',
'memory_search', 'memory_add', 'memory_replace', 'memory_remove', 'session_list', 'session_read',
'memory', 'session_list', 'session_read',
'datetime', 'calculator',
'random', 'uuid', 'json_format', 'hash'
]);
@@ -518,12 +519,13 @@ async function handleInit(
}
// 注入记忆上下文
if (isMemoryEnabled() && userContent) {
const relevantMemories = searchMemories(userContent, 6);
if (relevantMemories.length > 0) {
systemPromptParts.push(buildMemoryContext(relevantMemories));
for (const m of relevantMemories) { await markMemoryUsed(m.id); }
}
if (userContent) {
try {
const relevantMemories = await search(userContent, 6);
if (relevantMemories.length > 0) {
systemPromptParts.push(formatMemoryContext(relevantMemories));
}
} catch { /* 记忆搜索失败不影响主流程 */ }
}
// 注入工作空间上下文
@@ -677,7 +679,7 @@ Shell: ${osInfo.shell}
}
}
logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, 模式: ${ctx.mode}, tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))}`);
logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 模式: ${ctx.mode}, tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))}`);
// ── Plan Mode: 如果是 plan 模式,先注入计划提示 ──
if (ctx.mode === 'plan' && ctx.loopCount === 0) {
@@ -1444,18 +1446,6 @@ async function handleObserving(
.filter(r => r.status === 'success')
.map(r => getToolCacheKey(r.name, r.arguments));
// 增量记忆提取 — 每 20 轮
if (isMemoryEnabled() && ctx.loopCount > 1 && ctx.loopCount % 20 === 0 && ctx.messages.length >= 10) {
import('./memory-manager.js').then(({ extractMemoriesFromConversation }) => {
const recentMsgs = ctx.messages.slice(-30).filter(m => m.role === 'user' || m.role === 'assistant');
extractMemoriesFromConversation(
recentMsgs.map(m => ({ role: m.role, content: m.content })),
currentSession?.title
).then(() => logInfo('增量记忆提取完成', `${ctx.loopCount}`))
.catch(() => {});
}).catch(() => {});
}
// 保存本轮工具调用
ctx.prevToolCalls = [...ctx.toolCalls];
@@ -1670,14 +1660,21 @@ async function handleReflecting(
logInfo('模型停止工具调用 → ReAct Agent Loop 结束');
clearPlanTracker();
if (isMemoryEnabled() && ctx.messages.length >= 10) {
try {
const { extractMemoriesFromConversation } = await import('./memory-manager.js');
await extractMemoriesFromConversation(
ctx.messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })),
currentSession?.title
);
} catch { /* 不阻塞 */ }
// ── 自动记忆提取(异步,不阻塞用户看到回复)──
const abortController = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER);
if (!abortController?.signal.aborted) {
const _api = state.get<OllamaAPI>(KEYS.API);
const _model = state.get<string>('_defaultModel', '');
const msgsForMemory = ctx.messages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({ role: m.role, content: m.content || '' }));
// 使用 setTimeout 延迟执行,确保 onDone 先触发,用户先看到回复
setTimeout(() => {
import('./memory-service.js').then(({ extractAndSaveMemories }) => {
extractAndSaveMemories(msgsForMemory, _api, _model).catch(() => {});
}).catch(() => {});
}, 500);
}
callbacks.onDone(ctx.content || '(模型未返回内容)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
+2 -2
View File
@@ -138,7 +138,7 @@ const toolHallucinationCheck: CompletionCheck = {
const calledTools = new Set(ctx.allToolRecords.map(r => r.name));
const content = ctx.content;
// ── 统一的"声称 → 必需工具"映射表(覆盖全部 42 个工具)──
// ── 统一的"声称 → 必需工具"映射表(覆盖全部 40 个工具)──
const CLAIM_RULES: Array<{ patterns: RegExp[]; requiredTools: string[]; label: string }> = [
// 文件写入
{ patterns: [/已写入文件/, /已创建文件/, /文件已生成/, /已保存到/, /已成功创建/, /成功写入/,
@@ -191,7 +191,7 @@ const toolHallucinationCheck: CompletionCheck = {
requiredTools: ['get_file_info'], label: '获取文件信息' },
// 内存/记忆操作
{ patterns: [/已记住/, /已添加记忆/, /记忆已保存/, /已更新记忆/, /已删除记忆/],
requiredTools: ['memory_add', 'memory_replace', 'memory_remove'], label: '记忆操作' },
requiredTools: ['memory'], label: '记忆操作' },
// 会话操作
{ patterns: [/历史会话.*显示/, /已读取.*会话/, /会话.*内容.*如下/],
requiredTools: ['session_list', 'session_read'], label: '会话操作' },
-751
View File
@@ -1,751 +0,0 @@
/**
* MemoryManager - Agent 记忆系统(长效向量记忆)
* 自动提取 → 存储 → 检索 → 注入上下文
*
* 记忆类型:
* - fact: 用户告诉我的事实(项目信息、个人背景等)
* - preference: 用户偏好(语言、风格、格式习惯)
* - rule: 应遵守的规则(命名规范、输出格式要求)
*/
import { state, KEYS } from '../state/state.js';
import { generateId } from '../utils/utils.js';
import {
initMemoryVectorStore, getOrCreateMemoryCollection,
embedMemoryEntry, addMemoryVector, updateMemoryVector,
deleteMemoryVector, searchMemoriesByVector, reindexAllMemories,
getMemoryCollectionId
} from './vector-memory.js';
import { logMemory, logDebug, logWarn, logInfo } from './log-service.js';
import type { MemoryEntry, MemorySearchResult, MemoryExtractionResult } from '../types.js';
import type { ChatDB } from '../db/chat-db.js';
import type { OllamaAPI } from '../api/ollama.js';
const TYPE_ICONS: Record<string, string> = {
fact: '📌',
preference: '⚙️',
rule: '📏'
};
const TYPE_NAMES: Record<string, string> = {
fact: '事实',
preference: '偏好',
rule: '规则'
};
let memoryCache: MemoryEntry[] = [];
let memoryEnabled = true;
let embeddingModel = '';
// ── 初始化 ──
export async function initMemoryManager(): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
memoryEnabled = await db.getSetting('memoryEnabled', true);
state.set('memoryEnabled', memoryEnabled);
// 加载嵌入模型设置
embeddingModel = await db.getSetting('embeddingModel', '');
state.set('embeddingModel', embeddingModel);
memoryCache = await db.getAllMemories();
state.set('memoryEntries', memoryCache);
// 如果有嵌入模型,初始化向量存储
if (embeddingModel) {
try {
await initMemoryVectorStore();
logMemory('向量存储已初始化');
} catch (err) {
logWarn('向量存储初始化失败', (err as Error).message);
}
}
logMemory(`初始化完成, 加载 ${memoryCache.length}${embeddingModel ? ', 向量记忆已启用' : ', 仅关键词模式'}`);
}
// ── 嵌入模型管理 ──
export function getEmbeddingModel(): string {
return embeddingModel;
}
export async function setEmbeddingModel(model: string): Promise<void> {
const oldModel = embeddingModel;
embeddingModel = model;
state.set('embeddingModel', model);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.saveSetting('embeddingModel', model);
if (model && model !== oldModel) {
// 嵌入模型变化,重新索引所有记忆
logMemory(`嵌入模型变更: ${oldModel || '(无)'}${model}`);
await reindexMemories();
}
}
export function isVectorMemoryEnabled(): boolean {
return !!embeddingModel;
}
// ── 重新索引所有记忆 ──
export async function reindexMemories(): Promise<void> {
if (!embeddingModel || memoryCache.length === 0) return;
try {
await initMemoryVectorStore();
const { id: colId } = await getOrCreateMemoryCollection(embeddingModel);
await reindexAllMemories(memoryCache, embeddingModel, colId, (done, total) => {
logMemory(`重新索引进度: ${done}/${total}`);
});
logMemory('向量索引重建完成');
} catch (err) {
logWarn('向量索引重建失败', (err as Error).message);
}
}
// ── 基础管理 ──
export function isMemoryEnabled(): boolean {
return memoryEnabled;
}
export function setMemoryEnabled(enabled: boolean): void {
memoryEnabled = enabled;
state.set('memoryEnabled', enabled);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) db.saveSetting('memoryEnabled', enabled);
}
export function getMemoryCache(): MemoryEntry[] {
return memoryCache;
}
export function getTypeIcon(type: string): string {
return TYPE_ICONS[type] || '📌';
}
export function getTypeName(type: string): string {
return TYPE_NAMES[type] || type;
}
// ── 记忆检索(向量优先,关键词补充)──
export function searchMemories(query: string, limit = 8): MemorySearchResult[] {
if (!memoryEnabled || memoryCache.length === 0) return [];
// 始终执行关键词搜索(即时结果)
const keywordResults = searchMemoriesByKeyword(query, limit);
// 如果启用了向量记忆,异步触发向量搜索(结果在下次调用时更新)
if (embeddingModel) {
triggerVectorSearch(query, limit);
}
return keywordResults;
}
// 向量搜索异步缓存
const vectorSearchCache = new Map<string, MemorySearchResult[]>();
let vectorSearchTimer: ReturnType<typeof setTimeout> | null = null;
function triggerVectorSearch(query: string, limit: number): void {
if (vectorSearchTimer) clearTimeout(vectorSearchTimer);
vectorSearchTimer = setTimeout(async () => {
try {
const colId = getMemoryCollectionId();
if (!colId) return;
const results = await searchMemoriesByVector(query, colId, limit);
const memoryResults: MemorySearchResult[] = results.map(r => {
const entry = memoryCache.find(e => e.id === r.docId);
if (!entry) return null;
return { ...entry, score: r.score };
}).filter(Boolean) as MemorySearchResult[];
vectorSearchCache.set(query, memoryResults);
} catch (err) {
logWarn('向量搜索失败', (err as Error).message);
}
}, 100);
}
// 关键词搜索
function searchMemoriesByKeyword(query: string, limit: number): MemorySearchResult[] {
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
// rule 和 preference 类型的记忆全局生效,始终注入
const alwaysInclude: MemorySearchResult[] = [];
const scored: MemorySearchResult[] = [];
for (const entry of memoryCache) {
if (entry.type === 'rule' || entry.type === 'preference') {
// 规则和偏好:始终包含,按重要性排序
alwaysInclude.push({ ...entry, score: 100 + entry.importance });
continue;
}
// fact 类型:按关键词匹配
let score = 0;
const contentLower = entry.content.toLowerCase();
const tagsLower = entry.tags.map(t => t.toLowerCase());
// 完全匹配内容
if (contentLower.includes(queryLower)) score += 50;
// 标签匹配
for (const tag of tagsLower) {
if (queryLower.includes(tag) || tag.includes(queryLower)) score += 30;
for (const word of queryWords) {
if (tag.includes(word)) score += 15;
}
}
// 关键词匹配
for (const word of queryWords) {
if (contentLower.includes(word)) score += 10;
}
// 重要性加权
score *= (0.5 + entry.importance / 20);
// 最近使用加权(7 天内使用过 +20%)
const daysSinceUse = (Date.now() - entry.lastUsedAt) / (1000 * 60 * 60 * 24);
if (daysSinceUse < 7) score *= 1.2;
// 使用频率加权
score *= (1 + Math.min(entry.useCount, 10) / 50);
if (score > 0) {
scored.push({ ...entry, score });
}
}
// 合并:全局规则/偏好 + 关键词匹配的事实,去重后按分数排序
const seen = new Set<string>();
const merged: MemorySearchResult[] = [];
for (const item of [...alwaysInclude, ...scored.sort((a, b) => b.score - a.score)]) {
if (!seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
}
}
return merged.slice(0, limit);
}
// ── 记忆添加 ──
export async function addMemory(data: {
type: MemoryEntry['type'];
content: string;
importance?: number;
tags?: string[];
source?: string;
sessionId?: string;
}): Promise<MemoryEntry> {
const db = state.get<ChatDB | null>(KEYS.DB);
// 自动提取来源禁止添加 rule 类型
if (data.type === 'rule' && data.source === '自动提取') {
logWarn('记忆提取拦截', '自动提取不允许创建规则类型,规则只能由用户手动添加');
throw new Error('规则类型记忆只能由用户手动添加');
}
// 安全扫描:检测 prompt injection 和敏感信息
const securityCheck = scanMemorySecurity(data.content);
if (!securityCheck.safe) {
logWarn('记忆安全扫描拦截', securityCheck.reason);
throw new Error(`记忆内容被安全规则拦截: ${securityCheck.reason}`);
}
// v5.1.2 记忆容量限制:超过上限自动清理低价值条目
const MAX_MEMORIES = 500;
if (memoryCache.length >= MAX_MEMORIES) {
const evicted = autoCleanMemories();
if (evicted > 0) {
logInfo('记忆容量清理', `已清理 ${evicted} 条低价值记忆(上限 ${MAX_MEMORIES}`);
}
}
// 检查重复(内容相似度 > 80% 或前缀匹配则跳过)
const existing = memoryCache.find(e => {
if (e.type !== data.type) return false;
// v5.1.3 前缀匹配:前 50 字符完全相同视为重复
const prefixLen = Math.min(50, data.content.length, e.content.length);
if (prefixLen > 20 && data.content.slice(0, prefixLen) === e.content.slice(0, prefixLen)) {
return true;
}
const similarity = simpleSimilarity(e.content, data.content);
return similarity > 0.8;
});
if (existing) return existing;
const entry: MemoryEntry = {
id: `mem_${generateId()}`,
type: data.type,
content: data.content.trim(),
importance: data.importance ?? 5,
tags: data.tags || extractTags(data.content),
source: data.source,
sessionId: data.sessionId,
createdAt: Date.now(),
updatedAt: Date.now(),
lastUsedAt: Date.now(),
useCount: 0
};
memoryCache.push(entry);
state.set('memoryEntries', [...memoryCache]);
if (db) await db.saveMemory(entry);
logMemory(`新增: ${data.type}`, entry.content.slice(0, 60));
// 向量存储
if (embeddingModel) {
try {
await initMemoryVectorStore();
const colId = getMemoryCollectionId() || (await getOrCreateMemoryCollection(embeddingModel)).id;
const embedding = await embedMemoryEntry(entry, embeddingModel);
entry.embedding = embedding;
await addMemoryVector(entry, embedding, colId);
if (db) await db.saveMemory(entry); // 保存包含 embedding 的版本
} catch (err) {
const errMsg = (err as Error).message;
logWarn('向量存储失败', `记忆 "${entry.content.slice(0, 40)}" (${entry.id}): ${errMsg}`);
logDebug('向量存储失败详情', `模型: ${embeddingModel}, 类型: ${entry.type}, 错误: ${(err as Error).stack?.split('\n')[0] || errMsg}`);
}
}
return entry;
}
// ── 容量管理 ──
/**
* 自动清理低价值记忆条目
* 策略:按综合评分排序,清理后 20% 的条目
* 评分 = importance * 2 + useCount * 3 + recencyBonus(最近 7 天使用过 +5
* rule 类型记忆受保护不被清理
* @returns 清理的条目数
*/
function autoCleanMemories(): number {
const db = state.get<ChatDB | null>(KEYS.DB);
const now = Date.now();
const SEVEN_DAYS = 7 * 24 * 3600 * 1000;
const NINETY_DAYS = 90 * 24 * 3600 * 1000;
// v5.1.3 记忆过期衰减:90 天未使用的记忆自动降级 importance
for (const entry of memoryCache) {
if (entry.type === 'rule') continue; // rule 受保护
const unusedDays = now - entry.lastUsedAt;
if (unusedDays > NINETY_DAYS && entry.importance > 1) {
entry.importance = Math.max(1, entry.importance - 2);
if (db) db.saveMemory(entry);
}
}
// 计算综合评分
const scored = memoryCache
.map((entry) => {
const recencyBonus = (now - entry.lastUsedAt) < SEVEN_DAYS ? 5 : 0;
const agePenalty = (now - entry.createdAt) > NINETY_DAYS ? 3 : 0;
const score = entry.importance * 2 + entry.useCount * 3 + recencyBonus - agePenalty;
return { entry, score };
})
// rule 类型受保护
.filter(s => s.entry.type !== 'rule')
.sort((a, b) => a.score - b.score);
const evictCount = Math.max(1, Math.floor(memoryCache.length * 0.2));
const toEvict = scored.slice(0, evictCount);
// 从缓存和数据库中移除
const evictIds = new Set(toEvict.map(s => s.entry.id));
for (const id of evictIds) {
if (db) db.deleteMemory(id);
}
memoryCache = memoryCache.filter(e => !evictIds.has(e.id));
state.set('memoryEntries', [...memoryCache]);
return evictIds.size;
}
// ── 安全扫描 ──
/** 记忆内容安全扫描 */
function scanMemorySecurity(content: string): { safe: boolean; reason: string } {
// Prompt injection 模式
const injectionPatterns = [
/ignore\s+(all\s+)?previous/i,
/forget\s+(all\s+)?instructions/i,
/you\s+are\s+now\s+a/i,
/new\s+system\s*prompt/i,
/override\s+(your|the)\s+/i,
/disregard\s+(all|any|previous)/i,
];
for (const p of injectionPatterns) {
if (p.test(content)) return { safe: false, reason: '疑似 prompt injection 攻击' };
}
// 敏感信息模式
const secretPatterns = [
/-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/,
/sk-[a-zA-Z0-9]{20,}/,
/ghp_[a-zA-Z0-9]{36}/,
/AKIA[A-Z0-9]{16}/,
/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, // 信用卡号
];
for (const p of secretPatterns) {
if (p.test(content)) return { safe: false, reason: '疑似包含敏感信息(密钥/密码/信用卡号)' };
}
// 不可见字符
if (/[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/.test(content)) {
return { safe: false, reason: '包含不可见 Unicode 字符' };
}
return { safe: true, reason: '' };
}
// ── 记忆替换(根据 old_text 子串匹配)──
export async function replaceMemoryByContent(
target: 'memory' | 'user',
oldText: string,
newContent: string
): Promise<{ success: boolean; message: string }> {
if (!oldText || oldText.length < 2) {
return { success: false, message: 'old_text 不能为空且至少 2 个字符' };
}
if (!newContent || newContent.length < 2) {
return { success: false, message: 'new_content 不能为空且至少 2 个字符' };
}
// 安全扫描
const securityCheck = scanMemorySecurity(newContent);
if (!securityCheck.safe) {
return { success: false, message: `新内容被安全规则拦截: ${securityCheck.reason}` };
}
// 匹配
const matches = memoryCache.filter(e => {
if (target === 'user') {
return (e.type === 'preference' || e.type === 'fact') && e.content.includes(oldText);
}
return e.content.includes(oldText);
});
if (matches.length === 0) {
return { success: false, message: `未找到包含 "${oldText.slice(0, 50)}" 的记忆` };
}
if (matches.length > 1) {
return { success: false, message: `匹配到 ${matches.length} 条记忆,请使用更精确的 old_text` };
}
await updateMemory(matches[0].id, { content: newContent.trim() });
logMemory('替换记忆', `${matches[0].id}: ${oldText.slice(0, 30)}${newContent.slice(0, 30)}`);
return { success: true, message: `已替换记忆: ${matches[0].id}` };
}
// ── 记忆删除(根据 old_text 子串匹配)──
export async function removeMemoryByContent(
target: 'memory' | 'user',
oldText: string
): Promise<{ success: boolean; message: string }> {
if (!oldText || oldText.length < 2) {
return { success: false, message: 'old_text 不能为空且至少 2 个字符' };
}
const matches = memoryCache.filter(e => {
if (target === 'user') {
return (e.type === 'preference' || e.type === 'fact') && e.content.includes(oldText);
}
return e.content.includes(oldText);
});
if (matches.length === 0) {
return { success: false, message: `未找到包含 "${oldText.slice(0, 50)}" 的记忆` };
}
if (matches.length > 1) {
return { success: false, message: `匹配到 ${matches.length} 条记忆,请使用更精确的 old_text` };
}
await deleteMemory(matches[0].id);
logMemory('删除记忆', `${matches[0].id}: ${oldText.slice(0, 50)}`);
return { success: true, message: `已删除记忆: ${matches[0].id}` };
}
// ── 记忆更新 ──
export async function updateMemory(id: string, updates: Partial<MemoryEntry>): Promise<void> {
const idx = memoryCache.findIndex(e => e.id === id);
if (idx === -1) return;
memoryCache[idx] = { ...memoryCache[idx], ...updates, updatedAt: Date.now() };
state.set('memoryEntries', [...memoryCache]);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.saveMemory(memoryCache[idx]);
// 更新向量
if (embeddingModel && (updates.content || updates.type || updates.tags)) {
try {
const colId = getMemoryCollectionId();
if (colId) {
const embedding = await embedMemoryEntry(memoryCache[idx], embeddingModel);
memoryCache[idx].embedding = embedding;
await updateMemoryVector(memoryCache[idx], embedding, colId);
if (db) await db.saveMemory(memoryCache[idx]);
}
} catch (err) {
logWarn('向量更新失败', (err as Error).message);
}
}
}
// ── 记忆删除 ──
export async function deleteMemory(id: string): Promise<void> {
memoryCache = memoryCache.filter(e => e.id !== id);
state.set('memoryEntries', [...memoryCache]);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.deleteMemory(id);
// 删除向量
if (embeddingModel) {
try {
const colId = getMemoryCollectionId();
if (colId) await deleteMemoryVector(id, colId);
} catch (err) {
logWarn('向量删除失败', (err as Error).message);
}
}
logMemory(`删除`, id);
}
// ── 清空所有记忆 ──
export async function clearAllMemories(): Promise<void> {
memoryCache = [];
state.set('memoryEntries', []);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.clearAllMemories();
// 清空向量集合
if (embeddingModel) {
try {
await initMemoryVectorStore();
const { id: colId } = await getOrCreateMemoryCollection(embeddingModel);
const { getMemoryVectorStore } = await import('./vector-memory.js');
const vs = getMemoryVectorStore();
if (vs) {
await vs.deleteCollection(colId);
// 重新创建空集合
const { setMemoryCollectionId } = await import('./vector-memory.js');
setMemoryCollectionId(null);
await getOrCreateMemoryCollection(embeddingModel);
}
} catch (err) {
logWarn('向量集合清空失败', (err as Error).message);
}
}
}
// ── 标记记忆被使用 ──
export async function markMemoryUsed(id: string): Promise<void> {
const entry = memoryCache.find(e => e.id === id);
if (!entry) return;
entry.useCount++;
entry.lastUsedAt = Date.now();
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.saveMemory(entry);
}
// ── 构建记忆上下文(注入 system prompt)──
export function buildMemoryContext(memories: MemorySearchResult[]): string {
if (memories.length === 0) return '';
const grouped: Record<string, MemorySearchResult[]> = {};
for (const m of memories) {
if (!grouped[m.type]) grouped[m.type] = [];
grouped[m.type].push(m);
}
let context = '';
// 规则类:强制约束,放在最前面
if (grouped['rule']) {
context += '【必须严格遵守的规则】\n以下规则是用户明确要求的,必须无条件执行:\n';
for (const item of grouped['rule']) {
context += ` - ${item.content}\n`;
}
context += '\n';
delete grouped['rule'];
}
// 偏好类:用户偏好
if (grouped['preference']) {
context += '【用户偏好】\n在回答时请遵循以下偏好:\n';
for (const item of grouped['preference']) {
context += ` - ${item.content}\n`;
}
context += '\n';
delete grouped['preference'];
}
// 事实类:参考信息
if (grouped['fact']) {
context += '【关于用户的参考信息】\n以下信息供参考,与当前对话无关时可忽略:\n';
for (const item of grouped['fact']) {
context += ` - ${item.content}\n`;
}
context += '\n';
}
context += '请自然地利用以上信息,不要生硬地列举。';
return context;
}
// ── 自动提取记忆(会话结束时调用)──
export async function extractMemoriesFromConversation(
messages: Array<{ role: string; content: string }>,
sessionTitle?: string
): Promise<number> {
if (!memoryEnabled) return 0;
if (messages.length < 3) return 0;
const api = state.get<OllamaAPI>(KEYS.API);
const model = state.get<string>('_defaultModel', '');
if (!api || !model) return 0;
const recentMessages = messages.slice(-20);
const conversationText = recentMessages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => `[${m.role === 'user' ? '用户' : 'AI'}]: ${m.content}`)
.join('\n\n');
const extractPrompt = `你是一个记忆提取系统。请分析以下对话,提取值得长期记住的关键信息。
对话内容:
${conversationText.slice(0, 4000)}
请以 JSON 格式返回提取结果(不要输出其他内容):
\`\`\`json
{
"entries": [
{
"type": "fact",
"content": "用户正在开发一个 Electron 桌面应用",
"importance": 7,
"tags": ["项目", "Electron", "开发"]
}
]
}
\`\`\`
记忆类型说明(自动提取仅支持以下两种):
- fact: 关于用户的具体事实(项目信息、技术栈、工作内容、个人背景)
- preference: 用户明确表达的偏好(喜欢某种输出格式、编码风格等)
⚠️ 严禁自动提取的类型:
- rule(规则): 规则只能由用户手动添加,绝对不要自动提取规则
提取标准(极其严格):
1. 只提取用户**明确陈述**的、有长期价值的信息,不要猜测或推断
2. 必须是**跨会话有用**的信息——如果只是本次对话的临时内容,不要提取
3. 每条记忆精炼简洁,不超过 50 字
4. importance 1-10,只有真正重要的才给 7+
5. tags 2-5 个关键词,用于检索匹配
6. 最多提取 3 条,宁缺毋滥
以下内容**不要提取**(常见错误):
- 对话中的临时任务描述(如"帮我写个函数")
- AI 的回复或建议内容
- 泛泛的表述(如"用户喜欢编程"、"用户是开发者"
- 一次性的查询或请求
- 用户没有明确表达的隐含偏好
- 任何可以简单从对话上下文推断的信息
如果对话中没有真正值得跨会话记住的信息,返回 {"entries": []}`;
try {
const response = await api.chat({
model,
messages: [{ role: 'user', content: extractPrompt }],
think: false,
options: { num_ctx: 8192, temperature: 0.1 }
} as any);
const content = (response as { message?: { content?: string } })?.message?.content || '';
const jsonMatch = content.match(new RegExp('```json\\s*([\\s\\S]*?)\\s*```')) || content.match(/\{[\s\S]*"entries"[\s\S]*\}/);
if (!jsonMatch) return 0;
const parsed: MemoryExtractionResult = JSON.parse(jsonMatch[1] || jsonMatch[0]);
if (!parsed.entries?.length) return 0;
let count = 0;
const currentSession = state.get(KEYS.CURRENT_SESSION);
for (const entry of parsed.entries) {
if (!entry.content || entry.content.length < 5) continue;
const validType = entry.type === 'fact' || entry.type === 'preference' ? entry.type : 'fact';
// 自动提取仅允许 fact 和 preference
if (entry.type === 'rule') {
logDebug('跳过自动提取的规则类型', entry.content.slice(0, 40));
continue;
}
await addMemory({
type: validType,
content: entry.content,
importance: Math.min(10, Math.max(1, entry.importance || 5)),
tags: entry.tags || [],
source: '自动提取',
sessionId: (currentSession as any)?.id
});
count++;
}
if (count > 0) logMemory(`自动提取 ${count}`);
return count;
} catch (err) {
logWarn('记忆自动提取失败', (err as Error).message);
return 0;
}
}
// ── 工具函数 ──
function extractTags(text: string): string[] {
const words = text
.replace(/[^\w\u4e00-\u9fff\s]/g, ' ')
.split(/\s+/)
.filter(w => w.length > 1);
return [...new Set(words)].slice(0, 5);
}
function simpleSimilarity(a: string, b: string): number {
const aWords = new Set(a.toLowerCase().split(/\s+/));
const bWords = new Set(b.toLowerCase().split(/\s+/));
let intersection = 0;
for (const w of aWords) {
if (bWords.has(w)) intersection++;
}
const union = aWords.size + bWords.size - intersection;
return union === 0 ? 0 : intersection / union;
}
+809
View File
@@ -0,0 +1,809 @@
/**
* Memory Service — 基于工作空间 MEMORY.md 的轻量记忆系统
*
* MEMORY.md 格式:
* # METONA MEMORY
* > ⚠️ 此文件由 memory 工具自动管理,严禁手动编辑
*
* ## fact | id: mem_YYYYMMDD_NNN | importance: 1-10 | tags: tag1, tag2
* 内容行
*
* ## preference | id: mem_YYYYMMDD_NNN | importance: 1-10 | tags: tag1, tag2
* 内容行
*
* ## rule | id: mem_YYYYMMDD_NNN | importance: 1-10 | tags: tag1, tag2
* 内容行
*/
import { logInfo, logWarn, logDebug, logMemory } from './log-service.js';
// ═══════════════════════════════════════════════════════════════
// 类型定义
// ═══════════════════════════════════════════════════════════════
export type MemoryType = 'fact' | 'preference' | 'rule';
export interface MemoryEntry {
id: string;
type: MemoryType;
content: string;
importance: number; // 1-10
tags: string[];
}
export interface MemorySearchResult extends MemoryEntry {
score: number;
}
// ═══════════════════════════════════════════════════════════════
// 常量
// ═══════════════════════════════════════════════════════════════
const MEMORY_FILE_NAME = 'MEMORY.md';
const MAX_ENTRIES = 500;
/** 文件头部 */
const FILE_HEADER = `# METONA MEMORY
> ⚠️ 此文件由 memory 工具自动管理,严禁手动编辑
> 元数据格式: ## [类型] | id: [ID] | importance: [1-10] | tags: [tag1, tag2, ...]
> 条目内容紧跟元数据行,直到下一个 ## 或文件末尾
`;
/** 条目元数据正则: ## [类型] | id: [ID] | importance: [1-10] | tags: [tag1, tag2] */
const ENTRY_HEADER_RE = /^##\s+(fact|preference|rule)\s*\|\s*id:\s*(mem_\d{8}_\d{3})\s*\|\s*importance:\s*(\d{1,2})\s*\|\s*tags:\s*(.+)$/i;
const VALID_TYPES: MemoryType[] = ['fact', 'preference', 'rule'];
// ═══════════════════════════════════════════════════════════════
// 安全扫描
// ═══════════════════════════════════════════════════════════════
function scanMemorySecurity(content: string): { safe: boolean; reason: string } {
const injectionPatterns = [
/ignore\s+(all\s+)?previous/i,
/forget\s+(all\s+)?instructions/i,
/you\s+are\s+now\s+a/i,
/new\s+system\s*prompt/i,
/override\s+(your|the)\s+/i,
/disregard\s+(all|any|previous)/i,
];
for (const p of injectionPatterns) {
if (p.test(content)) return { safe: false, reason: '疑似 prompt injection 攻击' };
}
const secretPatterns = [
/-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/,
/sk-[a-zA-Z0-9]{20,}/,
/ghp_[a-zA-Z0-9]{36}/,
/AKIA[A-Z0-9]{16}/,
/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/,
];
for (const p of secretPatterns) {
if (p.test(content)) return { safe: false, reason: '疑似包含敏感信息(密钥/密码/信用卡号)' };
}
if (/[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/.test(content)) {
return { safe: false, reason: '包含不可见 Unicode 字符' };
}
return { safe: true, reason: '' };
}
// ═══════════════════════════════════════════════════════════════
// IPC 通信 — 通过专用通道访问 MEMORY.md
// ═══════════════════════════════════════════════════════════════
function getBridge() {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) {
throw new Error('记忆系统仅支持桌面版');
}
return bridge;
}
/** 读取 MEMORY.md 原始内容(通过专用 IPC 通道) */
async function readMemoryFile(): Promise<string> {
const bridge = getBridge();
const result = await bridge.memoryAccess!.read();
if (!result.success) {
// 文件不存在视为空
if (result.error?.includes('ENOENT') || result.error?.includes('not found') || result.error?.includes('不存在')) {
return '';
}
throw new Error(`读取 MEMORY.md 失败: ${result.error}`);
}
return result.content || '';
}
/** 写入 MEMORY.md(通过专用 IPC 通道) */
async function writeMemoryFile(content: string): Promise<void> {
const bridge = getBridge();
const result = await bridge.memoryAccess!.write(content);
if (!result.success) {
throw new Error(`写入 MEMORY.md 失败: ${result.error}`);
}
}
// ═══════════════════════════════════════════════════════════════
// 解析与序列化
// ═══════════════════════════════════════════════════════════════
/**
* 解析 MEMORY.md 内容为条目数组
*/
export function parseMemoryMd(content: string): MemoryEntry[] {
if (!content || !content.trim()) return [];
const entries: MemoryEntry[] = [];
const lines = content.split('\n');
let currentEntry: MemoryEntry | null = null;
let contentLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const headerMatch = line.match(ENTRY_HEADER_RE);
if (headerMatch) {
// 保存上一个条目
if (currentEntry) {
currentEntry.content = contentLines.join('\n').trim();
if (currentEntry.content) entries.push(currentEntry);
}
// 解析新条目元数据
const type = headerMatch[1].toLowerCase() as MemoryType;
const id = headerMatch[2];
const importance = parseInt(headerMatch[3], 10);
const tagsStr = headerMatch[4];
const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
currentEntry = { id, type, content: '', importance: Math.min(10, Math.max(1, importance)), tags };
contentLines = [];
} else if (currentEntry) {
contentLines.push(line);
}
}
// 最后一个条目
if (currentEntry) {
currentEntry.content = contentLines.join('\n').trim();
if (currentEntry.content) entries.push(currentEntry);
}
return entries;
}
/**
* 校验 MEMORY.md 格式
*/
export function validateMemoryMd(content: string): { valid: boolean; error?: string; line?: number } {
if (!content || !content.trim()) {
return { valid: false, error: '文件内容为空' };
}
const lines = content.split('\n');
const firstLine = lines[0]?.trim();
if (firstLine !== '# METONA MEMORY') {
return { valid: false, error: `文件必须以 "# METONA MEMORY" 开头,当前: "${firstLine?.slice(0, 50) || '(空)'}"`, line: 1 };
}
let entryCount = 0;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
// 跳过注释行(以 > 开头)
if (line.trim().startsWith('>')) continue;
// 跳过空行
if (!line.trim()) continue;
const headerMatch = line.match(ENTRY_HEADER_RE);
if (!headerMatch) {
// 非元数据行的内容行(属于上一个条目)
continue;
}
entryCount++;
// 验证类型
const type = headerMatch[1].toLowerCase();
if (!VALID_TYPES.includes(type as MemoryType)) {
return { valid: false, error: `${i + 1} 行: 无效的记忆类型 "${type}",必须为 fact / preference / rule`, line: i + 1 };
}
// 验证 ID
const id = headerMatch[2];
if (!/^mem_\d{8}_\d{3}$/.test(id)) {
return { valid: false, error: `${i + 1} 行: 无效的 ID 格式 "${id}",必须为 mem_YYYYMMDD_NNN`, line: i + 1 };
}
// 验证 importance
const importance = parseInt(headerMatch[3], 10);
if (isNaN(importance) || importance < 1 || importance > 10) {
return { valid: false, error: `${i + 1} 行: importance 必须为 1-10 的整数,当前: "${headerMatch[3]}"`, line: i + 1 };
}
// 验证 tags
const tagsStr = headerMatch[4]?.trim();
if (!tagsStr) {
return { valid: false, error: `${i + 1} 行: tags 不能为空`, line: i + 1 };
}
const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
if (tags.length === 0) {
return { valid: false, error: `${i + 1} 行: tags 至少需要一个标签`, line: i + 1 };
}
}
if (entryCount === 0 && content.includes('## ')) {
// 有 ## 但没匹配到,格式可能有问题
}
return { valid: true };
}
/**
* 将条目数组序列化为 MEMORY.md 内容
*/
export function serializeMemoryMd(entries: MemoryEntry[]): string {
let content = FILE_HEADER;
for (const entry of entries) {
if (!entry.content.trim()) continue;
const tagsStr = entry.tags.join(', ');
content += `## ${entry.type} | id: ${entry.id} | importance: ${entry.importance} | tags: ${tagsStr}\n`;
content += entry.content.trim() + '\n\n';
}
return content;
}
// ═══════════════════════════════════════════════════════════════
// 搜索
// ═══════════════════════════════════════════════════════════════
/**
* 关键词搜索记忆
*/
export function searchMemory(entries: MemoryEntry[], query: string, limit = 8): MemorySearchResult[] {
if (!query || entries.length === 0) return [];
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
// rule 和 preference 类型的记忆全局生效,始终注入
const alwaysInclude: MemorySearchResult[] = [];
const scored: MemorySearchResult[] = [];
for (const entry of entries) {
if (entry.type === 'rule' || entry.type === 'preference') {
alwaysInclude.push({ ...entry, score: 100 + entry.importance });
continue;
}
let score = 0;
const contentLower = entry.content.toLowerCase();
const tagsLower = entry.tags.map(t => t.toLowerCase());
if (contentLower.includes(queryLower)) score += 50;
for (const tag of tagsLower) {
if (queryLower.includes(tag) || tag.includes(queryLower)) score += 30;
for (const word of queryWords) {
if (tag.includes(word)) score += 15;
}
}
for (const word of queryWords) {
if (contentLower.includes(word)) score += 10;
}
score *= (0.5 + entry.importance / 20);
if (score > 0) scored.push({ ...entry, score });
}
// 去重 + 排序
const seen = new Set<string>();
const merged: MemorySearchResult[] = [];
for (const item of [...alwaysInclude, ...scored.sort((a, b) => b.score - a.score)]) {
if (!seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
}
}
return merged.slice(0, limit);
}
// ═══════════════════════════════════════════════════════════════
// 上下文格式化
// ═══════════════════════════════════════════════════════════════
/**
* 将搜索结果格式化为系统提示词注入用的上下文文本
*/
export function formatMemoryContext(memories: MemorySearchResult[]): string {
if (memories.length === 0) return '';
const grouped: Record<string, MemorySearchResult[]> = {};
for (const m of memories) {
if (!grouped[m.type]) grouped[m.type] = [];
grouped[m.type].push(m);
}
let context = '';
// 规则类:强制约束,放在最前面
if (grouped['rule']) {
context += '【必须严格遵守的规则】\n以下规则是用户明确要求的,必须无条件执行:\n';
for (const item of grouped['rule']) {
context += ` - ${item.content}\n`;
}
context += '\n';
delete grouped['rule'];
}
// 偏好类:用户偏好
if (grouped['preference']) {
context += '【用户偏好】\n在回答时请遵循以下偏好:\n';
for (const item of grouped['preference']) {
context += ` - ${item.content}\n`;
}
context += '\n';
delete grouped['preference'];
}
// 事实类:参考信息
if (grouped['fact']) {
context += '【关于用户的参考信息】\n以下信息供参考,与当前对话无关时可忽略:\n';
for (const item of grouped['fact']) {
context += ` - ${item.content}\n`;
}
context += '\n';
}
context += '请自然地利用以上信息,不要生硬地列举。';
return context;
}
// ═══════════════════════════════════════════════════════════════
// CRUD 操作(全部通过专用 IPC 通道读写 MEMORY.md
// ═══════════════════════════════════════════════════════════════
/** 生成新 ID */
function generateMemoryId(): string {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const seq = String(Math.floor(Math.random() * 1000)).padStart(3, '0');
return `mem_${dateStr}_${seq}`;
}
/** 加载全部条目 */
export async function loadAllEntries(): Promise<MemoryEntry[]> {
const content = await readMemoryFile();
if (!content.trim()) return [];
return parseMemoryMd(content);
}
/** 搜索记忆(读取 MEMORY.md → 解析 → 搜索) */
export async function search(query: string, limit = 8): Promise<MemorySearchResult[]> {
try {
const entries = await loadAllEntries();
return searchMemory(entries, query, limit);
} catch (err) {
logWarn('记忆搜索失败', (err as Error).message);
return [];
}
}
/** 添加记忆条目 */
export async function addEntry(
type: MemoryType,
content: string,
importance = 5,
tags: string[] = []
): Promise<MemoryEntry> {
if (!content || content.length < 2) {
throw new Error('记忆内容不能为空且至少 2 个字符');
}
if (!VALID_TYPES.includes(type)) {
throw new Error(`无效的记忆类型: ${type},必须为 fact / preference / rule`);
}
// 安全扫描
const securityCheck = scanMemorySecurity(content);
if (!securityCheck.safe) {
throw new Error(`记忆内容被安全规则拦截: ${securityCheck.reason}`);
}
// 自动提取标签
if (tags.length === 0) {
tags = extractTags(content);
}
// 读取现有条目
let entries = await loadAllEntries();
// 容量检查
if (entries.length >= MAX_ENTRIES) {
entries = autoCleanEntries(entries);
}
// 去重检查(前 50 字符相同视为重复)
const existing = entries.find(e => {
if (e.type !== type) return false;
const prefixLen = Math.min(50, content.length, e.content.length);
if (prefixLen > 20 && content.slice(0, prefixLen) === e.content.slice(0, prefixLen)) return true;
return simpleSimilarity(e.content, content) > 0.8;
});
if (existing) return existing;
const entry: MemoryEntry = {
id: generateMemoryId(),
type,
content: content.trim(),
importance: Math.min(10, Math.max(1, importance)),
tags: tags.slice(0, 5),
};
entries.push(entry);
const fileContent = serializeMemoryMd(entries);
const validation = validateMemoryMd(fileContent);
if (!validation.valid) {
throw new Error(`序列化后校验失败: ${validation.error}`);
}
await writeMemoryFile(fileContent);
logMemory(`新增: ${type}`, content.slice(0, 60));
return entry;
}
/** 替换记忆(子串匹配) */
export async function replaceEntry(oldText: string, newContent: string): Promise<{ success: boolean; message: string }> {
if (!oldText || oldText.length < 2) {
return { success: false, message: 'old_text 不能为空且至少 2 个字符' };
}
if (!newContent || newContent.length < 2) {
return { success: false, message: 'new_content 不能为空且至少 2 个字符' };
}
const securityCheck = scanMemorySecurity(newContent);
if (!securityCheck.safe) {
return { success: false, message: `新内容被安全规则拦截: ${securityCheck.reason}` };
}
const entries = await loadAllEntries();
const matches = entries.filter(e => e.content.includes(oldText));
if (matches.length === 0) {
return { success: false, message: `未找到包含 "${oldText.slice(0, 50)}" 的记忆` };
}
if (matches.length > 1) {
return { success: false, message: `匹配到 ${matches.length} 条记忆,请使用更精确的 old_text` };
}
const target = matches[0];
target.content = newContent.trim();
// 自动刷新标签
if (target.tags.length === 0) {
target.tags = extractTags(newContent);
}
const fileContent = serializeMemoryMd(entries);
const validation = validateMemoryMd(fileContent);
if (!validation.valid) {
return { success: false, message: `序列化后校验失败: ${validation.error}` };
}
await writeMemoryFile(fileContent);
logMemory('替换记忆', `${target.id}: ${oldText.slice(0, 30)}${newContent.slice(0, 30)}`);
return { success: true, message: `已替换记忆: ${target.id}` };
}
/** 删除记忆(子串匹配) */
export async function removeEntry(oldText: string): Promise<{ success: boolean; message: string }> {
if (!oldText || oldText.length < 2) {
return { success: false, message: 'old_text 不能为空且至少 2 个字符' };
}
const entries = await loadAllEntries();
const matches = entries.filter(e => e.content.includes(oldText));
if (matches.length === 0) {
return { success: false, message: `未找到包含 "${oldText.slice(0, 50)}" 的记忆` };
}
if (matches.length > 1) {
return { success: false, message: `匹配到 ${matches.length} 条记忆,请使用更精确的 old_text` };
}
const newEntries = entries.filter(e => e.id !== matches[0].id);
const fileContent = newEntries.length > 0 ? serializeMemoryMd(newEntries) : '';
if (fileContent) {
await writeMemoryFile(fileContent);
} else {
// 清空文件(写入空内容让主进程删除或留空)
await writeMemoryFile('');
}
logMemory('删除记忆', `${matches[0].id}: ${oldText.slice(0, 50)}`);
return { success: true, message: `已删除记忆: ${matches[0].id}` };
}
/** 清空所有记忆 */
export async function clearAll(): Promise<void> {
await writeMemoryFile('');
logMemory('清空', '所有记忆已删除');
}
// ═══════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════
function extractTags(text: string): string[] {
const words = text
.replace(/[^\w\u4e00-\u9fff\s]/g, ' ')
.split(/\s+/)
.filter(w => w.length > 1);
return [...new Set(words)].slice(0, 5);
}
function simpleSimilarity(a: string, b: string): number {
const aWords = new Set(a.toLowerCase().split(/\s+/));
const bWords = new Set(b.toLowerCase().split(/\s+/));
let intersection = 0;
for (const w of aWords) {
if (bWords.has(w)) intersection++;
}
const union = aWords.size + bWords.size - intersection;
return union === 0 ? 0 : intersection / union;
}
/**
* 自动清理低价值条目(容量超限时)
* rule 类型受保护不被清理
* @returns 清理后的条目数组
*/
function autoCleanEntries(entries: MemoryEntry[]): MemoryEntry[] {
const now = Date.now();
const SEVEN_DAYS = 7 * 24 * 3600 * 1000;
// 计算评分(没有 useCount/lastUsedAt 等字段,只按 importance
const scored = entries
.map(entry => ({
entry,
score: entry.importance * 2,
}))
.filter(s => s.entry.type !== 'rule')
.sort((a, b) => a.score - b.score);
const evictCount = Math.max(1, Math.floor(entries.length * 0.2));
const evictIds = new Set(scored.slice(0, evictCount).map(s => s.entry.id));
logMemory('容量清理', `清理 ${evictIds.size} 条低价值记忆(上限 ${MAX_ENTRIES}`);
return entries.filter(e => !evictIds.has(e.id));
}
/**
* 检查记忆系统是否可用(桌面环境且工作空间已设置)
*/
export function isMemoryAvailable(): boolean {
const bridge = window.metonaDesktop;
return !!(bridge?.isDesktop && bridge?.memoryAccess);
}
/**
* 自动提取并保存记忆(对话结束时调用)
*
* 多层质量管控:
* 1. 前置条件:对话 >= 5 轮,用户消息 >= 3 条
* 2. LLM 提取:严格 prompt,最多 2 条,importance >= 7
* 3. 后置过滤:去重、内容质量、类型校验
* 4. 静默失败:任何环节失败不阻塞主流程
*
* @returns 成功保存的条目数
*/
export async function extractAndSaveMemories(
messages: Array<{ role: string; content: string }>,
api: any,
model: string,
abortSignal?: AbortSignal
): Promise<number> {
// ── 前置条件:对话太短不提取 ──
const userMsgs = messages.filter(m => m.role === 'user');
const assistantMsgs = messages.filter(m => m.role === 'assistant');
if (userMsgs.length < 3 || assistantMsgs.length < 2) {
logDebug('自动记忆跳过: 对话轮次不足', `用户${userMsgs.length}条, AI${assistantMsgs.length}`);
return 0;
}
// ── 前置条件:bridge 可用 ──
if (!isMemoryAvailable()) {
logDebug('自动记忆跳过: 非桌面环境');
return 0;
}
// ── 取最近 15 轮构建提取文本 ──
const recentMsgs = messages.slice(-30).filter(m => m.role === 'user' || m.role === 'assistant');
const conversationText = recentMsgs
.map(m => `[${m.role === 'user' ? '用户' : 'AI'}]: ${(m.content || '').slice(0, 800)}`)
.join('\n\n');
const extractPrompt = `分析以下对话,仅提取用户明确陈述的、有长期跨会话价值的个人信息。
${conversationText.slice(0, 5000)}
严格按此 JSON 返回(不要输出其他内容),最多 2 条,宁缺毋滥:
\`\`\`json
{
"entries": [
{"type": "fact", "content": "具体事实(不超过40字)", "importance": 8, "tags": ["关键词1", "关键词2"]}
]
}
\`\`\`
提取门槛(极高,必须全部满足):
1. 仅提取用户**明确陈述**的个人信息(项目名、技术栈、偏好、习惯),禁止推断
2. 必须是**跨会话有用**的长期信息,临时需求不提取
3. 每条内容 ≤40 字,精炼具体
4. importance 7-10,只有真正重要的才给 7+
5. tags 2-3 个精确关键词
6. 禁止自动提取 rule 类型
绝不提取的内容:
- AI 的回答或建议
- 泛泛表述("用户喜欢编程")
- 一次性任务描述("帮我写个函数")
- 临时查询或请求
- 可以从对话上下文轻易推断的信息
如果没有值得长期记忆的内容,返回 {"entries": []}`;
try {
// ── 调用 LLM 提取 ──
const response = await api.chat({
model,
messages: [{ role: 'user', content: extractPrompt }],
think: false,
options: { num_ctx: 8192, temperature: 0.1 }
});
// 检查中止信号
if (abortSignal?.aborted) {
logDebug('自动记忆提取已中止');
return 0;
}
const content = (response as { message?: { content?: string } })?.message?.content || '';
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/) || content.match(/\{[\s\S]*"entries"[\s\S]*\}/);
if (!jsonMatch) return 0;
const parsed: { entries?: Array<{ type: string; content: string; importance: number; tags: string[] }> } =
JSON.parse(jsonMatch[1] || jsonMatch[0]);
if (!parsed.entries?.length) return 0;
// ── 加载已有记忆用于去重 ──
const existingEntries = await loadAllEntries();
let count = 0;
const MAX_PER_EXTRACT = 2;
for (const entry of parsed.entries) {
if (count >= MAX_PER_EXTRACT) break;
// ── 过滤 1: 类型校验(仅 fact / preference,禁止 rule)──
if (!['fact', 'preference'].includes(entry.type)) {
logDebug('自动记忆过滤: 无效类型', entry.type);
continue;
}
// ── 过滤 2: 内容质量 ──
if (!entry.content || entry.content.length < 8 || entry.content.length > 100) {
logDebug('自动记忆过滤: 内容长度不符', `${entry.content?.length || 0}字符`);
continue;
}
// ── 过滤 3: 泛化检测(过于宽泛的表述)──
const genericPatterns = [
/^用户是/, /^用户喜欢/, /^用户正在/, /^用户需要/,
/^用户想要/, /^用户希望/, /^用户使用/, /^用户做/,
];
const isGeneric = genericPatterns.some(p => p.test(entry.content)) && entry.content.length < 15;
if (isGeneric) {
logDebug('自动记忆过滤: 过于泛化', entry.content);
continue;
}
// ── 过滤 4: importance 门槛(>= 7)──
const importance = Math.min(10, Math.max(1, entry.importance || 5));
if (importance < 7) {
logDebug('自动记忆过滤: importance 不足', `${importance}`);
continue;
}
// ── 过滤 5: 去重(与已有记忆比对)──
const isDuplicate = existingEntries.some(e => {
if (e.type !== entry.type) return false;
const prefixLen = Math.min(40, entry.content.length, e.content.length);
if (prefixLen > 15 && entry.content.slice(0, prefixLen) === e.content.slice(0, prefixLen)) return true;
return simpleSimilarity(e.content, entry.content) > 0.75;
});
if (isDuplicate) {
logDebug('自动记忆过滤: 重复', entry.content.slice(0, 40));
continue;
}
// ── 保存 ──
try {
await addEntry(
entry.type as MemoryType,
entry.content.trim(),
importance,
(entry.tags || []).slice(0, 3)
);
existingEntries.push({
id: '', type: entry.type as MemoryType, content: entry.content.trim(),
importance, tags: (entry.tags || []).slice(0, 3),
});
count++;
} catch (err) {
logDebug('自动记忆保存失败', (err as Error).message);
}
}
if (count > 0) {
logInfo('🧠 自动记忆提取完成', `从对话中提取了 ${count} 条有价值记忆`);
}
return count;
} catch (err) {
logDebug('自动记忆提取异常', (err as Error).message);
return 0;
}
}
/**
* 初始化 MEMORY.md 文件
* - 文件不存在 → 创建符合格式的空模板
* - 文件存在但格式不符合 → 备份为 .bak,然后创建空模板
* - 文件存在且格式正确 → 不做任何操作
*
* 应在应用启动和工作空间变更时调用
*/
export async function initMemoryFile(): Promise<{ action: string; existed: boolean; valid: boolean; backedUp?: string }> {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop || !bridge?.memoryAccess) {
logWarn('记忆初始化跳过', '非桌面环境或 bridge 不可用');
return { action: 'skipped', existed: false, valid: false };
}
try {
const result = await bridge.memoryAccess.init();
if (!result.success) {
logWarn('MEMORY.md 初始化失败', result.error || '未知错误');
return { action: 'failed', existed: false, valid: false };
}
// 日志已由主进程打印,渲染进程补充简要汇总
switch (result.action) {
case 'created':
logInfo('🧠 MEMORY.md 已创建(文件不存在,自动初始化)');
break;
case 'recreated':
logWarn('🧠 MEMORY.md 格式错误已修复', `原文件备份至: ${result.backedUp || 'MEMORY.md.bak'}`);
break;
case 'valid':
logInfo('🧠 MEMORY.md 格式校验通过', result.existed ? '文件已存在' : '');
break;
}
return {
action: result.action,
existed: result.existed,
valid: result.valid,
backedUp: result.backedUp,
};
} catch (err) {
logWarn('MEMORY.md 初始化异常', (err as Error).message);
return { action: 'failed', existed: false, valid: false };
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ const SUB_AGENT_TOOL_WHITELIST = new Set([
'read_file', 'list_directory', 'search_files',
'web_search', 'web_fetch',
'browser_extract', 'browser_screenshot',
'memory_search',
'memory',
'session_list', 'session_read',
]);
+63 -97
View File
@@ -351,72 +351,33 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
}
},
// ══════════════════════════════════════════════
// v4.0 新增工具:记忆和会话管理
// 记忆工具(统一入口,读写工作空间 MEMORY.md)
// ══════════════════════════════════════════════
{
type: 'function',
function: {
name: 'memory_search',
description: 'Search agent memories by semantic similarity or keywords. Returns relevant memories about the user, preferences, rules, and past conversations.',
name: 'memory',
description: 'Manage agent memories stored in MEMORY.md in the workspace. Supports: search (keyword search across all entries), add (append new entry with auto-generated ID), replace (substring match replace), remove (substring match delete), read_all (read all entries). Memories are automatically injected into system prompt on new conversations.',
parameters: {
type: 'object',
required: ['query'],
required: ['action'],
properties: {
query: { type: 'string', description: 'Search query for memories.' },
limit: { type: 'integer', description: 'Max results to return. Default: 8.' },
type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: 'Filter by memory type.' }
}
}
}
},
{
type: 'function',
function: {
name: 'memory_add',
description: 'Add a new memory entry for future reference. Use when you learn something important about the user, their preferences, or rules they want you to follow.',
parameters: {
type: 'object',
required: ['type', 'content'],
properties: {
type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: 'Memory type: fact (about user), preference (user preference), rule (must follow).' },
content: { type: 'string', description: 'The memory content. Be concise and specific.' },
importance: { type: 'integer', description: 'Importance 1-10. Higher = more important. Default: 5.' },
tags: { type: 'array', items: { type: 'string' }, description: 'Tags for search matching. 2-5 keywords.' }
}
}
}
},
{
type: 'function',
function: {
name: 'memory_replace',
description: 'Replace an existing memory entry with updated content. Uses substring matching to find the entry to replace.',
parameters: {
type: 'object',
required: ['target', 'old_text', 'new_content'],
properties: {
target: { type: 'string', enum: ['memory', 'user'], description: 'Target store: memory (agent notes) or user (user profile).' },
old_text: { type: 'string', description: 'Unique substring that identifies the entry to replace.' },
new_content: { type: 'string', description: 'The new content to replace the old entry with.' }
}
}
}
},
{
type: 'function',
function: {
name: 'memory_remove',
description: 'Remove a memory entry. Uses substring matching to find the entry to remove.',
parameters: {
type: 'object',
required: ['target', 'old_text'],
properties: {
target: { type: 'string', enum: ['memory', 'user'], description: 'Target store: memory (agent notes) or user (user profile).' },
old_text: { type: 'string', description: 'Unique substring that identifies the entry to remove.' }
action: { type: 'string', enum: ['search', 'add', 'replace', 'remove', 'read_all'], description: 'Action: search=keyword match search, add=append entry, replace=find by old_text and replace, remove=find by old_text and delete, read_all=return all entries.' },
query: { type: 'string', description: 'Search query for search action.' },
limit: { type: 'integer', description: 'Max results for search. Default: 8.' },
type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: 'Memory type for add. fact=about user, preference=user preference, rule=must follow.' },
content: { type: 'string', description: 'Memory content for add. Concise and specific.' },
importance: { type: 'integer', description: 'Importance 1-10 for add. Default: 5.' },
tags: { type: 'array', items: { type: 'string' }, description: 'Tags for add. 2-5 keywords for search matching.' },
old_text: { type: 'string', description: 'Unique substring to find the entry for replace/remove.' },
new_content: { type: 'string', description: 'New content for replace action.' }
}
}
}
},
// ══════════════════════════════════════════════
// 会话管理工具
// ══════════════════════════════════════════════
{
type: 'function',
function: {
@@ -733,7 +694,7 @@ let enabledTools: Set<string> = new Set([
'move_file', 'copy_file', 'web_fetch', 'web_search', 'edit_file',
'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files',
'read_multiple_files', 'git', 'compress',
'memory_search', 'memory_add', 'memory_replace', 'memory_remove',
'memory',
'session_list', 'session_read', 'spawn_task',
'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract',
'browser_click', 'browser_type', 'browser_scroll', 'browser_wait', 'browser_close',
@@ -809,46 +770,51 @@ export async function executeTool(toolName: string, args: Record<string, unknown
}
try {
// 内存工具(不走 IPC,直接处理)
if (toolName === 'memory_search') {
const { searchMemories } = await import('./memory-manager.js');
const query = args.query as string;
const limit = (args.limit as number) || 8;
const type = args.type as string | undefined;
let results = searchMemories(query, limit);
if (type) results = results.filter(r => r.type === type);
logToolResult('memory_search', true, `${results.length} 条结果`);
return { success: true, results, total: results.length };
}
if (toolName === 'memory_add') {
const { addMemory } = await import('./memory-manager.js');
const type = args.type as 'fact' | 'preference' | 'rule';
const content = args.content as string;
const importance = (args.importance as number) || 5;
const tags = (args.tags as string[]) || [];
if (!content || content.length < 2) {
return { success: false, error: 'content 不能为空且至少 2 个字符' };
// 内存工具(不走 IPC,直接处理 MEMORY.md
if (toolName === 'memory') {
const { search, addEntry, replaceEntry, removeEntry, loadAllEntries } = await import('./memory-service.js');
const action = args.action as string;
switch (action) {
case 'search': {
const query = args.query as string;
const limit = (args.limit as number) || 8;
if (!query) return { success: false, error: '缺少 query 参数' };
const results = await search(query, limit);
logToolResult('memory', true, `${results.length} 条结果`);
return { success: true, action, results, total: results.length };
}
case 'add': {
const type = args.type as 'fact' | 'preference' | 'rule';
const content = args.content as string;
const importance = (args.importance as number) || 5;
const tags = (args.tags as string[]) || [];
if (!type) return { success: false, error: '缺少 type 参数 (fact/preference/rule)' };
if (!content || content.length < 2) return { success: false, error: 'content 不能为空且至少 2 个字符' };
const entry = await addEntry(type, content, importance, tags);
logToolResult('memory', true, `${type}: ${content.slice(0, 50)}`);
return { success: true, action, id: entry.id, type: entry.type, content: entry.content };
}
case 'replace': {
const oldText = String(args.old_text || '');
const newContent = String(args.new_content || '');
const result = await replaceEntry(oldText, newContent);
logToolResult('memory', result.success, result.message);
return { success: result.success, action, ...(result.success ? {} : { error: result.message }), message: result.message };
}
case 'remove': {
const oldText = String(args.old_text || '');
const result = await removeEntry(oldText);
logToolResult('memory', result.success, result.message);
return { success: result.success, action, ...(result.success ? {} : { error: result.message }), message: result.message };
}
case 'read_all': {
const entries = await loadAllEntries();
logToolResult('memory', true, `${entries.length} 条记忆`);
return { success: true, action, entries, total: entries.length };
}
default:
return { success: false, error: `未知操作: ${action}。支持: search / add / replace / remove / read_all` };
}
const entry = await addMemory({ type, content, importance, tags });
logToolResult('memory_add', true, `${type}: ${content.slice(0, 50)}`);
return { success: true, id: entry.id, type: entry.type, content: entry.content };
}
if (toolName === 'memory_replace') {
const { replaceMemoryByContent } = await import('./memory-manager.js');
const target = String(args.target || 'memory') as 'memory' | 'user';
const oldText = String(args.old_text || '');
const newContent = String(args.new_content || '');
const result = await replaceMemoryByContent(target, oldText, newContent);
logToolResult('memory_replace', result.success, result.message);
return result;
}
if (toolName === 'memory_remove') {
const { removeMemoryByContent } = await import('./memory-manager.js');
const target = String(args.target || 'memory') as 'memory' | 'user';
const oldText = String(args.old_text || '');
const result = await removeMemoryByContent(target, oldText);
logToolResult('memory_remove', result.success, result.message);
return result;
}
if (toolName === 'session_list') {
const bridge = window.metonaDesktop;
@@ -978,7 +944,7 @@ export function getToolIcon(name: string): string {
edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', memory_replace: '🔄', memory_remove: '🗑️',
memory: '🧠',
session_list: '📋', session_read: '📖', spawn_task: '🤖',
plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
@@ -1054,7 +1020,7 @@ export function formatToolName(name: string): string {
get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆',
memory: '记忆管理',
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
-172
View File
@@ -1,172 +0,0 @@
/**
* VectorMemory - 记忆向量存储与检索
* 替代原 rag.ts,专为记忆系统提供向量能力
*/
import { state, KEYS } from '../state/state.js';
import { VectorStore } from './vector-store.js';
import { logMemory, logDebug, logError } from './log-service.js';
import type { MemoryEntry, VectorItem, SearchResult } from '../types.js';
import type { OllamaAPI } from '../api/ollama.js';
const MEMORY_COLLECTION_NAME = '记忆向量索引';
let vectorStore: VectorStore | null = null;
let memoryCollectionId: string | null = null;
// ── 初始化 ──
export async function initMemoryVectorStore(): Promise<VectorStore> {
if (vectorStore) return vectorStore;
vectorStore = new VectorStore();
await vectorStore.init();
logDebug('记忆向量存储已初始化');
return vectorStore;
}
export function getMemoryVectorStore(): VectorStore | null {
return vectorStore;
}
// ── 集合管理 ──
export async function getOrCreateMemoryCollection(embedModel: string): Promise<{ id: string; isNew: boolean }> {
const vs = await initMemoryVectorStore();
// 如果已有缓存的集合ID,验证它是否存在
if (memoryCollectionId) {
const existing = await vs.getCollection(memoryCollectionId);
if (existing) {
if (existing.embeddingModel !== embedModel) {
existing.embeddingModel = embedModel;
await vs.updateCollection(existing);
}
return { id: memoryCollectionId, isNew: false };
}
memoryCollectionId = null;
}
// 搜索现有集合
const collections = await vs.getCollections();
const found = collections.find(c => c.name === MEMORY_COLLECTION_NAME);
if (found) {
memoryCollectionId = found.id;
if (found.embeddingModel !== embedModel) {
found.embeddingModel = embedModel;
await vs.updateCollection(found);
}
return { id: found.id, isNew: false };
}
// 创建新集合
const col = await vs.createCollection(MEMORY_COLLECTION_NAME, embedModel);
memoryCollectionId = col.id;
logMemory('创建记忆向量集合', col.id);
return { id: col.id, isNew: true };
}
export function getMemoryCollectionId(): string | null {
return memoryCollectionId;
}
export function setMemoryCollectionId(id: string | null): void {
memoryCollectionId = id;
}
// ── 文本嵌入 ──
export async function embedText(text: string, model: string): Promise<number[]> {
const api = state.get<OllamaAPI>(KEYS.API);
if (!api) throw new Error('Ollama API 未连接');
const result = await api.embed(model, text);
if (result.embedding) return result.embedding;
if (result.embeddings && result.embeddings[0]) return result.embeddings[0];
throw new Error('嵌入向量返回格式异常');
}
// ── 为记忆条目生成向量 ──
export async function embedMemoryEntry(entry: MemoryEntry, model: string): Promise<number[]> {
const text = `[${entry.type}] ${entry.content} ${entry.tags.join(' ')}`;
return embedText(text, model);
}
// ── 添加记忆向量 ──
export async function addMemoryVector(entry: MemoryEntry, embedding: number[], colId: string): Promise<void> {
const vs = await initMemoryVectorStore();
const item: VectorItem = {
id: `vec_${entry.id}`,
collectionId: colId,
docId: entry.id,
filename: entry.type,
chunkIndex: 0,
text: entry.content,
charCount: entry.content.length,
embedding
};
await vs.addVectors([item]);
}
// ── 更新记忆向量 ──
export async function updateMemoryVector(entry: MemoryEntry, embedding: number[], colId: string): Promise<void> {
// 先删除旧的,再添加新的
await deleteMemoryVector(entry.id, colId);
await addMemoryVector(entry, embedding, colId);
}
// ── 删除记忆向量 ──
export async function deleteMemoryVector(entryId: string, colId: string): Promise<void> {
const vs = await initMemoryVectorStore();
await vs.deleteVectorsByDocument(colId, entryId);
}
// ── 向量搜索记忆 ──
export async function searchMemoriesByVector(query: string, colId: string, topK = 8): Promise<SearchResult[]> {
const vs = await initMemoryVectorStore();
const col = await vs.getCollection(colId);
if (!col || !col.embeddingModel) return [];
const queryEmbedding = await embedText(query, col.embeddingModel);
const results = await vs.search(colId, queryEmbedding, topK);
return results;
}
// ── 重新索引所有记忆 ──
export async function reindexAllMemories(
entries: MemoryEntry[],
embedModel: string,
colId: string,
onProgress?: (done: number, total: number) => void
): Promise<void> {
const vs = await initMemoryVectorStore();
// 清空集合中的旧向量
const oldVectors = await vs.getVectorsByCollection(colId);
if (oldVectors.length > 0) {
await vs.deleteCollection(colId);
const col = await vs.createCollection(MEMORY_COLLECTION_NAME, embedModel);
memoryCollectionId = col.id;
colId = col.id;
}
logMemory(`开始重新索引: ${entries.length} 条记忆`);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
try {
const embedding = await embedMemoryEntry(entry, embedModel);
await addMemoryVector(entry, embedding, colId);
} catch (err) {
logError(`索引记忆失败: ${entry.id}`, (err as Error).message);
}
if (onProgress) onProgress(i + 1, entries.length);
}
logMemory(`重新索引完成: ${entries.length}`);
}
-371
View File
@@ -1,371 +0,0 @@
/**
* VectorStore - 向量存储与相似度检索
*/
import type { VectorCollection, VectorItem, SearchResult } from '../types.js';
import { logRAG, logDebug } from './log-service.js';
const MIN_IVF_SIZE = 200;
const DEFAULT_K = 20;
const DEFAULT_NPROBE = 5;
const KMEANS_ITERS = 10;
interface IVFIndex {
centroids: number[][];
invertedLists: Map<number, string[]>;
collectionId?: string;
version?: string;
}
export class VectorStore {
private dbName: string;
private db: IDBDatabase | null = null;
private _indexCache = new Map<string, IVFIndex>();
private _vectorCache = new Map<string, Map<string, VectorItem>>();
constructor(dbName = 'metona-ollama-vectors') {
this.dbName = dbName;
}
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(this.dbName, 2);
req.onerror = () => reject(req.error);
req.onsuccess = () => { this.db = req.result; logDebug('向量数据库已连接'); resolve(); };
req.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('vectors')) {
const store = db.createObjectStore('vectors', { keyPath: 'id' });
store.createIndex('collectionId', 'collectionId', { unique: false });
}
if (!db.objectStoreNames.contains('collections')) {
db.createObjectStore('collections', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('indexes')) {
db.createObjectStore('indexes', { keyPath: 'collectionId' });
}
};
});
}
private _tx(store: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
return this.db!.transaction(store, mode).objectStore(store);
}
async createCollection(name: string, embeddingModel = ''): Promise<VectorCollection> {
const col: VectorCollection = {
id: `mem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
name, embeddingModel,
docCount: 0, chunkCount: 0,
createdAt: Date.now(), updatedAt: Date.now()
};
return new Promise((resolve, reject) => {
const req = this._tx('collections', 'readwrite').put(col);
req.onsuccess = () => { logRAG(`创建集合: ${name}`, col.id); resolve(col); };
req.onerror = () => reject(req.error);
});
}
async getCollections(): Promise<VectorCollection[]> {
return new Promise((resolve, reject) => {
const req = this._tx('collections').getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
async getCollection(id: string): Promise<VectorCollection | null> {
return new Promise((resolve, reject) => {
const req = this._tx('collections').get(id);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
}
async updateCollection(col: VectorCollection): Promise<void> {
col.updatedAt = Date.now();
return new Promise((resolve, reject) => {
const req = this._tx('collections', 'readwrite').put(col);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async deleteCollection(colId: string): Promise<void> {
const vectors = await this.getVectorsByCollection(colId);
logRAG(`删除集合`, `${colId} (${vectors.length} 向量)`);
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(['vectors', 'collections', 'indexes'], 'readwrite');
const vStore = tx.objectStore('vectors');
for (const v of vectors) vStore.delete(v.id);
tx.objectStore('collections').delete(colId);
tx.objectStore('indexes').delete(colId);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
this._indexCache.delete(colId);
this._vectorCache.delete(colId);
}
async addVectors(items: VectorItem[]): Promise<void> {
if (items.length === 0) return;
const colId = items[0].collectionId;
logDebug(`写入向量`, `${colId}: ${items.length}`);
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction('vectors', 'readwrite');
const store = tx.objectStore('vectors');
for (const item of items) store.put(item);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
if (!this._vectorCache.has(colId)) {
this._vectorCache.set(colId, new Map());
}
const cache = this._vectorCache.get(colId)!;
for (const item of items) cache.set(item.id, item);
this._indexCache.delete(colId);
}
async getVectorsByCollection(colId: string): Promise<VectorItem[]> {
if (this._vectorCache.has(colId)) {
return Array.from(this._vectorCache.get(colId)!.values());
}
const vectors = await new Promise<VectorItem[]>((resolve, reject) => {
const idx = this._tx('vectors').index('collectionId');
const req = idx.getAll(colId);
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
const cache = new Map<string, VectorItem>();
for (const v of vectors) cache.set(v.id, v);
this._vectorCache.set(colId, cache);
return vectors;
}
async deleteVectorsByDocument(colId: string, docId: string): Promise<void> {
const vectors = await this.getVectorsByCollection(colId);
const docVectors = vectors.filter(v => v.docId === docId);
logDebug(`删除文档向量`, `${docId}: ${docVectors.length}`);
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction('vectors', 'readwrite');
const store = tx.objectStore('vectors');
for (const v of docVectors) store.delete(v.id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
if (this._vectorCache.has(colId)) {
const cache = this._vectorCache.get(colId)!;
for (const v of docVectors) cache.delete(v.id);
}
this._indexCache.delete(colId);
}
async search(colId: string, queryEmbedding: number[], topK = 5): Promise<SearchResult[]> {
const vectors = await this.getVectorsByCollection(colId);
if (vectors.length === 0) return [];
let results: SearchResult[];
if (vectors.length < MIN_IVF_SIZE) {
results = this._bruteForceSearch(vectors, queryEmbedding, topK);
} else {
const index = await this._getIndex(colId, vectors);
results = index ? this._ivfSearch(vectors, index, queryEmbedding, topK) : this._bruteForceSearch(vectors, queryEmbedding, topK);
}
logDebug(`向量搜索`, `${colId}: ${vectors.length} 向量中检索到 ${results.length} 个结果`);
return results;
}
private _bruteForceSearch(vectors: VectorItem[], queryEmbedding: number[], topK: number): SearchResult[] {
return vectors
.map(v => ({ ...v, score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
private _ivfSearch(vectors: VectorItem[], index: IVFIndex, queryEmbedding: number[], topK: number): SearchResult[] {
const { centroids, invertedLists } = index;
const clusterScores = centroids.map((c, i) => ({
id: i,
score: VectorStore.cosineSimilarity(queryEmbedding, c)
}));
clusterScores.sort((a, b) => b.score - a.score);
const nprobe = Math.min(DEFAULT_NPROBE, centroids.length);
const targetClusters = clusterScores.slice(0, nprobe);
const candidateIds = new Set<string>();
for (const { id: clusterId } of targetClusters) {
const list = invertedLists.get(clusterId);
if (list) for (const id of list) candidateIds.add(id);
}
const vectorMap = new Map<string, VectorItem>();
for (const v of vectors) vectorMap.set(v.id, v);
const candidates: VectorItem[] = [];
for (const id of candidateIds) {
const v = vectorMap.get(id);
if (v) candidates.push(v);
}
return candidates
.map(v => ({ ...v, score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
private async _getIndex(colId: string, vectors: VectorItem[]): Promise<IVFIndex | null> {
if (this._indexCache.has(colId)) {
const cached = this._indexCache.get(colId)!;
if (cached.version === this._indexVersion(vectors)) return cached;
}
const saved = await new Promise<IVFIndex & { invertedListsObj?: Record<string, string[]> } | null>((resolve, reject) => {
const req = this._tx('indexes').get(colId);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
if (saved && saved.version === this._indexVersion(vectors)) {
if (!(saved.invertedLists instanceof Map)) {
saved.invertedLists = new Map(Object.entries(saved.invertedListsObj || {}).map(([k, v]) => [parseInt(k), v as string[]]));
}
this._indexCache.set(colId, saved);
return saved;
}
const index = this._buildIVFIndex(vectors);
index.collectionId = colId;
index.version = this._indexVersion(vectors);
const toSave = {
collectionId: index.collectionId,
version: index.version,
centroids: index.centroids,
invertedListsObj: Object.fromEntries(index.invertedLists)
};
await new Promise<void>((resolve, reject) => {
const req = this._tx('indexes', 'readwrite').put(toSave);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
this._indexCache.set(colId, index);
return index;
}
private _indexVersion(vectors: VectorItem[]): string {
if (vectors.length === 0) return '0';
return `${vectors.length}_${vectors[0]?.id || ''}_${vectors[vectors.length - 1]?.id || ''}`;
}
private _buildIVFIndex(vectors: VectorItem[]): IVFIndex {
const N = vectors.length;
const dim = vectors[0].embedding.length;
const K = Math.max(2, Math.min(DEFAULT_K, Math.floor(N / 10)));
const centroids = this._kmeansPPInit(vectors, K, dim);
const assignments = new Array<number>(N);
for (let iter = 0; iter < KMEANS_ITERS; iter++) {
for (let i = 0; i < N; i++) {
let bestCluster = 0;
let bestScore = -Infinity;
for (let k = 0; k < K; k++) {
const score = VectorStore.cosineSimilarity(vectors[i].embedding, centroids[k]);
if (score > bestScore) { bestScore = score; bestCluster = k; }
}
assignments[i] = bestCluster;
}
const newCentroids = Array.from({ length: K }, () => new Array<number>(dim).fill(0));
const counts = new Array<number>(K).fill(0);
for (let i = 0; i < N; i++) {
const k = assignments[i];
counts[k]++;
const emb = vectors[i].embedding;
for (let d = 0; d < dim; d++) newCentroids[k][d] += emb[d];
}
for (let k = 0; k < K; k++) {
if (counts[k] > 0) {
for (let d = 0; d < dim; d++) newCentroids[k][d] /= counts[k];
this._normalize(newCentroids[k]);
} else {
newCentroids[k] = [...vectors[Math.floor(Math.random() * N)].embedding];
}
}
let converged = true;
for (let k = 0; k < K; k++) {
if (VectorStore.cosineSimilarity(centroids[k], newCentroids[k]) < 0.999) {
converged = false;
break;
}
}
for (let k = 0; k < K; k++) centroids[k] = newCentroids[k];
if (converged) break;
}
const invertedLists = new Map<number, string[]>();
for (let i = 0; i < N; i++) {
const k = assignments[i];
if (!invertedLists.has(k)) invertedLists.set(k, []);
invertedLists.get(k)!.push(vectors[i].id);
}
return { centroids, invertedLists };
}
private _kmeansPPInit(vectors: VectorItem[], K: number, dim: number): number[][] {
const centroids: number[][] = [];
const first = Math.floor(Math.random() * vectors.length);
centroids.push([...vectors[first].embedding]);
for (let k = 1; k < K; k++) {
const dists = vectors.map(v => {
let minDist = Infinity;
for (const c of centroids) {
const sim = VectorStore.cosineSimilarity(v.embedding, c);
const dist = 1 - sim;
if (dist < minDist) minDist = dist;
}
return minDist;
});
const total = dists.reduce((s, d) => s + d, 0);
if (total === 0) {
centroids.push([...vectors[Math.floor(Math.random() * vectors.length)].embedding]);
continue;
}
let r = Math.random() * total;
for (let i = 0; i < vectors.length; i++) {
r -= dists[i];
if (r <= 0) { centroids.push([...vectors[i].embedding]); break; }
}
}
return centroids;
}
private _normalize(vec: number[]): void {
let norm = 0;
for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i];
norm = Math.sqrt(norm);
if (norm > 0) for (let i = 0; i < vec.length; i++) vec[i] /= norm;
}
static cosineSimilarity(a: number[], b: number[]): number {
if (!a || !b || a.length !== b.length) return 0;
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}
}