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:
+1
-142
@@ -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<SQL.Database> {
|
||||
);
|
||||
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<SQL.Database> {
|
||||
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);
|
||||
}
|
||||
|
||||
+130
-31
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验 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 {
|
||||
|
||||
+1
-1
@@ -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()
|
||||
});
|
||||
|
||||
+5
-7
@@ -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'),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 自己的工作空间),放行
|
||||
|
||||
Reference in New Issue
Block a user