/** * Workspace Service — 工作空间管理 * * 负责: * 1. 工作空间目录的创建和验证 * 2. 2 个必需磁盘文件的加载和自动创建(SOUL.md + MEMORY.md) * 3. MEMORY.md 格式校验和自动修正 * * v0.3.14: 移除 AGENTS.md 和 USERS.md(不再读取,不再自动创建) * * @see docs/MetonaAI-Desktop 架构与交互设计.html — 工作空间 * @see standard/开发规范.md — 使用 fs/path 内置模块(非第三方库) */ import { join, resolve, sep } from 'path'; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, statSync, renameSync, unlinkSync, } from 'fs'; import { app } from 'electron'; import log from 'electron-log'; // v0.8.0 P2-4: @ 提及文本片段的编码检测(与 read_file 同源) import { decodeBufferWithDetection } from '../harness/tools/built-in/file-guard'; // ===== 类型定义 ===== export interface WorkspaceFiles { soul: string; memory: string; } export interface WorkspaceInfo { path: string; files: WorkspaceFiles; isValid: boolean; missingFiles: string[]; } // ===== 常量 ===== const REQUIRED_FILES = ['SOUL.md', 'MEMORY.md'] as const; const AUTO_CREATED_DIRS = ['logs', '.metona'] as const; const MEMORY_TEMPLATE = `# MEMORY.md — AI 持久记忆 > 创建时间: __CREATED_AT__ > 最后更新: __UPDATED_AT__ > 工作空间: __WORKSPACE_PATH__ ## 用户偏好 ## 项目上下文 ## 重要决策 ## 待办事项 ## 已知问题 `; // ===== 服务类 ===== export class WorkspaceService { private workspacePath: string; private files: WorkspaceFiles = { soul: '', memory: '' }; constructor(workspacePath?: string) { this.workspacePath = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default'); } /** * 初始化工作空间 * * 1. 创建目录结构 * 2. 验证/创建 2 个必需文件(SOUL.md + MEMORY.md) * 3. 加载文件内容 */ initialize(): WorkspaceInfo { log.info(`Initializing workspace: ${this.workspacePath}`); // 创建工作空间目录 this.ensureDirectory(this.workspacePath); // 创建自动目录(logs/, .metona/) for (const dir of AUTO_CREATED_DIRS) { this.ensureDirectory(join(this.workspacePath, dir)); } // 验证/创建必需文件(SOUL.md + MEMORY.md) const missingOnStart: string[] = []; for (const fileName of REQUIRED_FILES) { const filePath = join(this.workspacePath, fileName); if (!existsSync(filePath)) { missingOnStart.push(fileName); this.createFile(fileName); } } // 加载文件内容(在自动创建之后) this.loadFiles(); // 校验 MEMORY.md 格式 this.validateMemoryFormat(); // isValid: 所有文件均已就绪(包含刚自动创建的) const isValid = true; if (missingOnStart.length > 0) { log.info(`Auto-created missing files: ${missingOnStart.join(', ')}`); } log.info(`Workspace initialized: ${this.workspacePath} (valid: ${isValid})`); return { path: this.workspacePath, files: { ...this.files }, isValid, missingFiles: missingOnStart, }; } /** * 获取工作空间路径 */ getPath(): string { return this.workspacePath; } /** * 获取文件内容 */ getFiles(): WorkspaceFiles { return { ...this.files }; } /** * 重新加载文件(用户可能在外部编辑) */ reload(): WorkspaceFiles { this.loadFiles(); this.validateMemoryFormat(); return { ...this.files }; } // ===== v0.8.0 P2-4: @ 文件提及数据源 ===== /** 列出目录树时的噪声目录(依赖产物/版本库/运行时数据,永不进入提及索引) */ private static readonly MENTION_SKIP_DIRS = new Set([ 'node_modules', '.git', 'logs', '.metona', 'dist', 'dist-electron', 'out', 'build', 'release', ]); /** * 工作空间文件名索引(@ 文件提及联想数据源)。 * * 同步递归收集相对路径;跳过 MENTION_SKIP_DIRS 与隐藏目录;深度上限 8; * 数量上限 maxFiles(防超大工作空间拖慢主进程)。FileGuard 的 MEMORY.md * 保护不适用于"文件名索引"(只列名字不读内容)。 */ listFiles(maxFiles = 2000): string[] { const out: string[] = []; const walk = (dir: string, rel: string, depth: number): void => { if (out.length >= maxFiles || depth > 8) return; let entries: import('fs').Dirent[]; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (out.length >= maxFiles) return; if (entry.name.startsWith('.')) continue; if (entry.isDirectory() && WorkspaceService.MENTION_SKIP_DIRS.has(entry.name)) continue; const relName = rel ? `${rel}/${entry.name}` : entry.name; if (entry.isFile()) { out.push(relName); } else if (entry.isDirectory()) { walk(join(dir, entry.name), relName, depth + 1); } } }; walk(this.workspacePath, '', 0); return out; } /** * 读取 @ 提及文件的文本片段(≤ maxBytes,默认 512KB,二进制拒绝)。 * * 安全:路径 resolve 后必须位于工作空间内;根目录 MEMORY.md 与工具读取 * 同口径保护(isProtectedWorkspaceFile);文本判定复用 file-guard 的 * decodeBufferWithDetection(NUL 启发式在 BOM 之后仍命中则拒绝)。 */ readFileClip( relPath: string, maxBytes = 512 * 1024, ): { success: boolean; error?: string; name?: string; content?: string; truncated?: boolean; size?: number; } { try { const resolved = resolve(this.workspacePath, relPath); if (resolved !== this.workspacePath && !resolved.startsWith(this.workspacePath + sep)) { return { success: false, error: 'Path escapes workspace' }; } if (!existsSync(resolved) || !statSync(resolved).isFile()) { return { success: false, error: 'File not found' }; } // 根目录 MEMORY.md 保护(与 read_file 工具同口径) if ( resolve(resolved).toLowerCase() === resolve(this.workspacePath, 'MEMORY.md').toLowerCase() ) { return { success: false, error: 'Access to workspace root MEMORY.md is protected' }; } const size = statSync(resolved).size; const buf = readFileSync(resolved); const head = buf.subarray(0, Math.min(buf.length, 8192)); // 二进制判定:非 UTF-16 BOM 前缀下含 NUL 字节 → 拒绝(文本附件语义) const isUtf16Bom = (head[0] === 0xff && head[1] === 0xfe) || (head[0] === 0xfe && head[1] === 0xff); if (!isUtf16Bom) { for (const b of head) { if (b === 0) return { success: false, error: 'Binary file is not supported for @ mention' }; } } const clipped = buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf; const { content } = decodeBufferWithDetection(clipped); return { success: true, name: relPath, content, truncated: buf.length > maxBytes, size, }; } catch (err) { return { success: false, error: (err as Error).message }; } } /** * 更新 MEMORY.md 的最后更新时间戳 */ updateMemoryTimestamp(): void { const memoryPath = join(this.workspacePath, 'MEMORY.md'); if (!existsSync(memoryPath)) return; let content = readFileSync(memoryPath, 'utf-8'); const now = new Date().toISOString(); content = content.replace(/> 最后更新: .*/, `> 最后更新: ${now}`); writeFileSync(memoryPath, content, 'utf-8'); this.files.memory = content; log.info('MEMORY.md timestamp updated'); } /** * 追加记忆到 MEMORY.md * * v0.7.2 根治: 分区头匹配此前使用 `\b` 词边界 —— JS 的 `\b` 仅对 * [A-Za-z0-9_] 有效,中文分区名(用户偏好/项目上下文/重要决策/待办事项/ * 已知问题)永远无法命中,导致 appendMemory 恒走"分区不存在"分支, * 在文件末尾创建重复的分区头而不是插入既有分区。现改为 * `## (?=\n|$)` 前瞻断言:分区头行必须以换行或 EOF 结束, * 对 CJK 与拉丁分区名均正确,且防止前缀误匹配(如"用户偏好(旧)")。 */ appendMemory(section: string, entry: string): void { const memoryPath = join(this.workspacePath, 'MEMORY.md'); if (!existsSync(memoryPath)) return; let content = readFileSync(memoryPath, 'utf-8'); // 查找目标 section(转义 section 名中的正则元字符) const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const sectionRegex = new RegExp(`## ${escaped}(?=\\n|$)`); const sectionMatch = content.match(sectionRegex); if (sectionMatch) { // 在 section 末尾追加 const sectionIndex = content.indexOf(sectionMatch[0]) + sectionMatch[0].length; const nextSectionIndex = content.indexOf('\n## ', sectionIndex); const insertPoint = nextSectionIndex === -1 ? content.length : nextSectionIndex; const before = content.slice(0, insertPoint).trimEnd(); const after = content.slice(insertPoint); content = `${before}\n- ${entry}\n${after}`; } else { // section 不存在,追加到文件末尾 content += `\n## ${section}\n- ${entry}\n`; } // 更新时间戳 content = content.replace(/> 最后更新: .*/, `> 最后更新: ${new Date().toISOString()}`); writeFileSync(memoryPath, content, 'utf-8'); this.files.memory = content; log.info(`MEMORY.md: appended to section "${section}"`); } /** * v0.8.1 P1-2: 整体改写 MEMORY.md(记忆整理闭环的唯一写入口)。 * * 供 MemoryMaintainer 应用"删除/合并"动作后重写文件 —— 工具层(file-guard) * 对工作空间根目录 MEMORY.md 的写保护不受影响:本方法只由主进程维护链路调用, * 不经过 Agent 工具执行管道,Agent 仍无法绕过保护直接改写根目录记忆。 * 原子性:tmp + rename,与 write_file 工具同口径;写后同步 files.memory 缓存 * 并更新时间戳。 */ rewriteMemory(content: string): void { const memoryPath = join(this.workspacePath, 'MEMORY.md'); // 时间戳随写随更 const stamped = content.replace(/> 最后更新: .*/, `> 最后更新: ${new Date().toISOString()}`); const tmpPath = `${memoryPath}.tmp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; writeFileSync(tmpPath, stamped, 'utf-8'); try { renameSync(tmpPath, memoryPath); } catch (err) { try { unlinkSync(tmpPath); } catch { /* 忽略清理失败 */ } throw err; } this.files.memory = stamped; log.info('MEMORY.md rewritten (memory maintenance)'); } /** * 校验 MEMORY.md 格式 * * 简化规则: * 1. 元数据头 — 用 > 引用语法,包含创建时间、最后更新、工作空间 * 2. 分区结构 — 包含用户偏好、项目上下文、重要决策(其余可选) * 3. 时间戳自动更新 */ validateMemoryFormat(): boolean { const memoryPath = join(this.workspacePath, 'MEMORY.md'); if (!existsSync(memoryPath)) return false; let content = readFileSync(memoryPath, 'utf-8'); let modified = false; // 规则 1: 元数据头(> 引用语法) const requiredMeta: Array<{ key: string; line: string }> = [ { key: '> 创建时间', line: `> 创建时间: ${new Date().toISOString()}` }, { key: '> 最后更新', line: `> 最后更新: ${new Date().toISOString()}` }, { key: '> 工作空间', line: `> 工作空间: ${this.workspacePath}` }, ]; for (const meta of requiredMeta) { if (!content.includes(meta.key)) { // v0.3.0 修复: 检查 # MEMORY 标题是否存在,不存在则在文件开头插入 const memoryTitleIdx = content.indexOf('# MEMORY'); let titleEnd: number; if (memoryTitleIdx === -1) { // 标题不存在,在开头插入 titleEnd = 0; } else { titleEnd = content.indexOf('\n', memoryTitleIdx) + 1; // 如果没有换行符(文件只有一行),titleEnd 为 0,在末尾插入 if (titleEnd === 0) titleEnd = content.length; } content = content.slice(0, titleEnd) + meta.line + '\n' + content.slice(titleEnd); modified = true; log.info(`MEMORY.md: auto-added missing metadata "${meta.key}"`); } } // 规则 2: 核心分区 const requiredSections = ['## 用户偏好', '## 项目上下文', '## 重要决策']; for (const section of requiredSections) { if (!content.includes(section)) { content += `\n${section}\n`; modified = true; log.info(`MEMORY.md: auto-added missing section "${section}"`); } } // 规则 3: 时间戳更新 const now = new Date().toISOString(); content = content.replace(/> 最后更新: .*/, `> 最后更新: ${now}`); if (modified) { writeFileSync(memoryPath, content, 'utf-8'); this.files.memory = content; log.info('MEMORY.md: format validation completed, auto-corrected'); } return !modified; } // ===== 私有方法 ===== /** * 确保目录存在 */ private ensureDirectory(dirPath: string): void { if (!existsSync(dirPath)) { mkdirSync(dirPath, { recursive: true }); log.debug(`Created directory: ${dirPath}`); } } /** * 创建必需文件 */ private createFile(fileName: string): void { const filePath = join(this.workspacePath, fileName); switch (fileName) { case 'MEMORY.md': { const now = new Date().toISOString(); const content = MEMORY_TEMPLATE.replace('__WORKSPACE_PATH__', this.workspacePath) .replace('__CREATED_AT__', now) .replace('__UPDATED_AT__', now); writeFileSync(filePath, content, 'utf-8'); break; } case 'SOUL.md': writeFileSync( filePath, '# SOUL.md — AI 灵魂定义\n\n# 用户可在此定义 Agent 的身份、性格和核心价值观\n', 'utf-8', ); break; } log.info(`Auto-created file: ${fileName}`); } /** * 加载所有文件内容(SOUL.md + MEMORY.md) */ private loadFiles(): void { this.files.soul = this.readFile('SOUL.md'); this.files.memory = this.readFile('MEMORY.md'); } /** * 读取单个文件 */ private readFile(fileName: string): string { const filePath = join(this.workspacePath, fileName); try { return readFileSync(filePath, 'utf-8'); } catch { log.warn(`Failed to read file: ${filePath}`); return ''; } } }