From 058ee2de3686c2accf4112b4e487c046a2503590 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Tue, 21 Jul 2026 16:08:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E8=87=B3=20v0.3.12?= =?UTF-8?q?=20=E2=80=94=20=E6=96=87=E4=BB=B6=E4=B8=8E=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=B1=BB=E5=B7=A5=E5=85=B7=E5=85=A8=E9=9D=A2=E4=BC=98=E5=8C=96?= =?UTF-8?q?=EF=BC=8816=20=E9=A1=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【阶段一 · 紧急修复 F1】 - F1-1 file_editor regex 计数 bug:强制 g 标志 + 同一 RegExp 实例做 match+replace - F1-2 code_search 统一 safeResolvePath(含路径遍历 + MEMORY.md 拦截) - F1-3 git_commit amend 模式:message 可选 + --no-edit 防止编辑器 hang - F1-4 write_file append 模式原子化:显式 open(O_APPEND)+write+fsync+close 【阶段二 · 增强现有工具 F2】 - F2-1 read_file 智能编码检测:BOM(UTF-8/UTF-16 LE/BE) + GBK 降级 - F2-2 read_file 支持 tail 模式(读取末尾 N 行,适用日志) - F2-3 search_files 二进制过滤 + 智能编码检测 - F2-4 search_files 多 glob 匹配(逗号分隔,如 *.ts,*.js) - F2-5 file_editor 新增 find_replace 操作(字面量替换,规避正则歧义) - F2-6 file_editor regex 支持跨行匹配(multiline 参数) - F2-7 file_editor 新增 backup 参数(编辑前 .bak 备份) 【阶段三 · 新增工具 F3】 - F3-1 file_move:双路径校验 + overwrite + 自动建父目录 + rename 原子 - F3-2 file_info:大小/时间/类型/编码/二进制/权限位 【阶段四 · 优化 F4】 - F4-1 diff_viewer Uint32Array→Uint16Array(省一半内存)+ safeResolvePath + 智能编码 - F4-2 错误处理统一:diff-viewer/file-editor/code-search catch 块改用 extractErrorMessage 【阶段五 · 验证 F5】 - tsc --noEmit 类型检查通过 - 人工审查通过:路径校验/错误处理/原子性/资源释放/权限策略/导出注册 工具数量:13 → 15(新增 file_move / file_info) --- README.md | 2 +- electron/harness/sandbox/permissions.ts | 6 + .../harness/tools/built-in/code-search.ts | 61 ++-- .../harness/tools/built-in/diff-viewer.ts | 40 ++- .../harness/tools/built-in/file-editor.ts | 143 +++++++-- electron/harness/tools/built-in/file-guard.ts | 83 ++++- electron/harness/tools/built-in/filesystem.ts | 300 +++++++++++++++++- electron/harness/tools/built-in/git.ts | 31 +- electron/harness/tools/built-in/index.ts | 7 +- electron/main.ts | 7 + package-lock.json | 4 +- package.json | 2 +- 12 files changed, 586 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 7e4fdb1..5a29153 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 生产级通用 AI Agent 智能体桌面应用 -[![Version](https://img.shields.io/badge/version-0.3.11-blue)](./package.json) +[![Version](https://img.shields.io/badge/version-0.3.12-blue)](./package.json) [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/) [![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/) diff --git a/electron/harness/sandbox/permissions.ts b/electron/harness/sandbox/permissions.ts index 1bdf70c..d28450e 100644 --- a/electron/harness/sandbox/permissions.ts +++ b/electron/harness/sandbox/permissions.ts @@ -86,6 +86,12 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [ // v0.3.2: 文件删除工具(1 个)— 破坏性操作,必须确认 { toolName: 'delete_file', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 }, + + // v0.3.3: 文件移动/重命名工具(1 个)— 可能覆盖目标,需确认 + { toolName: 'file_move', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 }, + + // v0.3.3: 文件信息查询工具(1 个)— 只读 + { toolName: 'file_info', requiredLevel: PermissionLevel.READ }, ]; export class PolicyEngine { diff --git a/electron/harness/tools/built-in/code-search.ts b/electron/harness/tools/built-in/code-search.ts index 9ab944b..abc0454 100644 --- a/electron/harness/tools/built-in/code-search.ts +++ b/electron/harness/tools/built-in/code-search.ts @@ -12,12 +12,13 @@ import { execFile } from 'child_process'; import { promisify } from 'util'; -import { resolve } from 'path'; import log from 'electron-log'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; -import { isPathWithinWorkspace } from './file-guard'; +// F1-2: 统一用 safeResolvePath(含 isPathWithinWorkspace + MEMORY.md 拦截) +// F4-2: 引入 extractErrorMessage 统一错误处理 +import { safeResolvePath, extractErrorMessage } from './file-guard'; const execFileAsync = promisify(execFile); @@ -60,31 +61,37 @@ export class CodeSearchTool implements IMetonaTool { }; async execute(args: Record, context: ToolExecutionContext): Promise { - const pattern = args.pattern as string; - if (typeof pattern !== 'string' || !pattern) { - return { results: [], count: 0, error: 'Pattern is required and must be a string' }; - } - if (pattern.length > 500) { - return { results: [], count: 0, error: 'Pattern too long (max 500 chars)' }; - } + // F4-2: 外层 try-catch 防止 safeResolvePath 抛出异常向上传播 + // (read_file/write_file/list_directory 均有外层 try-catch,code_search 此前缺失) + try { + const pattern = args.pattern as string; + if (typeof pattern !== 'string' || !pattern) { + return { results: [], count: 0, error: 'Pattern is required and must be a string' }; + } + if (pattern.length > 500) { + return { results: [], count: 0, error: 'Pattern too long (max 500 chars)' }; + } - const searchPath = args.path - ? this.resolveSearchPath(args.path as string, context.workspacePath) - : context.workspacePath; - const fileGlob = args.file_glob as string | undefined; - const caseSensitive = (args.case_sensitive as boolean) ?? false; - const contextBefore = Math.min(5, Math.max(0, (args.context_before as number) ?? 0)); - const contextAfter = Math.min(5, Math.max(0, (args.context_after as number) ?? 0)); - const maxResults = Math.min(200, Math.max(1, (args.max_results as number) ?? 50)); + const searchPath = args.path + ? safeResolvePath(args.path as string, context.workspacePath) + : context.workspacePath; + const fileGlob = args.file_glob as string | undefined; + const caseSensitive = (args.case_sensitive as boolean) ?? false; + const contextBefore = Math.min(5, Math.max(0, (args.context_before as number) ?? 0)); + const contextAfter = Math.min(5, Math.max(0, (args.context_after as number) ?? 0)); + const maxResults = Math.min(200, Math.max(1, (args.max_results as number) ?? 50)); - const opts = { fileGlob, caseSensitive, contextBefore, contextAfter, maxResults }; + const opts = { fileGlob, caseSensitive, contextBefore, contextAfter, maxResults }; - // 优先使用 ripgrep,回退到 JS 实现 - const hasRg = await this.checkRipgrep(); - if (hasRg) { - return this.searchWithRipgrep(pattern, searchPath, opts, context); + // 优先使用 ripgrep,回退到 JS 实现 + const hasRg = await this.checkRipgrep(); + if (hasRg) { + return this.searchWithRipgrep(pattern, searchPath, opts, context); + } + return this.searchWithJs(pattern, searchPath, opts, context); + } catch (error) { + return { results: [], count: 0, error: extractErrorMessage(error), success: false }; } - return this.searchWithJs(pattern, searchPath, opts, context); } /** 使用 ripgrep 子进程搜索 */ @@ -217,12 +224,4 @@ export class CodeSearchTool implements IMetonaTool { limit: opts.maxResults, }, context); } - - private resolveSearchPath(filePath: string, workspacePath: string): string { - const resolved = resolve(workspacePath, filePath); - if (!isPathWithinWorkspace(filePath, workspacePath)) { - throw new Error(`Path traversal detected: ${filePath}`); - } - return resolved; - } } diff --git a/electron/harness/tools/built-in/diff-viewer.ts b/electron/harness/tools/built-in/diff-viewer.ts index de82175..3174f44 100644 --- a/electron/harness/tools/built-in/diff-viewer.ts +++ b/electron/harness/tools/built-in/diff-viewer.ts @@ -8,11 +8,14 @@ */ import { readFile } from 'fs/promises'; -import { resolve } from 'path'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; -import { isProtectedWorkspaceFile, isPathWithinWorkspace } from './file-guard'; +import { + safeResolvePath, + extractErrorMessage, + decodeBufferWithDetection, +} from './file-guard'; interface DiffLine { type: 'context' | 'added' | 'removed'; @@ -30,8 +33,9 @@ function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] { const sm = oldSliced.length; const sn = newSliced.length; - // D4.1: 使用 Uint32Array 一维数组替代 number[][],节省内存 - const lcs = new Uint32Array((sm + 1) * (sn + 1)); + // F4-1: 使用 Uint16Array 一维数组替代 Uint32Array,节省一半内存 + // LCS 长度最大值为 min(sm, sn) <= 5000,远小于 Uint16Array 上限 65535,安全 + const lcs = new Uint16Array((sm + 1) * (sn + 1)); const idx = (i: number, j: number): number => i * (sn + 1) + j; for (let i = 1; i <= sm; i++) { @@ -183,21 +187,24 @@ export class DiffViewerTool implements IMetonaTool { return { success: false, error: 'file_a and file_b are required for files mode' }; } - // 安全校验 - const pathA = resolve(context.workspacePath, fileA); - const pathB = resolve(context.workspacePath, fileB); - if (!isPathWithinWorkspace(fileA, context.workspacePath) || !isPathWithinWorkspace(fileB, context.workspacePath)) { - return { success: false, error: 'Path traversal detected' }; - } - if (isProtectedWorkspaceFile(fileA, context.workspacePath) || isProtectedWorkspaceFile(fileB, context.workspacePath)) { - return { success: false, error: 'Access denied: MEMORY.md is protected' }; + // F4-1: 用 safeResolvePath 统一路径校验(含 isPathWithinWorkspace + MEMORY.md 拦截) + let pathA: string; + let pathB: string; + try { + pathA = safeResolvePath(fileA, context.workspacePath); + pathB = safeResolvePath(fileB, context.workspacePath); + } catch (error) { + return { success: false, error: extractErrorMessage(error) }; } try { - contentA = await readFile(pathA, 'utf-8'); - contentB = await readFile(pathB, 'utf-8'); + // F4-1: 用智能编码检测读取文件(支持 GBK/UTF-16 等非 UTF-8 编码) + const bufferA = await readFile(pathA); + const bufferB = await readFile(pathB); + contentA = decodeBufferWithDetection(bufferA).content; + contentB = decodeBufferWithDetection(bufferB).content; } catch (err) { - return { success: false, error: `Failed to read files: ${(err as Error).message}` }; + return { success: false, error: `Failed to read files: ${extractErrorMessage(err)}` }; } labelA = fileA; labelB = fileB; @@ -252,7 +259,8 @@ export class DiffViewerTool implements IMetonaTool { diff_lines: diff.slice(0, 500), }; } catch (err) { - return { success: false, error: (err as Error).message }; + // F4-2: 统一错误处理,避免 non-Error 抛出值被 String 强转丢失信息 + return { success: false, error: extractErrorMessage(err) }; } } } diff --git a/electron/harness/tools/built-in/file-editor.ts b/electron/harness/tools/built-in/file-editor.ts index 2cd7836..a9c58b9 100644 --- a/electron/harness/tools/built-in/file-editor.ts +++ b/electron/harness/tools/built-in/file-editor.ts @@ -33,7 +33,7 @@ export class FileEditorTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'file_editor', description: - 'Edit a file with precision. Supports operations: replace (replace lines), insert (insert at line), delete (delete lines), regex (regex replace in range). More efficient than write_file for targeted edits. Supports dry_run mode for preview. File size limit: 10MB.', + 'Edit a file with precision. Supports operations: replace (replace lines), insert (insert at line), delete (delete lines), regex (regex replace in range), find_replace (literal string replace, avoids regex ambiguity). More efficient than write_file for targeted edits. Supports dry_run mode for preview, multiline regex matching, and automatic .bak backup. File size limit: 10MB.', parameters: { type: 'object', properties: { @@ -41,14 +41,19 @@ export class FileEditorTool implements IMetonaTool { operation: { type: 'string', description: 'Edit operation', - enum: ['replace', 'insert', 'delete', 'regex'], + enum: ['replace', 'insert', 'delete', 'regex', 'find_replace'], }, start_line: { type: 'number', description: 'Start line number (1-indexed). Used by replace/insert/delete/regex' }, - end_line: { type: 'number', description: 'End line number (inclusive). Used by replace/delete' }, + end_line: { type: 'number', description: 'End line number (inclusive). Used by replace/delete/regex' }, content: { type: 'string', description: 'New content for replace/insert operations' }, pattern: { type: 'string', description: 'Regex pattern for regex operation' }, replacement: { type: 'string', description: 'Replacement string for regex operation' }, - flags: { type: 'string', description: 'Regex flags (default "g")' }, + flags: { type: 'string', description: 'Regex flags (default "g"). Use "gm" for multiline, "gms" for multiline+dotall' }, + multiline: { type: 'boolean', description: 'F2-6: Enable cross-line regex matching. When true, regex is applied to the joined content of the range (not line-by-line). Useful for replacing multi-line code blocks. Default false.' }, + find: { type: 'string', description: 'F2-5: Literal string to find for find_replace operation (no regex interpretation)' }, + replace: { type: 'string', description: 'F2-5: Replacement string for find_replace operation' }, + replace_all: { type: 'boolean', description: 'F2-5: Replace all occurrences (true, default) or only the first (false). For find_replace operation.' }, + backup: { type: 'boolean', description: 'F2-7: Save a .bak copy of the original file before editing (default false)' }, dry_run: { type: 'boolean', description: 'Preview mode: return the diff without writing to file (default false)' }, }, required: ['file_path', 'operation'], @@ -66,8 +71,10 @@ export class FileEditorTool implements IMetonaTool { return { success: false, error: 'file_path is required' }; } const filePath = safeResolvePath(args.file_path as string, context.workspacePath); - const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex'; + const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex' | 'find_replace'; const dryRun = (args.dry_run as boolean) ?? false; + // F2-7: backup 参数 — 编辑前保存 .bak 文件 + const backup = (args.backup as boolean) ?? false; // 文件必须存在(不支持创建新文件,请用 write_file) if (!existsSync(filePath)) { @@ -143,6 +150,8 @@ export class FileEditorTool implements IMetonaTool { const pattern = args.pattern as string; const replacement = (args.replacement as string) ?? ''; const flags = (args.flags as string) ?? 'g'; + // F2-6: multiline 参数 — 支持跨行匹配 + const multiline = (args.multiline as boolean) ?? false; if (!pattern) { return { success: false, error: 'Regex operation requires "pattern" parameter' }; } @@ -156,27 +165,42 @@ export class FileEditorTool implements IMetonaTool { return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` }; } + // F1-1 修复: 强制 regex 含 g 标志,保证 match 计数与 replace 结果一致 + const effectiveFlags = flags.includes('g') ? flags : flags + 'g'; let regex: RegExp; try { - regex = new RegExp(pattern, flags); + regex = new RegExp(pattern, effectiveFlags); } catch (err) { - return { success: false, error: `Invalid regex: ${(err as Error).message}` }; + // F4-2: 统一用 extractErrorMessage 提取错误信息 + return { success: false, error: `Invalid regex: ${extractErrorMessage(err)}` }; } - // F1.4: 用带 g 标志的正则统计匹配数 - const targetLines = lines.slice(startLine - 1, endLine); - const countRegex = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g'); - const replacedLines = targetLines.map((line) => { - const matches = line.match(countRegex); - if (matches) replaceCount += matches.length; - return line.replace(regex, replacement); - }); - - newLines = [ - ...lines.slice(0, startLine - 1), - ...replacedLines, - ...lines.slice(endLine), - ]; + if (multiline) { + // F2-6: 跨行匹配 — 对整个目标内容做正则替换(支持多行代码块替换) + const targetContent = lines.slice(startLine - 1, endLine).join('\n'); + const matches = targetContent.match(regex); + if (matches) replaceCount = matches.length; + const replacedContent = targetContent.replace(regex, replacement); + const replacedLines = replacedContent.split('\n'); + newLines = [ + ...lines.slice(0, startLine - 1), + ...replacedLines, + ...lines.slice(endLine), + ]; + } else { + // F1-1 修复: 用同一个 regex 实例做 match 计数 + replace 替换(逐行) + const targetLines = lines.slice(startLine - 1, endLine); + const replacedLines = targetLines.map((line) => { + const matches = line.match(regex); + if (matches) replaceCount += matches.length; + return line.replace(regex, replacement); + }); + newLines = [ + ...lines.slice(0, startLine - 1), + ...replacedLines, + ...lines.slice(endLine), + ]; + } affectedRange = { start: startLine, end: endLine }; // 如果没有替换,返回提示 @@ -195,6 +219,56 @@ export class FileEditorTool implements IMetonaTool { break; } + case 'find_replace': { + // F2-5: 字面量字符串替换 — 不解析正则元字符,避免歧义 + // 适用于精准替换代码片段(如函数名、配置值) + const find = args.find as string; + const replaceStr = (args.replace as string) ?? ''; + const replaceAll = (args.replace_all as boolean) ?? true; + if (find === undefined || find === null) { + return { success: false, error: 'find_replace operation requires "find" parameter' }; + } + if (find.length === 0) { + return { success: false, error: '"find" must not be empty' }; + } + + if (replaceAll) { + // 全局替换:用 split + join 统计匹配次数并替换 + const parts = originalContent.split(find); + replaceCount = parts.length - 1; + const newContent = parts.join(replaceStr); + newLines = newContent.split('\n'); + } else { + // 只替换第一个匹配 + const idx = originalContent.indexOf(find); + if (idx >= 0) { + replaceCount = 1; + const newContent = originalContent.slice(0, idx) + replaceStr + originalContent.slice(idx + find.length); + newLines = newContent.split('\n'); + } else { + replaceCount = 0; + newLines = lines; + } + } + + // find_replace 作用于整个文件,affectedRange 覆盖全部行 + affectedRange = { start: 1, end: lines.length }; + + if (replaceCount === 0) { + return { + success: true, + operation, + file_path: args.file_path, + message: 'No matches found for find pattern', + replacements: 0, + affected_range: affectedRange, + dry_run: dryRun, + }; + } + + break; + } + default: return { success: false, error: `Unknown operation: ${operation}` }; } @@ -214,6 +288,24 @@ export class FileEditorTool implements IMetonaTool { affectedRange.start - 1, Math.min(affectedRange.end, newLines.length), ).join('\n'); + } else if (operation === 'find_replace') { + // F2-5: find_replace 的 dry_run — 只预览第一个匹配附近的内容 + // 避免大文件预览整个文件(affectedRange 覆盖全部行) + const firstMatchIdx = originalContent.indexOf(args.find as string); + if (firstMatchIdx >= 0) { + const beforeMatch = originalContent.slice(0, firstMatchIdx); + const matchLine = beforeMatch.split('\n').length; + const contextRadius = 3; // 前后各 3 行 + const previewStart = Math.max(1, matchLine - contextRadius); + const previewEnd = Math.min(lines.length, matchLine + contextRadius); + originalPreview = lines.slice(previewStart - 1, previewEnd).join('\n'); + const lineDelta = newLines.length - lines.length; + const modifiedEnd = Math.min(newLines.length, previewEnd + lineDelta); + modifiedPreview = newLines.slice(previewStart - 1, Math.max(previewStart - 1, modifiedEnd)).join('\n'); + } else { + originalPreview = ''; + modifiedPreview = ''; + } } else { // replace/delete/regex: 原文件取 [start-1, end) 区间 originalPreview = lines.slice( @@ -254,6 +346,14 @@ export class FileEditorTool implements IMetonaTool { await mkdir(parentDir, { recursive: true }); } + // F2-7: backup 模式 — 编辑前保存 .bak 文件 + // 注意:备份在写入前进行,确保即使写入失败也有原始备份 + let backupPath: string | null = null; + if (backup) { + backupPath = `${filePath}.bak`; + await writeFile(backupPath, originalContent, 'utf-8'); + } + const newContent = newLines.join('\n'); // F1.7: 原子写入 - 临时文件 + rename const tmpPath = `${filePath}.tmp_${Date.now()}`; @@ -277,6 +377,7 @@ export class FileEditorTool implements IMetonaTool { lines_before: lines.length, lines_after: newLines.length, bytes_written: Buffer.byteLength(newContent, 'utf-8'), + backup_path: backupPath, // F2-7: 备份文件路径(null 表示未备份) }; } catch (err) { return { success: false, error: extractErrorMessage(err) }; diff --git a/electron/harness/tools/built-in/file-guard.ts b/electron/harness/tools/built-in/file-guard.ts index 60b92bb..5e4f638 100644 --- a/electron/harness/tools/built-in/file-guard.ts +++ b/electron/harness/tools/built-in/file-guard.ts @@ -149,16 +149,93 @@ export function matchGlob(name: string, glob: string): boolean { return new RegExp(`^${pattern}$`, 'i').test(name); } +/** + * F2-4: 多 glob 匹配(逗号分隔) + * + * 支持 "*.ts,*.js,*.tsx" 形式的多 glob 匹配,任一匹配即通过。 + * 单个 glob 时等价于 matchGlob。空字符串或空白字符串视为匹配所有。 + * + * @param name 待匹配的文件名 + * @param globStr 通配符模式字符串(支持逗号分隔多 glob) + * @returns 是否匹配任一 glob + */ +export function matchAnyGlob(name: string, globStr: string): boolean { + // 按逗号分割,去除空白,过滤空字符串 + const globs = globStr.split(',').map((g) => g.trim()).filter((g) => g.length > 0); + if (globs.length === 0) return true; // 空字符串视为匹配所有 + for (const g of globs) { + if (matchGlob(name, g)) return true; + } + return false; +} + /** * 共享错误提取 * - * 统一从 unknown 错误对象中提取 message 字符串 + * 统一从 unknown 错误对象中提取 message 字符串,可选附加 stderr 信息 + * + * F4-2: 支持 optional stderr 参数,用于 child_process 错误(git/command) * * @param error catch 块中的 unknown 错误 + * @param includeStderr 是否尝试从 error.stderr 提取 stderr 信息(默认 false) * @returns 错误消息字符串 */ -export function extractErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); +export function extractErrorMessage(error: unknown, includeStderr = false): string { + if (error instanceof Error) { + if (includeStderr) { + const stderr = (error as Error & { stderr?: string }).stderr ?? ''; + return stderr ? `${error.message}\n${stderr}` : error.message; + } + return error.message; + } + return String(error); +} + +/** + * F2-1: 智能文件编码检测与解码 + * + * 支持 BOM 检测(UTF-8 / UTF-16 LE / UTF-16 BE)和无 BOM 时的编码推断 + * (UTF-8 strict → GBK → UTF-8 loose 三级降级)。 + * + * 解决 Windows 中文环境 GBK 文件读取乱码问题,以及 UTF-16 文件读取问题。 + * + * @param buffer 文件/命令输出的原始字节 + * @returns 解码后的文本和检测到的编码名(utf-8 / utf-8-bom / utf-16le / utf-16be / gbk / utf-8-loose) + */ +export function decodeBufferWithDetection(buffer: Buffer): { content: string; encoding: string } { + if (buffer.length === 0) { + return { content: '', encoding: 'utf-8' }; + } + + // BOM 检测 + // UTF-8 BOM: EF BB BF + if (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) { + return { content: buffer.slice(3).toString('utf-8'), encoding: 'utf-8-bom' }; + } + // UTF-16 LE BOM: FF FE + if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) { + return { content: buffer.slice(2).toString('utf16le'), encoding: 'utf-16le' }; + } + // UTF-16 BE BOM: FE FF + if (buffer.length >= 2 && buffer[0] === 0xFE && buffer[1] === 0xFF) { + const body = buffer.slice(2); + // 偶数长度保护(UTF-16 每字符 2 字节) + const safe = body.length % 2 === 0 ? body : body.slice(0, body.length - 1); + const swapped = Buffer.from(safe); // 复制避免修改原 buffer + swapped.swap16(); // BE → LE 字节交换 + return { content: swapped.toString('utf16le'), encoding: 'utf-16be' }; + } + + // 无 BOM:UTF-8 strict → GBK → UTF-8 loose 三级降级 + try { + return { content: new TextDecoder('utf-8', { fatal: true }).decode(buffer), encoding: 'utf-8' }; + } catch { + try { + return { content: new TextDecoder('gbk').decode(buffer), encoding: 'gbk' }; + } catch { + return { content: buffer.toString('utf-8'), encoding: 'utf-8-loose' }; + } + } } /** diff --git a/electron/harness/tools/built-in/filesystem.ts b/electron/harness/tools/built-in/filesystem.ts index 1bc1da7..b06830e 100644 --- a/electron/harness/tools/built-in/filesystem.ts +++ b/electron/harness/tools/built-in/filesystem.ts @@ -22,7 +22,7 @@ * @see standard/开发规范.md — 使用 fs/path 内置模块 */ -import { readFile, writeFile, readdir, stat, appendFile, mkdir, open, unlink, rmdir, rm, rename, type FileHandle } from 'fs/promises'; +import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, type FileHandle } from 'fs/promises'; import { join, relative, resolve, dirname } from 'path'; import { existsSync } from 'fs'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; @@ -33,7 +33,9 @@ import { isPathWithinWorkspace, safeResolvePath, matchGlob, + matchAnyGlob, extractErrorMessage, + decodeBufferWithDetection, MAX_FILE_SIZE_BYTES, FILE_TOOL_TIMEOUT_MS, MAX_LINE_LENGTH, @@ -83,13 +85,14 @@ export class ReadFileTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'read_file', description: - 'Read the contents of a text file. Returns lines with offset/limit for large files. Auto-detects and rejects binary files (suggest view_image for images). File size limit: 10MB. Lines longer than 10000 chars are truncated.', + 'Read the contents of a text file. Returns lines with offset/limit for large files. Auto-detects and rejects binary files (suggest view_image for images). File size limit: 10MB. Lines longer than 10000 chars are truncated. Smart encoding detection: supports UTF-8/UTF-8-BOM/UTF-16LE/UTF-16BE (BOM) and GBK/CP936 (fallback for Windows Chinese files). Returns the detected encoding in the response. Supports tail mode to read the last N lines (useful for logs).', parameters: { type: 'object', properties: { file_path: { type: 'string', description: 'Absolute or relative path to the file to read' }, - offset: { type: 'number', description: 'Start line number (1-indexed, default 1)' }, - limit: { type: 'number', description: 'Maximum lines to read (default 500, max 2000)' }, + offset: { type: 'number', description: 'Start line number (1-indexed, default 1). Ignored if tail is specified.' }, + limit: { type: 'number', description: 'Maximum lines to read (default 500, max 2000). Ignored if tail is specified.' }, + tail: { type: 'number', description: 'Read the last N lines from the file. Takes precedence over offset/limit. Useful for reading log tails. Max 2000.' }, }, required: ['file_path'], }, @@ -104,6 +107,10 @@ export class ReadFileTool implements IMetonaTool { const filePath = safeResolvePath(args.file_path as string, context.workspacePath); const offset = Math.max(1, (args.offset as number) ?? 1); const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500)); + // F2-2: tail 模式 — 从文件末尾读取 N 行(优先于 offset/limit) + const tail = args.tail !== undefined + ? Math.min(2000, Math.max(1, Math.floor(args.tail as number))) + : undefined; // v0.3.2: 文件存在性 + 大小预检(先 stat 再决定是否读取,避免大文件 OOM) let stats; @@ -135,9 +142,27 @@ export class ReadFileTool implements IMetonaTool { }; } - const content = await readFile(filePath, 'utf-8'); + // F2-1: 智能编码检测 — 读取原始 Buffer 后用 BOM 检测 + UTF-8/GBK 降级 + // 解决 Windows 中文环境 GBK 文件读取乱码,以及 UTF-16 文件读取问题 + const buffer = await readFile(filePath); + const { content, encoding } = decodeBufferWithDetection(buffer); const lines = content.split('\n'); - const slicedLines = lines.slice(offset - 1, offset - 1 + limit); + + // F2-2: 根据 tail 参数选择切片模式 + let slicedLines: string[]; + let truncated: boolean; + let startLine: number; + if (tail !== undefined) { + // tail 模式:从末尾取 tail 行 + slicedLines = lines.slice(-tail); + truncated = lines.length > tail; + startLine = Math.max(1, lines.length - tail + 1); + } else { + // offset/limit 模式 + slicedLines = lines.slice(offset - 1, offset - 1 + limit); + truncated = lines.length > offset - 1 + limit; + startLine = offset; + } // v0.3.2: 超长行截断 const processedLines = slicedLines.map((line) => truncateLine(line)); @@ -147,9 +172,12 @@ export class ReadFileTool implements IMetonaTool { content: processedLines.map((l) => l.text).join('\n'), total_lines: lines.length, returned_lines: slicedLines.length, - truncated: lines.length > offset - 1 + limit, + truncated, + start_line: startLine, // F2-2: 返回实际起始行号 lines_truncated: truncatedLines, file_size: stats.size, + encoding, // F2-1: 检测到的编码(utf-8 / utf-8-bom / utf-16le / utf-16be / gbk / utf-8-loose) + mode: tail !== undefined ? 'tail' : 'offset', // F2-2: 读取模式 success: true, }; } catch (error) { @@ -207,13 +235,41 @@ export class WriteFileTool implements IMetonaTool { } if (mode === 'append') { - // append 模式:直接 appendFile(append 本身是原子的) - await appendFile(filePath, content, 'utf-8'); + // F1-4: append 模式增强 — 显式 open(O_APPEND) + write + fsync + close + // 修复 v0.3.2 注释错误:"append 本身是原子的"不准确 + // 实际:fs.appendFile 内部是 open(O_APPEND) + write + close,存在: + // - 无 fsync:write 后数据在 page cache,崩溃可能丢失 + // - 大内容(> 4KB)非完全原子:可能被分成多个 write + // 改进:显式 open + 循环 write + fsync + close,保证数据持久化到磁盘 + // 保持 append 语义(O_APPEND 内核级追加),不改变并发行为 + const fileExisted = existsSync(filePath); + const oldSize = fileExisted ? (await stat(filePath)).size : 0; + + let fd: FileHandle | null = null; + try { + fd = await open(filePath, 'a'); + // 'a' 模式下 write 追加到末尾(O_APPEND 内核级保证) + // 循环写入确保大内容完整写入(单次 write 可能不完整) + const buffer = Buffer.from(content, 'utf-8'); + let totalWritten = 0; + while (totalWritten < buffer.length) { + const { bytesWritten } = await fd.write(buffer, totalWritten, buffer.length - totalWritten); + totalWritten += bytesWritten; + } + await fd.sync(); // fsync 保证数据持久化到磁盘 + } finally { + if (fd) { + try { await fd.close(); } catch { /* 忽略关闭错误 */ } + } + } + const newStats = await stat(filePath); return { bytes_written: contentBytes, path: filePath, mode: 'append', + created: !fileExisted, + old_size: oldSize, new_file_size: newStats.size, success: true, }; @@ -256,7 +312,7 @@ export class ListDirectoryTool implements IMetonaTool { properties: { dir_path: { type: 'string', description: 'Directory path (default: workspace root)' }, depth: { type: 'number', description: 'Recursive depth (default 1, max 5)' }, - glob: { type: 'string', description: 'Filename filter pattern (e.g., "*.ts")' }, + glob: { type: 'string', description: 'Filename filter pattern. Supports comma-separated multi-glob (e.g., "*.ts" or "*.ts,*.js,*.tsx")' }, include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' }, }, }, @@ -324,8 +380,8 @@ export class ListDirectoryTool implements IMetonaTool { await this.listDir(rootPath, fullPath, maxDepth, glob, includeHidden, currentDepth + 1, results, maxEntries); } } else { - // glob 过滤仅适用于文件 - if (glob && !matchGlob(entry.name, glob)) continue; + // F2-4: glob 过滤仅适用于文件,支持多 glob(逗号分隔,如 "*.ts,*.js") + if (glob && !matchAnyGlob(entry.name, glob)) continue; const stats = await stat(fullPath); // v0.3.2: 添加 modified time(ISO 字符串) results.push({ @@ -356,7 +412,7 @@ export class SearchFilesTool implements IMetonaTool { pattern: { type: 'string', description: 'Search pattern (regex for content, glob for filenames)' }, target: { type: 'string', description: '"content" (default) to search file contents, "files" to search filenames', enum: ['content', 'files'] }, path: { type: 'string', description: 'Search directory (default: workspace root)' }, - file_glob: { type: 'string', description: 'Limit to specific file types (e.g., "*.py")' }, + file_glob: { type: 'string', description: 'Limit to specific file types. Supports comma-separated multi-glob (e.g., "*.py" or "*.ts,*.js,*.tsx")' }, limit: { type: 'number', description: 'Maximum results (default 50, max 200)' }, context_lines: { type: 'number', description: 'Lines of context to show around content matches (default 0, max 5). Only for target="content".' }, include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' }, @@ -443,7 +499,13 @@ export class SearchFilesTool implements IMetonaTool { const fileStats = await stat(filePath); if (fileStats.size > MAX_FILE_SIZE_BYTES) return; - const content = await readFile(filePath, 'utf-8'); + // F2-3: 跳过二进制文件(避免读取乱码 + 提升性能) + // 空文件不算二进制,直接放行(size=0 时 isBinaryFile 内部 bytesRead=0 返回 false) + if (fileStats.size > 0 && await isBinaryFile(filePath)) return; + + // F2-3: 用智能编码检测读取文件(支持 GBK 等非 UTF-8 编码) + const buffer = await readFile(filePath); + const { content } = decodeBufferWithDetection(buffer); const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { if (results.length >= limit) break; @@ -492,7 +554,8 @@ export class SearchFilesTool implements IMetonaTool { if (entry.isDirectory()) { await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath); } else { - if (fileGlob && !matchGlob(entry.name, fileGlob)) continue; + // F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx") + if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue; await callback(fullPath, entry.name); } } @@ -613,3 +676,210 @@ export class DeleteFileTool implements IMetonaTool { } } } + +// ===== 6. file_move ===== + +/** + * F3-1: 文件移动/重命名工具 + * + * 安全策略: + * 1. 源路径和目标路径都必须在工作空间内(双重 safeResolvePath 校验) + * 2. 受保护文件(MEMORY.md)禁止移动(safeResolvePath 内置拦截) + * 3. 自动创建目标父目录 + * 4. 支持覆盖已存在文件(overwrite 参数) + * 5. 禁止移动工作空间根目录 + * 6. rename 在同文件系统上是原子操作 + */ +export class FileMoveTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'file_move', + description: + 'Move or rename a file or directory. Source and destination must be within workspace. Auto-creates destination parent directory. Use overwrite: true to replace existing destination. Atomic on same filesystem (uses rename).', + parameters: { + type: 'object', + properties: { + source_path: { type: 'string', description: 'Path to the file/directory to move' }, + destination_path: { type: 'string', description: 'Destination path' }, + overwrite: { type: 'boolean', description: 'Overwrite if destination exists (default false)' }, + }, + required: ['source_path', 'destination_path'], + }, + category: MetonaToolCategory.FILESYSTEM, + riskLevel: MetonaRiskLevel.MEDIUM, + requiresPermission: true, + timeoutMs: FILE_TOOL_TIMEOUT_MS, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + try { + const sourcePath = args.source_path as string; + const destinationPath = args.destination_path as string; + const overwrite = (args.overwrite as boolean) ?? false; + + if (!sourcePath || !destinationPath) { + return { error: 'source_path and destination_path are required', success: false }; + } + + // F3-1: 双路径校验(源和目标都必须在工作空间内 + 非 MEMORY.md) + let resolvedSource: string; + let resolvedDest: string; + try { + resolvedSource = safeResolvePath(sourcePath, context.workspacePath); + resolvedDest = safeResolvePath(destinationPath, context.workspacePath); + } catch (error) { + return { error: extractErrorMessage(error), success: false }; + } + + // 禁止移动工作空间根目录 + const workspaceRoot = resolve(context.workspacePath); + if (resolvedSource === workspaceRoot) { + return { + error: 'Cannot move workspace root directory', + source_path: sourcePath, + success: false, + }; + } + + // 源必须存在 + if (!existsSync(resolvedSource)) { + return { error: 'Source not found', path: sourcePath, success: false }; + } + + // 目标已存在处理 + let overwritten = false; + if (existsSync(resolvedDest)) { + if (!overwrite) { + return { + error: 'Destination already exists. Use overwrite: true to replace.', + destination_path: destinationPath, + success: false, + }; + } + // overwrite: true — 删除目标 + const destStats = await stat(resolvedDest); + if (destStats.isDirectory()) { + await rm(resolvedDest, { recursive: true, force: false }); + } else { + await unlink(resolvedDest); + } + overwritten = true; + } + + // 自动创建目标父目录 + const destParentDir = dirname(resolvedDest); + if (!existsSync(destParentDir)) { + await mkdir(destParentDir, { recursive: true }); + } + + // 执行移动(rename 在同文件系统上是原子操作) + await rename(resolvedSource, resolvedDest); + + const stats = await stat(resolvedDest); + return { + source: sourcePath, + destination: destinationPath, + isDirectory: stats.isDirectory(), + overwritten, + success: true, + }; + } catch (error) { + return { error: extractErrorMessage(error), success: false }; + } + } +} + +// ===== 7. file_info ===== + +/** + * F3-2: 文件信息查询工具 + * + * 返回文件的元信息:大小、时间戳、类型、编码检测、权限位。 + * 只读操作,风险等级 SAFE。 + * + * 编码检测只读取前 8KB,避免大文件 OOM。 + */ +export class FileInfoTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'file_info', + description: + 'Get detailed file information: size, timestamps (created/modified/accessed), type (file/directory/symlink), encoding detection (UTF-8/UTF-16/GBK), binary check, and permission bits.', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Path to the file or directory' }, + }, + required: ['file_path'], + }, + category: MetonaToolCategory.FILESYSTEM, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: FILE_TOOL_TIMEOUT_MS, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + try { + const filePath = safeResolvePath(args.file_path as string, context.workspacePath); + + if (!existsSync(filePath)) { + return { error: 'File not found', path: args.file_path, success: false }; + } + + const stats = await stat(filePath); + const isDir = stats.isDirectory(); + const isFile = stats.isFile(); + const isSymlink = stats.isSymbolicLink(); + + const info: Record = { + path: args.file_path, + absolute_path: filePath, + type: isDir ? 'directory' : isFile ? 'file' : isSymlink ? 'symlink' : 'unknown', + size: stats.size, + created: stats.birthtime.toISOString(), + modified: stats.mtime.toISOString(), + accessed: stats.atime.toISOString(), + mode: stats.mode.toString(8), // 八进制权限位 + success: true, + }; + + // F3-2: 文件类型额外信息(编码 + 二进制检测) + // 只读前 8KB,避免大文件 OOM + if (isFile) { + let fd: FileHandle | null = null; + try { + fd = await open(filePath, 'r'); + const buffer = Buffer.alloc(8192); + const { bytesRead } = await fd.read(buffer, 0, 8192, 0); + const actualBuffer = buffer.slice(0, bytesRead); + + // 二进制检测(NULL 字节检查) + let isBinary = false; + for (let i = 0; i < bytesRead; i++) { + if (actualBuffer[i] === 0) { + isBinary = true; + break; + } + } + info.is_binary = isBinary; + + // 编码检测(仅非二进制文件) + if (!isBinary && bytesRead > 0) { + const { encoding } = decodeBufferWithDetection(actualBuffer); + info.encoding = encoding; + } else if (bytesRead === 0) { + info.encoding = 'utf-8'; // 空文件默认 UTF-8 + } + } catch { + // 读取失败,不报告编码信息 + } finally { + if (fd) { + try { await fd.close(); } catch { /* 忽略关闭错误 */ } + } + } + } + + return info; + } catch (error) { + return { error: extractErrorMessage(error), success: false }; + } + } +} diff --git a/electron/harness/tools/built-in/git.ts b/electron/harness/tools/built-in/git.ts index ddc8e19..3daa63e 100644 --- a/electron/harness/tools/built-in/git.ts +++ b/electron/harness/tools/built-in/git.ts @@ -340,11 +340,11 @@ export class GitLogTool implements IMetonaTool { export class GitCommitTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'git_commit', - description: 'Stage files and create a git commit. Supports amending the previous commit. Requires user confirmation.', + description: 'Stage files and create a git commit. Supports amending the previous commit. In amend mode, message is optional (omitting it keeps the original commit message). Requires user confirmation.', parameters: { type: 'object', properties: { - message: { type: 'string', description: 'Commit message' }, + message: { type: 'string', description: 'Commit message. Required for new commits. Optional in amend mode (omitting keeps original message).' }, files: { type: 'array', items: { type: 'string', description: 'File path to stage' }, @@ -352,7 +352,8 @@ export class GitCommitTool implements IMetonaTool { }, amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one (default false)' }, }, - required: ['message'], + // F1-3: message 不再硬性 required — amend 模式下可省略(保留原 message) + // 校验在 execute 内根据 amend 标志动态进行 }, category: MetonaToolCategory.FILESYSTEM, riskLevel: MetonaRiskLevel.MEDIUM, @@ -361,12 +362,17 @@ export class GitCommitTool implements IMetonaTool { }; async execute(args: Record, context: ToolExecutionContext): Promise { - const message = args.message as string; + const message = args.message as string | undefined; const files = (args.files as string[] | undefined) ?? []; const amend = (args.amend as boolean) ?? false; - if (!message || message.trim().length === 0) { - return { success: false, error: 'Commit message is required' }; + // F1-3: 非 amend 模式必须有 message;amend 模式可省略(保留原 message) + if (!amend && (!message || message.trim().length === 0)) { + return { success: false, error: 'Commit message is required (use amend: true to reuse the previous message)' }; + } + // amend 模式下若提供 message,需校验非空字符串 + if (amend && message !== undefined && message.trim().length === 0) { + return { success: false, error: 'Commit message must not be empty (or omit message field to keep original)' }; } try { @@ -393,9 +399,16 @@ export class GitCommitTool implements IMetonaTool { const filesChanged = stagedOutput.split('\n').filter((l) => l.length > 0).length; // 3. 提交(commit 可能触发 hooks,给予更长超时) + // F1-3: amend 模式下若未提供 message,不加 -m 参数(保留原 message) const commitArgs = ['commit']; if (amend) commitArgs.push('--amend'); - commitArgs.push('-m', message); + if (message && message.trim().length > 0) { + commitArgs.push('-m', message); + } else if (amend) { + // amend + 无 message + 无 --no-edit → git 会打开编辑器要求输入 + // 加 --no-edit 明确表示保留原 message(防止 hang 等待编辑器) + commitArgs.push('--no-edit'); + } await runGit(commitArgs, context.workspacePath, 20_000); // 4. 获取提交 hash 和当前分支 @@ -408,8 +421,10 @@ export class GitCommitTool implements IMetonaTool { success: true, commit, branch, - message, + // F1-3: amend 模式下若未提供 message,返回值为空字符串(实际 message 保留原值) + message: message ?? (amend ? '(amended - original message preserved)' : ''), filesChanged, + amended: amend, }; } catch (error) { if (isNotGitRepoError(error)) { diff --git a/electron/harness/tools/built-in/index.ts b/electron/harness/tools/built-in/index.ts index bbeb1a3..d9bde0a 100644 --- a/electron/harness/tools/built-in/index.ts +++ b/electron/harness/tools/built-in/index.ts @@ -1,6 +1,9 @@ /** * 内置工具导出 * + * v0.3.3: 从 27 个工具扩展到 29 个工具 + * 新增:file_move, file_info + * * v0.3.2: 从 26 个工具扩展到 27 个工具 * 新增:delete_file * @@ -14,8 +17,8 @@ * @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 */ -// 文件系统工具(5 个) -export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool, DeleteFileTool } from './filesystem'; +// 文件系统工具(7 个) +export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool, DeleteFileTool, FileMoveTool, FileInfoTool } from './filesystem'; // v0.2.0: 精准文件编辑工具 export { FileEditorTool } from './file-editor'; // v0.2.0: ripgrep 代码搜索工具 diff --git a/electron/main.ts b/electron/main.ts index f687c19..2ccfb2b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -60,6 +60,9 @@ import { ViewImageTool, // v0.3.2 新增工具(1 个) DeleteFileTool, + // v0.3.3 新增工具(2 个) + FileMoveTool, + FileInfoTool, } from './harness/tools/built-in'; import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks'; import { ConfirmationHook } from './harness/hooks/confirmation-hook'; @@ -206,6 +209,10 @@ async function initialize(): Promise { // v0.3.2: 文件删除工具(破坏性操作,HIGH + requireConfirmation) toolRegistry.registerBuiltin(new DeleteFileTool()); + // v0.3.3: 文件移动/重命名工具 + 文件信息查询工具 + toolRegistry.registerBuiltin(new FileMoveTool()); + toolRegistry.registerBuiltin(new FileInfoTool()); + // v0.2.0: 新增文件工具 toolRegistry.registerBuiltin(new FileEditorTool()); toolRegistry.registerBuiltin(new CodeSearchTool()); diff --git a/package-lock.json b/package-lock.json index 28ba837..8b5bcfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.3.11", + "version": "0.3.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.3.11", + "version": "0.3.12", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 14d0162..382a092 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.3.11", + "version": "0.3.12", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team",