From 0903d740dacb41be683ef7bc56771738391d8ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B4=AB=E5=BD=B1233?= Date: Wed, 24 Jun 2026 14:49:09 +0800 Subject: [PATCH] =?UTF-8?q?v0.13.0:=20=E8=AE=B0=E5=BF=86=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20=E2=80=94=20MEMORY.md=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=8C=96=20+=20=E8=B7=AF=E5=BE=84=E4=BF=9D=E6=8A=A4=20+=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8F=90=E5=8F=96=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心变更: - 删除 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 --- README.md | 42 +- package-lock.json | 4 +- package.json | 2 +- src/main/db/sqlite.ts | 143 +--- src/main/ipc.ts | 161 +++- src/main/menu.ts | 2 +- src/main/preload.ts | 12 +- src/main/tool-security.ts | 27 + src/renderer/components/chat-area.ts | 4 +- src/renderer/components/input-area.ts | 1 - src/renderer/components/memory-modal.ts | 427 ++--------- src/renderer/components/settings-modal.ts | 93 +-- src/renderer/components/workspace-panel.ts | 6 +- src/renderer/db/chat-db.ts | 138 +--- src/renderer/index.html | 71 +- src/renderer/main.ts | 15 +- src/renderer/public/AGENT.md | 8 +- src/renderer/services/agent-engine.ts | 55 +- src/renderer/services/completion-gate.ts | 4 +- src/renderer/services/memory-manager.ts | 751 ------------------- src/renderer/services/memory-service.ts | 809 +++++++++++++++++++++ src/renderer/services/sub-agent.ts | 2 +- src/renderer/services/tool-registry.ts | 160 ++-- src/renderer/services/vector-memory.ts | 172 ----- src/renderer/services/vector-store.ts | 371 ---------- src/renderer/types.d.ts | 91 +-- 26 files changed, 1207 insertions(+), 2364 deletions(-) delete mode 100644 src/renderer/services/memory-manager.ts create mode 100644 src/renderer/services/memory-service.ts delete mode 100644 src/renderer/services/vector-memory.ts delete mode 100644 src/renderer/services/vector-store.ts diff --git a/README.md b/README.md index af794ce..1303128 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -37,8 +37,8 @@ | 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。8 状态机(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行,看门狗超时保护 | | 🛡️ | **5 层抗幻觉系统** | 提示词加固 → 任务感知 → 中途检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入/文件/工具 4 类关键阻断) | | 📋 | **Plan Mode** | 开关切换,AI 首先生成执行计划(Markdown 渲染确认弹窗),批准后按步骤追踪执行,plan_track 工具标记完成状态 | -| 🔧 | **42 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 · Plan Mode 追踪 | -| 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),FTS5 全文搜索 + 向量语义搜索,写入前安全扫描,容量 500 条,90 天衰减 | +| 🔧 | **40 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 · Plan Mode 追踪 | +| 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),存储于工作空间 MEMORY.md 文件,受路径保护仅 memory 工具可访问,写入前安全扫描,容量 500 条,对话结束自动提取,严格格式校验 | | 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback | | 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 | | 🔍 | **联网搜索(双模式)** | SearXNG 元搜索引擎 JSON API(70+引擎聚合)/ 四引擎 HTML 解析(Bing+百度+搜狗+360),双模式可切换;web_fetch 支持反爬+UA切换+浏览器回退 | @@ -129,14 +129,11 @@

-🧠 记忆管理(4 个) +🧠 记忆管理(1 个) | 工具 | 功能 | |------|------| -| `memory_search` | 搜索记忆(FTS5 + 向量语义) | -| `memory_add` | 添加记忆(自动安全扫描) | -| `memory_replace` | 修改记忆 | -| `memory_remove` | 删除记忆 | +| `memory` | 统一记忆管理(5 个 action:search 搜索 / add 添加 / replace 替换 / remove 删除 / read_all 读取全部)。工作空间 MEMORY.md 受路径保护,仅此工具可访问 |
@@ -179,7 +176,7 @@ ``` 用户消息 → 扫描工作空间 SOUL.md(不可压缩层)→ AGENT.md → USER.md ↓ - 记忆检索 (FTS5 + 向量语义搜索) → 上下文注入 + 记忆检索 (MEMORY.md 关键词搜索) → 上下文注入 ↓ Agent Engine (8 状态机 ReAct Loop, ≤85 轮, 看门狗 30min) ↓ @@ -214,14 +211,13 @@ main.ts (入口) ### 🗄️ 数据库 -SQLite (sql.js WASM),6 张表,WAL 模式 + FTS5 全文搜索: +SQLite (sql.js WASM),5 张表,WAL 模式: | 表 | 用途 | |---|---| | `sessions` | 会话(parent_id 父子关系) | | `messages` | 消息(外键级联删除) | | `tool_calls` | 工具调用记录 | -| `memories` | Agent 记忆(FTS5 + 向量嵌入) | | `settings` | 设置(JSON 序列化) | | `traces` | ReAct 执行轨迹 | @@ -234,7 +230,7 @@ SQLite (sql.js WASM),6 张表,WAL 模式 + FTS5 全文搜索: | 🖥️ 前端渲染 | HTML 净化器(白名单标签 + URI 协议检查) | | 🔒 Electron | `contextIsolation: true` + IPC 白名单 + IPC fs 路径验证 | | 🔐 数据加密 | AES-256-GCM | -| 🧠 记忆安全 | 写入前 Prompt Injection / 敏感信息检测 | +| 🧠 记忆安全 | 写入前 Prompt Injection / 敏感信息检测,多层级去重过滤 | | 🌐 MCP 安全 | Shadowing 防护 + 双下划线分隔防歧义 | | 🌐 网络安全 | `web_fetch` 流式体积限制(10MB)+ 无 content-length 时防 OOM | | ⚡ 进程安全 | Windows `taskkill` 强制终止 + 工作空间路径大小写不敏感校验 | @@ -256,7 +252,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.12.11.exe` +产出:`release/Metona Ollama Setup v0.13.0.exe` ## 🛠️ 常用命令 @@ -289,8 +285,8 @@ npm run dist # 构建 Windows 安装包 | 🤖 | **ReAct Agent Loop** | Always-on, only chat mode. 8-state machine (INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED), up to 85 iterations (configurable), auto-retry 2 times, tool dedup, parallel/chain execution, watchdog timeout protection | | 🛡️ | **5-Layer Anti-Hallucination** | Prompt hardening → task awareness → mid-task detection (16 rules covering all tool categories) → progress anchors → completion gate (6 checks: hallucination, injection, file, tool — 4 critical blocks) | | 📋 | **Plan Mode** | Toggle switch. AI first generates an execution plan (Markdown-rendered confirmation dialog), then tracks step-by-step execution, with plan_track tool marking completion | -| 🔧 | **42 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Sessions · Sub-agent · System · Plan Mode tracking | -| 🧠 | **Smart Memory System** | Three types (fact / preference / rule), FTS5 + vector semantic search, pre-write security scan, 500 capacity, 90-day decay | +| 🔧 | **40 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Sessions · Sub-agent · System · Plan Mode tracking | +| 🧠 | **Smart Memory System** | Three types (fact / preference / rule), stored in workspace MEMORY.md file, path-protected — only the memory tool can access it, pre-write security scan, 500 capacity, auto-extract on conversation end, strict format validation | | 📋 | **Custom Files** | SOUL.md (persona, never compressed) + AGENT.md (behavior rules) + USER.md (user profile), workspace-first with built-in defaults | | 🌐 | **MCP Protocol Extension** | JSON-RPC 2.0 over stdio, dynamic tool discovery, Shadowing protection | | 🔍 | **Web Search (dual-mode)** | SearXNG meta-search JSON API (70+ engines) / quad-engine HTML parsing (Bing+Baidu+Sogou+360), switchable; web_fetch with auto-retry+mobile UA+SPA browser fallback | @@ -380,14 +376,11 @@ npm run dist # 构建 Windows 安装包
-🧠 Memory Management (4) +🧠 Memory Management (1) | Tool | Function | |------|------| -| `memory_search` | Search memories (FTS5 + vector semantic) | -| `memory_add` | Add memory (auto security scan) | -| `memory_replace` | Modify memory | -| `memory_remove` | Delete memory | +| `memory` | Unified memory management (5 actions: search / add / replace / remove / read_all). Workspace MEMORY.md is path-protected, only this tool can access it |
@@ -430,7 +423,7 @@ npm run dist # 构建 Windows 安装包 ``` User message → workspace SOUL.md (never compressed) → AGENT.md → USER.md ↓ - Memory Retrieval (FTS5 + Vector Semantic Search) → Context Injection + Memory Retrieval (MEMORY.md keyword search) → Context Injection ↓ Agent Engine (8-state ReAct Loop, ≤85 iter, watchdog 30min) ↓ @@ -465,14 +458,13 @@ main.ts (Entry) ### 🗄️ Database -SQLite (sql.js WASM), 6 tables, WAL mode + FTS5 full-text search: +SQLite (sql.js WASM), 5 tables, WAL mode: | Table | Purpose | |---|---| | `sessions` | Sessions (parent_id hierarchy) | | `messages` | Messages (cascade delete) | | `tool_calls` | Tool call records | -| `memories` | Agent memories (FTS5 + vector embedding) | | `settings` | Settings (JSON serialization) | | `traces` | ReAct execution traces | @@ -485,7 +477,7 @@ SQLite (sql.js WASM), 6 tables, WAL mode + FTS5 full-text search: | 🖥️ Frontend Rendering | HTML sanitizer (whitelist tags + URI protocol check) | | 🔒 Electron | `contextIsolation: true` + IPC whitelist + IPC fs path validation | | 🔐 Data Encryption | AES-256-GCM | -| 🧠 Memory Security | Pre-write prompt injection / sensitive info detection | +| 🧠 Memory Security | Pre-write prompt injection / sensitive info detection, multi-layer dedup filtering | | 🌐 MCP Security | Shadowing protection + double-underscore delimiter disambiguation | | 🌐 Network Security | `web_fetch` streaming size limit (10MB) + OOM prevention without content-length | | ⚡ Process Security | Windows `taskkill` forced termination + case-insensitive workspace path validation | @@ -507,7 +499,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.12.11.exe` +Output: `release/Metona Ollama Setup v0.13.0.exe` ## 🛠️ Common Commands diff --git a/package-lock.json b/package-lock.json index 7cedc0e..1e5f8c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.12.11", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.12.11", + "version": "0.13.0", "license": "MIT", "dependencies": { "ffmpeg-static": "^5.2.0", diff --git a/package.json b/package.json index 13d1d7c..408c826 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.12.11", + "version": "0.13.0", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", diff --git a/src/main/db/sqlite.ts b/src/main/db/sqlite.ts index 6a0ed1f..89b1c6b 100644 --- a/src/main/db/sqlite.ts +++ b/src/main/db/sqlite.ts @@ -73,7 +73,6 @@ function runTransaction(db: SQL.Database, fn: () => void): void { let db: SQL.Database | null = null; let dbPath: string | null = null; -let _hasFTS5 = false; /** 获取数据库实例 */ export function getDb(): SQL.Database { @@ -171,24 +170,6 @@ export async function initDatabase(): Promise { ); CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id, tool_name); - -- 记忆表 - CREATE TABLE IF NOT EXISTS memories ( - id TEXT PRIMARY KEY, - type TEXT NOT NULL, - content TEXT NOT NULL, - importance INTEGER DEFAULT 5, - tags TEXT, - source TEXT, - session_id TEXT, - use_count INTEGER DEFAULT 0, - embedding TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - last_used_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type); - CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance DESC); - -- 设置表 CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, @@ -221,21 +202,6 @@ export async function initDatabase(): Promise { try { db.run('ALTER TABLE traces ADD COLUMN error_pattern TEXT'); } catch { /* 列已存在,忽略 */ } - // 尝试创建 FTS5 全文搜索(可选,sql.js 默认 WASM 可能不包含 FTS5) - try { - db.run(` - CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( - content, tags, type, - content='memories', - content_rowid='rowid' - ); - `); - _hasFTS5 = true; - } catch { - _hasFTS5 = false; - // FTS5 不可用时降级为 LIKE 搜索,状态通过 _hasFTS5 跟踪 - } - // 写入一次确保文件存在 persist(); @@ -271,20 +237,6 @@ export interface MessageRow { created_at: number; } -export interface MemoryRow { - id: string; - type: string; - content: string; - importance: number; - tags: string | null; - source: string | null; - session_id: string | null; - use_count: number; - embedding: string | null; - created_at: number; - updated_at: number; - last_used_at: number; -} export interface SettingRow { key: string; @@ -366,93 +318,6 @@ export function getMessagesBySession(sessionId: string): MessageRow[] { } -// ─── Memories CRUD ─── - -export function saveMemory(entry: MemoryRow): string { - const d = getDb(); - runExec(d, `INSERT OR REPLACE INTO memories (id, type, content, importance, tags, source, session_id, use_count, embedding, created_at, updated_at, last_used_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [entry.id, entry.type, entry.content, entry.importance, entry.tags, - entry.source, entry.session_id, entry.use_count, entry.embedding, - entry.created_at, entry.updated_at, entry.last_used_at] - ); - // FTS 同步(仅在 FTS5 可用时) - if (_hasFTS5) { - try { - runExec(d, "DELETE FROM memories_fts WHERE rowid IN (SELECT rowid FROM memories WHERE id = ?)", [entry.id]); - runExec(d, "INSERT INTO memories_fts(rowid, content, tags, type) SELECT rowid, content, tags, type FROM memories WHERE id = ?", [entry.id]); - } catch { /* FTS 同步失败不影响主流程 */ } - } - persist(); - return entry.id; -} - -export function getMemory(id: string): MemoryRow | null { - return queryOne(getDb(), 'SELECT * FROM memories WHERE id = ?', [id]) as unknown as MemoryRow | null; -} - -export function getAllMemories(): MemoryRow[] { - return queryAll(getDb(), 'SELECT * FROM memories ORDER BY importance DESC, updated_at DESC') as unknown as MemoryRow[]; -} - -export function getMemoriesByType(type: string): MemoryRow[] { - return queryAll(getDb(), 'SELECT * FROM memories WHERE type = ? ORDER BY importance DESC', [type]) as unknown as MemoryRow[]; -} - -export function deleteMemory(id: string): void { - const d = getDb(); - if (_hasFTS5) { - try { runExec(d, "DELETE FROM memories_fts WHERE rowid IN (SELECT rowid FROM memories WHERE id = ?)", [id]); } catch { /* ignore */ } - } - runExec(d, 'DELETE FROM memories WHERE id = ?', [id]); - persist(); -} - -export function clearAllMemories(): void { - const d = getDb(); - d.run('DELETE FROM memories'); - if (_hasFTS5) { - try { d.run('DELETE FROM memories_fts'); } catch { /* ignore */ } - } - persist(); -} - -export function searchMemoriesFTS(query: string, limit = 10): MemoryRow[] { - const d = getDb(); - - // FTS5 不可用时直接走 LIKE - if (!_hasFTS5) { - const likeQ = `%${query.replace(/[^\w\u4e00-\u9fff]/g, '%')}%`; - return queryAll(d, ` - SELECT * FROM memories - WHERE content LIKE ? OR tags LIKE ? - ORDER BY importance DESC - LIMIT ? - `, [likeQ, likeQ, limit]) as unknown as MemoryRow[]; - } - - try { - // FTS5 搜索 - const ftsQuery = query.replace(/[^\w\u4e00-\u9fff\s]/g, ' ').split(/\s+/).filter(w => w.length > 0).map(w => `"${w}"*`).join(' OR '); - if (!ftsQuery) return []; - return queryAll(d, ` - SELECT m.* FROM memories m - JOIN memories_fts f ON m.rowid = f.rowid - WHERE memories_fts MATCH ? - ORDER BY rank - LIMIT ? - `, [ftsQuery, limit]) as unknown as MemoryRow[]; - } catch { - // FTS 失败回退到 LIKE 搜索 - return queryAll(d, ` - SELECT * FROM memories - WHERE content LIKE ? OR tags LIKE ? - ORDER BY importance DESC - LIMIT ? - `, [`%${query}%`, `%${query}%`, limit]) as unknown as MemoryRow[]; - } -} - // ─── Settings CRUD ─── export function saveSetting(key: string, value: unknown): void { @@ -571,7 +436,6 @@ export function getAllSessionsTokenStats(): AllSessionsTokenStats { export interface ExportData { sessions: SessionRow[]; messages: MessageRow[]; - memories: MemoryRow[]; settings: Array<{ key: string; value: unknown }>; exportedAt: number; } @@ -580,10 +444,9 @@ export function exportAllSessions(): ExportData { const d = getDb(); const sessions = queryAll(d, 'SELECT * FROM sessions') as unknown as SessionRow[]; const messages = queryAll(d, 'SELECT * FROM messages') as unknown as MessageRow[]; - const memories = queryAll(d, 'SELECT * FROM memories') as unknown as MemoryRow[]; const settingsRows = queryAll(d, 'SELECT * FROM settings') as unknown as SettingRow[]; const settings = settingsRows.map(r => ({ key: r.key, value: (() => { try { return JSON.parse(r.value); } catch { return r.value; } })() })); - return { sessions, messages, memories, settings, exportedAt: Date.now() }; + return { sessions, messages, settings, exportedAt: Date.now() }; } export function importSessions(data: ExportData): { imported: number; skipped: number } { @@ -602,10 +465,6 @@ export function importSessions(data: ExportData): { imported: number; skipped: n const sessionExists = queryOne(d, 'SELECT id FROM sessions WHERE id = ?', [msg.session_id]); if (sessionExists) saveMessage(msg); } - for (const mem of data.memories) { - const existing = queryOne(d, 'SELECT id FROM memories WHERE id = ?', [mem.id]); - if (!existing) saveMemory(mem); - } for (const s of data.settings) { saveSetting(s.key, s.value); } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 8e3daf0..a44098a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -11,7 +11,6 @@ import { showNotification } from './utils.js'; import { initDatabase, saveSession, getSession, getAllSessions, deleteSession, clearAllSessions, saveMessage, getMessagesBySession, - saveMemory, getMemory, getAllMemories, getMemoriesByType, deleteMemory, clearAllMemories, searchMemoriesFTS, saveSetting, getSetting, saveToolCall, getToolCallsBySession, saveTrace, getTracesBySession, @@ -387,36 +386,6 @@ export async function setupIPC(): Promise { catch { return []; } }); - // Memories - ipcMain.handle('db:saveMemory', (_, entry) => { - try { return { success: true, id: saveMemory(entry) }; } - catch (err) { return { success: false, error: (err as Error).message }; } - }); - ipcMain.handle('db:getMemory', (_, id) => { - try { return getMemory(id); } - catch { return null; } - }); - ipcMain.handle('db:getAllMemories', () => { - try { return getAllMemories(); } - catch { return []; } - }); - ipcMain.handle('db:getMemoriesByType', (_, type) => { - try { return getMemoriesByType(type); } - catch { return []; } - }); - ipcMain.handle('db:deleteMemory', (_, id) => { - try { deleteMemory(id); return { success: true }; } - catch (err) { return { success: false, error: (err as Error).message }; } - }); - ipcMain.handle('db:clearAllMemories', () => { - try { clearAllMemories(); return { success: true }; } - catch (err) { return { success: false, error: (err as Error).message }; } - }); - ipcMain.handle('db:searchMemories', (_, query: string, limit?: number) => { - try { return searchMemoriesFTS(query, limit ?? 10); } - catch { return []; } - }); - // Settings ipcMain.handle('db:saveSetting', (_, key: string, value: unknown) => { try { saveSetting(key, value); return { success: true }; } @@ -463,6 +432,110 @@ export async function setupIPC(): Promise { catch (err) { sendLog('error', '获取全局 Token 统计失败', (err as Error).message); return null; } }); + // ── Memory 文件访问(专用通道,绕过 checkPathAllowed,仅限 MEMORY.md)── + ipcMain.handle('memory:read', async () => { + const wsDir = getWorkspaceDir(); + const memoryPath = path.join(wsDir, 'MEMORY.md'); + try { + if (!fs.existsSync(memoryPath)) { + return { success: true, content: '' }; + } + const content = await fs.promises.readFile(memoryPath, 'utf-8'); + return { success: true, content }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }); + + ipcMain.handle('memory:write', async (_, content: string) => { + const wsDir = getWorkspaceDir(); + const memoryPath = path.join(wsDir, 'MEMORY.md'); + try { + // 确保工作空间目录存在 + if (!fs.existsSync(wsDir)) { + await fs.promises.mkdir(wsDir, { recursive: true }); + } + if (content === '' || content.trim() === '') { + // 空内容 → 删除文件(让下次读取返回空) + if (fs.existsSync(memoryPath)) { + await fs.promises.unlink(memoryPath); + } + sendLog('info', '🧠 memory:write', 'MEMORY.md 已清空'); + return { success: true }; + } + await fs.promises.writeFile(memoryPath, content, 'utf-8'); + sendLog('success', '🧠 memory:write', `MEMORY.md 已写入 (${content.length} 字符)`); + return { success: true }; + } catch (err) { + sendLog('error', '🧠 memory:write 失败', (err as Error).message); + return { success: false, error: (err as Error).message }; + } + }); + + /** MEMORY.md 初始化:检查 → 校验 → 备份(如格式错误) → 重建 */ + ipcMain.handle('memory:init', async () => { + const wsDir = getWorkspaceDir(); + const memoryPath = path.join(wsDir, 'MEMORY.md'); + const result: { action: string; existed: boolean; valid: boolean; backedUp?: string } = { + action: '', existed: false, valid: false, + }; + + try { + // 确保工作空间目录存在 + if (!fs.existsSync(wsDir)) { + await fs.promises.mkdir(wsDir, { recursive: true }); + sendLog('info', '🧠 memory:init', `工作空间目录已创建: ${wsDir}`); + } + + const exists = fs.existsSync(memoryPath); + result.existed = exists; + + if (!exists) { + // 文件不存在 → 创建符合格式的空模板 + const template = `# METONA MEMORY +> ⚠️ 此文件由 memory 工具自动管理,严禁手动编辑 +> 元数据格式: ## [类型] | id: [ID] | importance: [1-10] | tags: [tag1, tag2, ...] +> 条目内容紧跟元数据行,直到下一个 ## 或文件末尾 + +`; + await fs.promises.writeFile(memoryPath, template, 'utf-8'); + result.action = 'created'; + sendLog('success', '🧠 MEMORY.md 已创建', `路径: ${memoryPath}`); + } else { + // 文件存在 → 校验格式 + const content = await fs.promises.readFile(memoryPath, 'utf-8'); + const valid = validateMemoryContent(content); + result.valid = valid; + + if (!valid) { + // 格式不符合 → 备份为 .bak,然后重建 + const bakPath = memoryPath + '.bak'; + await fs.promises.copyFile(memoryPath, bakPath); + result.backedUp = bakPath; + + const template = `# METONA MEMORY +> ⚠️ 此文件由 memory 工具自动管理,严禁手动编辑 +> 元数据格式: ## [类型] | id: [ID] | importance: [1-10] | tags: [tag1, tag2, ...] +> 条目内容紧跟元数据行,直到下一个 ## 或文件末尾 + +`; + await fs.promises.writeFile(memoryPath, template, 'utf-8'); + result.action = 'recreated'; + sendLog('warn', '🧠 MEMORY.md 格式不符合规范,已备份并重建', + `原文件 → ${bakPath} | 新文件已创建: ${memoryPath}`); + } else { + result.action = 'valid'; + sendLog('info', '🧠 MEMORY.md 格式校验通过', `路径: ${memoryPath} | ${content.split('\n').filter(l => /^## (fact|preference|rule)/i.test(l)).length} 条记忆`); + } + } + + return { success: true, ...result }; + } catch (err) { + sendLog('error', '🧠 memory:init 失败', (err as Error).message); + return { success: false, error: (err as Error).message, ...result }; + } + }); + // ── MCP IPC ── ipcMain.handle('mcp:startServer', async (_, config) => { return startServer(config); @@ -508,6 +581,32 @@ export async function setupIPC(): Promise { }); } +/** 校验 MEMORY.md 内容格式 */ +function validateMemoryContent(content: string): boolean { + if (!content || !content.trim()) return false; + const lines = content.split('\n'); + const firstLine = lines[0]?.trim(); + if (firstLine !== '# METONA MEMORY') return false; + + // 检查是否有 ## 条目头,且格式正确 + let entryCount = 0; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('>')) continue; + const match = trimmed.match(/^##\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); + if (match) { + entryCount++; + const importance = parseInt(match[3], 10); + if (importance < 1 || importance > 10) return false; + const tagsStr = match[4]?.trim(); + if (!tagsStr) return false; + } + } + + // 允许空文件(只有头部没有条目),也允许有条目的文件 + return true; +} + // ── 视频帧提取 (ffmpeg) ── interface ExtractedFrame { diff --git a/src/main/menu.ts b/src/main/menu.ts index efcc46a..5c889b2 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -101,7 +101,7 @@ export function createMenu(): void { dialog.showMessageBox(mainWindow!, { type: 'info', title: '关于 Metona Ollama', - message: 'Metona Ollama Desktop v0.12.11', + message: 'Metona Ollama Desktop v0.13.0', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', icon: getIconPath() }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 0211920..c393d3a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -63,13 +63,6 @@ contextBridge.exposeInMainWorld('metonaDesktop', { clearAllSessions: () => ipcRenderer.invoke('db:clearAllSessions'), saveMessage: (msg: unknown) => ipcRenderer.invoke('db:saveMessage', msg), getMessages: (sessionId: string) => ipcRenderer.invoke('db:getMessages', sessionId), - saveMemory: (entry: unknown) => ipcRenderer.invoke('db:saveMemory', entry), - getMemory: (id: string) => ipcRenderer.invoke('db:getMemory', id), - getAllMemories: () => ipcRenderer.invoke('db:getAllMemories'), - getMemoriesByType: (type: string) => ipcRenderer.invoke('db:getMemoriesByType', type), - deleteMemory: (id: string) => ipcRenderer.invoke('db:deleteMemory', id), - clearAllMemories: () => ipcRenderer.invoke('db:clearAllMemories'), - searchMemories: (query: string, limit?: number) => ipcRenderer.invoke('db:searchMemories', query, limit), saveSetting: (key: string, value: unknown) => ipcRenderer.invoke('db:saveSetting', key, value), getSetting: (key: string, defaultValue?: unknown) => ipcRenderer.invoke('db:getSetting', key, defaultValue), saveToolCall: (tc: unknown) => ipcRenderer.invoke('db:saveToolCall', tc), @@ -129,5 +122,10 @@ contextBridge.exposeInMainWorld('metonaDesktop', { removeProgressListener: () => { ipcRenderer.removeAllListeners('video:progress'); } + }, + memoryAccess: { + read: () => ipcRenderer.invoke('memory:read'), + write: (content: string) => ipcRenderer.invoke('memory:write', content), + init: () => ipcRenderer.invoke('memory:init'), } }); diff --git a/src/main/tool-security.ts b/src/main/tool-security.ts index 0afaf8c..b0d8793 100644 --- a/src/main/tool-security.ts +++ b/src/main/tool-security.ts @@ -71,6 +71,9 @@ export interface CheckResult { /** 路径安全豁免列表:这些路径即使匹配 BLOCKED_DIRS 也放行(如 app 自己的工作空间) */ let blocklistExemptions: string[] = []; +/** 工作空间下受保护的文件名:任何工具都禁止直接读写,只能通过专用通道访问 */ +const BLOCKED_FILES = new Set(['MEMORY.md']); + /** 注册路径为安全豁免(不受 BLOCKED_DIRS 限制)。主要用于注册工作空间目录 */ export function addBlocklistExemptions(dirs: string[]): void { for (const d of dirs) { @@ -91,6 +94,24 @@ function isBlocklistExempt(resolved: string): boolean { return false; } +/** 检查文件是否在工作空间下且属于受保护文件(MEMORY.md 等) */ +export function isBlockedFile(targetPath: string): boolean { + try { + const resolved = path.resolve(targetPath); + const basename = path.basename(resolved); + if (!BLOCKED_FILES.has(basename)) return false; + // 只保护工作空间下的 MEMORY.md(其他目录下的同名文件不保护) + for (const exempt of blocklistExemptions) { + if (resolved === path.join(exempt, basename) || resolved.startsWith(exempt + path.sep)) { + return true; + } + } + return false; + } catch { + return false; + } +} + export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'): CheckResult { const resolved = path.resolve(targetPath); @@ -98,6 +119,12 @@ export function checkPathAllowed(targetPath: string, operation: 'read' | 'write' return { ok: false, reason: '路径遍历深度过大' }; } + // ── 文件级保护:MEMORY.md 等受保护文件禁止所有工具直接访问 ── + if (isBlockedFile(resolved)) { + const basename = path.basename(resolved); + return { ok: false, reason: `禁止直接访问 ${basename}。此文件只能通过 memory 工具操作,其他任何工具都无权读写。` }; + } + for (const blocked of BLOCKED_DIRS) { if (resolved === blocked || resolved.startsWith(blocked + path.sep)) { // 如果路径在安全豁免列表中(如 app 自己的工作空间),放行 diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts index 43bdd2c..2499236 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -254,7 +254,7 @@ function renderToolCallCard(tc: ToolCallRecord): 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: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋', + memory: '🧠', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋', browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰', browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌' }; @@ -265,7 +265,7 @@ function renderToolCallCard(tc: ToolCallRecord): string { edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件', diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取', git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', - memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪', + memory: '记忆管理', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪', browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容', browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器' }; diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index 4600f21..7dd2e67 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -17,7 +17,6 @@ import { ChatDB } from '../db/chat-db.js'; import { OllamaAPI } from '../api/ollama.js'; import { runAgentLoop } from '../services/agent-engine.js'; import { estimateTokens } from '../services/context-manager.js'; -import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-manager.js'; import { showToolConfirm } from './tool-confirm-modal.js'; import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js'; import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js'; diff --git a/src/renderer/components/memory-modal.ts b/src/renderer/components/memory-modal.ts index 55ca3a7..4bf8098 100644 --- a/src/renderer/components/memory-modal.ts +++ b/src/renderer/components/memory-modal.ts @@ -1,24 +1,18 @@ /** - * MemoryModal - Agent 记忆管理大模态框 - * 左右分栏布局:左侧分类筛选+统计,右侧搜索+列表+操作 + * MemoryModal - Agent 记忆管理(简化版) + * 基于工作空间 MEMORY.md 文件 */ -import { - getMemoryCache, searchMemories, addMemory, updateMemory, deleteMemory, - clearAllMemories, isMemoryEnabled, setMemoryEnabled, - getTypeIcon, getTypeName, isVectorMemoryEnabled, getEmbeddingModel -} from '../services/memory-manager.js'; +import { loadAllEntries, addEntry, removeEntry, type MemoryEntry, type MemoryType } from '../services/memory-service.js'; import { showToast } from './toast.js'; import { showPrompt, showConfirm } from './prompt-modal.js'; -import { debounce, escapeHtml, formatTime } from '../utils/utils.js'; -import type { MemoryEntry, MemoryType } from '../types.js'; +import { escapeHtml, formatTime } from '../utils/utils.js'; let modalEl: HTMLElement; // ── 初始化 ── export function initMemoryModal(): void { - // 创建模态框 DOM const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'memoryModal'; @@ -26,12 +20,9 @@ export function initMemoryModal(): void { overlay.innerHTML = `