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:
@@ -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));
|
||||
|
||||
@@ -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: '会话操作' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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: '提取内容',
|
||||
|
||||
@@ -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} 条`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user