Files
metona-ollama-desktop/src/renderer/services/memory-service.ts
T
紫影233 0903d740da 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
2026-06-24 14:49:09 +08:00

810 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Memory 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 };
}
}