@epoch-agent/plugin-file
文件工具插件。8 个工具。
- ✅ 做:读 / 写 / 精确编辑(单处、多处、跨文件)/ 搜索 / 列目录
- ❌ 不做:git 操作、跨机同步、任何引擎逻辑
- 依赖:
diff。protocol 和 infra 都是dependencies(本包没有peerDependencies—— 2026-11-03 核)
工具
只读五个:file_read file_multi_read file_search file_list file_diff_preview;
破坏性三个:file_write file_patch file_multi_patch。
每个工具干什么、分页与 output_mode 那些边界在
docs/TOOLS.md —— 那份是工具清单的唯一真源,
这里只写这个包自己的实现取舍。
file_search 用 ripgrep,但不要求用户自己装:rg 从哪来由 infra 的
resolveRipgrep() 三级决定 —— 系统 PATH 上有就用系统的,
没有就用随包带的 vendor 二进制,两个都没有才退回「工具在、一调才报错 + 给安装提示」
的老行为(启动诊断一条 warn,其余七个工具不受影响)。
命中顺序由我们钉死(--sort path):rg 默认并行遍历,多文件命中按到达顺序打印,
同一条查询两次可能给出不同顺序。这不只是「看着别扭」—— 模型会把顺序变化读成结果变化,
而 evals/ 的指纹把工具结果原文算进哈希,于是它会变成偶发误报(2026-11-13 实测
200 遍翻 1 遍,现场是一条 eval 的第 1、2 发漂了一次)。代价是 rg 退化成单线程遍历;
搜索输出本来就有 limit 和截断兜着。
本插件不再导出 ripgrep 的解析与安装提示,它们搬去 infra 了(今天叫
resolveRipgrep() / ripgrepInstallHint(),见 infra/src/ripgrep.ts):
file_list 和将来的 @ 文件补全也要用同一份解析,而 plugin 之间不许互相依赖。
这里只管参数怎么拼和输出怎么收。
只读的四个(file_read / file_multi_read / file_search / file_list)标了
supportsParallel: true,引擎可以并发调。⚠️ file_diff_preview 没有这一格
(2026-11-03 核:supportsParallel 全包只有四处 true,它不在内)。
处理的几类脏输入
- 二进制:扩展名黑名单拦截(
.exe/.zip/.pdf等),不把乱码喂给模型 - 图片(2026-10-15):
file_read读到 png / jpeg / gif / webp 时交一张图(kind: 'image'的 artifact),不再回「二进制,无法读取」。进不进模型上下文不归这个包判 —— 模型认不认图、 字节 / 边长 / 像素上限是 core 的artifact-gate.ts那四道闸门(MCP 截图走的也是那一道), 过不了的模型拿到的是闸门那句「为什么没给你图」。OpenAI Chat 协议的部分接口(openai-compatible / groq 等) 的 tool 消息收不了图,图会被挪进下一条 user 消息;deepseek / xai 自 2026-10-19 升级后的 SDK 版本起原生收图,不再挪 —— 逐家判据在 core 的 registry 的TOOL_RESULT_MEDIA、 挪法在sdk-adapter.ts的hoistToolMedia上。 超过 32 MB 的「图」不读进内存。工具说明没改,eval 指纹不动 - BOM:剥掉 Windows 编辑器留的 UTF-8 BOM,否则第一行的 patch 匹配永远不中
- 行尾:纯 CRLF 文件归一成 LF 去匹配、写回时整份还原;混合行尾的文件原样不动(归一再还原会改掉用户没让动的那些行)
- 路径:
~和~user展开 - 工作区边界:
resolveSafePath防路径穿越——resolve 之后用relative判, 不是startsWith。判定本身是 infra 的isInWorkspace()(全仓唯一一份,权限层和 plugin-terminal 共用),这里只管~展开和抛PathEscapeError。ctx.extraRoots(epoch --add-dir)一并透传:落在额外根里的绝对路径 不算越界,但相对路径仍然只按ctx.workDir解析——否则src/a.ts会在每个 根里各找一遍,同一个相对路径有多个答案,而模型无从知道命中的是哪一个 ctx.grantedPaths:权限层这一次放行的路径(2026-10-08)。它和extraRoots并列当根用,但来源完全不同:那个是你事先--add-dir声明的地盘,这个是 权限层这次判定现算的(档位放行 / 你刚在审批框里点了「允许」)。 ⚠️ 工具不许再自己看ctx.permissionLevel判一次档位 —— 档位表只有权限层 那一张,这里多判一次的症状是「界面说放行了、工具说越界」,而那正是这一格 要修的东西。判据全文在 protocol 的ToolContext.grantedPaths上- 读类工具多认一格:本会话的 artifact 目录(方案 47)。
file_read/file_search/file_list/file_multi_read走的是resolveReadablePath, 它在上面那套之外再认artifactsDir(ctx.sessionId)。理由是大输出落盘之后 取回提示会告诉模型「用file_search在这个路径里搜」,而那个路径在工作区外—— 不放开这一格,那句提示就是一句必定失败的指令。 口子收窄到三条:只放开读(file_write/file_patch仍走resolveSafePath, 模型没有理由改写「命令当时输出了什么」这份证据)、只放开本会话那一个子目录 (不是~/.epoch/artifacts,更不是~/.epoch——后者等于把.env里的 API key 和sessions.db一起交出去)、sessionId由引擎给且artifactsDir()内部再洗一次。 这三条各有一条否定用例钉着,见__tests__/artifact-readable.test.ts
编辑的匹配判据:三个工具一份(2026-10-12)
file_patch、file_multi_patch、file_diff_preview 都走
edit-match.ts:先精确子串,不中再退到「按行、忽略
行尾空白」「按行、忽略行首尾空白」(后者命中时新内容按文件的缩进平移)。每一档都
要求唯一命中,多处而没给 replace_all 就报歧义。
file_multi_patch 的参数是 edits: [{ path, old_string, new_string, replace_all? }],
同一文件的多条按顺序作用。先在内存里全部算完、任何一条不中就一个文件都不写,
回执点名第几条、哪个文件;file_diff_preview 吃同一份参数、跑同一个计算,只是不写盘。
只改已有文件 —— 新建用 file_write。
这一版之前那两条路各有一种静默写坏文件的方式(file_patch 的模糊匹配把命中点
之后整个文件换掉、file_multi_patch 的 V4A 解析剥掉缩进且没命中也报成功),取证和
「为什么不修 V4A 而是换成结构化参数」写在两个文件的文件头。
四个读类工具自己收进 ctx.outputBudget(2026-09-08)
预算从 ctx.outputBudget 现拿 —— 那是引擎将要用的同一个数,所以自己截完不会
再被引擎切一次,工具说的那句「还有多少、怎么接着读」也就是真的。
MAX_OUTPUT_CHARS(50_000)退化成兜底,只在宿主自己拼
ToolContext 时用(那条路上也没有引擎在截)。
改之前是两处各量一遍、而且两个数不一样:这四个按 50_000 截并明说截了多少, 引擎再按自己那个数切一刀 —— 模型看到的既不是全文,也不是我们说的那句话。
file_read 那一格更具体:它的分页单位是行(offset / limit),预算的单位是
字符。两个单位不换算的后果是那条工具描述里的假话「一次读 500 行」——
500 行长行早就越过预算被引擎从中间切掉,而它报的 nextOffset 是按行算的,
模型照它翻页正好跳过被切掉的那一段,两侧都不报错。现在按行收进预算
(fitLines),报的是真正读到的那一行。
⚠️ 有一档翻不动,而它是有意留着的(2026-10-21 修):第一行自己就长过整页预算
(minified 产物 / 单行 JSON / 导出的 CSV)时,这一页交的是截断的那一行并明说
「只给了前 N 字符」。余下部分没有下一页可翻 —— offset 是按行走的,一行之内
不存在下一个偏移量。工具会直接指去 run_code 按字符切,而不是给一个翻回原地
的 offset(那样模型会原地打转,而两侧都不报错)。file_multi_read 也截在同一个
字符预算上,它腾不出第二个偏移量,所以那一档只有 run_code 答得了。
file_diff_preview 的 operation 是故意填错的
它的真实语义是只读(只算 diff 不落盘),annotations 照实写。但 operation 仍填
file_write:老白名单把它归在 FILE_WRITE_TOOLS 里,只改一边会让 recordApproval
那条路径与这里分叉。往严的方向偏,代价只是多一次确认。
开发
npx vitest run --project=plugin-file
path-boundary.test.ts 记的是具体的穿越手法,改 resolveSafePath 之前先读它。