文件工具:读、写、改
read 的分页、嗅探与嵌套指令文件,edit 的九级匹配级联,write 与 apply_patch 的写入流程,以及基于 ripgrep 的 glob 与 grep。
文件类工具共用一副骨架:相对路径按实例目录解析 → external_directory 检查 → 用 ctx.ask 询问 read、edit、glob 或 grep 权限 → 干活。写类工具最后还要发布文件事件、跑格式化器、收集 LSP 诊断。
read:分页、嗅探与附件
几条硬上限写在文件顶部:
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
const SAMPLE_BYTES = 4096(packages/opencode/src/tool/read.ts:13)
参数只有 filePath、offset(从 1 开始的行号)和 limit。输出包在 <path>、<type>、<content> 标签里,每行加上“行号、冒号、空格”前缀;末尾给出续读指引,例如 (Showing lines 1-2000 of 5231. Use offset=2001 to continue.),撞上 50 KB 时则提示 Output capped at 50 KB。read 自己设置了 metadata.truncated,通用截断不会再截它。
- 嗅探:先读前 4096 字节,按魔数识别 PNG、JPEG、GIF、BMP、PDF、WebP。JPEG、PNG、GIF、WebP 与 PDF 整个读出,作为 base64 附件返回;其余文件若扩展名在黑名单里(zip、exe、class、wasm、pyc 等),或样本中出现 NUL 字节、不可打印字符超过 30%,就报
Cannot read binary file。 - 找不到文件:在同目录做一次宽松的文件名匹配,最多给出 3 个
Did you mean one of these?候选。 - 目录:同样能读,条目排序、子目录带
/,按 offset / limit 分页。 - LSP 预热:读完文本后在后台
touchFile,失败不影响读取。
最容易被忽略的是嵌套指令文件。Instruction.resolve 会从被读文件所在目录向上走到项目根(不含根目录),沿途寻找 AGENTS.md(及 CLAUDE.md、已弃用的 CONTEXT.md),把尚未进入系统提示、此前也没加载过的指令文件追加到输出末尾的 <system-reminder> 里。子目录的约定因此是“读到才加载”,不必一开始就塞进系统提示(见系统提示词与指令文件)。
读权限的模式是相对路径,默认规则对 *.env、*.env.* 询问、对 *.env.example 放行,见权限系统。
edit:九级匹配级联
参数是 filePath、oldString、newString、replaceAll。真正替换之前有三道处理:
oldString为空只用于新建文件,文件已存在时报错,要求改用 write;- 同一路径的编辑经进程内信号量串行(
edit.ts里的locks),防止并行调用互相覆盖; - 文件含 CRLF 时,先把
oldString、newString转成 CRLF 再匹配;UTF-8 BOM 读时剥离、写回时恢复。
核心是 replace()。文件头注释写明这些策略参考了 Cline 的 diff-apply 评测代码和 Gemini CLI 的 editCorrector.ts。九个 Replacer 依次上阵:
| 顺序 | Replacer | 匹配方式 |
|---|---|---|
| 1 | SimpleReplacer | 原样查找 |
| 2 | LineTrimmedReplacer | 逐行比较去掉首尾空白后的内容 |
| 3 | BlockAnchorReplacer | 3 行以上时以首尾行为锚,中间行按 Levenshtein 相似度(阈值 0.65)择优 |
| 4 | WhitespaceNormalizedReplacer | 连续空白压成一个空格后比较 |
| 5 | IndentationFlexibleReplacer | 去掉公共缩进后比较 |
| 6 | EscapeNormalizedReplacer | 把 \n、\t、引号等转义还原后比较 |
| 7 | TrimmedBoundaryReplacer | 去掉整段首尾空白后比较 |
| 8 | ContextAwareReplacer | 首尾行锚定、行数相同、中间非空行至少一半一致 |
| 9 | MultiOccurrenceReplacer | 枚举全部精确出现,配合 replaceAll |
每个 Replacer 产出候选片段,循环这样消费它们(删减后):
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
if (index === -1) continue
notFound = false
if (isDisproportionateMatch(search, oldString)) {
// ...
}
if (replaceAll) {
return content.replaceAll(search, newString)
}
const lastIndex = content.lastIndexOf(search)
if (index !== lastIndex) continue
return content.substring(0, index) + newString + content.substring(index + search.length)
}(packages/opencode/src/tool/edit.ts:705)
候选必须真在原文里;非 replaceAll 模式还要求它唯一,否则换下一个候选、下一级策略。全部失败时区分两种错误:从没找到报 Could not find oldString in the file...,找到过但都不唯一报 Found multiple matches for oldString...。
isDisproportionateMatch 是模糊匹配的保险:匹配片段的行数达到“oldString 行数加 3”与“两倍行数”中的较大者,或者(多行时)字符数比 oldString 多出 500 以上且超过 4 倍,就拒绝替换、要求模型重读文件。宽松匹配省掉了大量“空白不一致”导致的失败重试,这道闸则防止锚点匹配一口吞掉半个文件。
写入之后:格式化器若改动了文件,按原 BOM 状态回写;发布 FileSystem.Event.Edited 与 Watcher.Event.Updated;ctx.metadata 推送 diff 和增删行数;最后只报告本文件的错误级 LSP 诊断,每个文件最多 20 条。权限询问发生在写入前,元数据里带着完整 diff,TUI 的授权框据此渲染差异视图。
write:整文件覆盖
write 同样先算 diff 供授权,BOM 取“旧文件有或新内容有”。它与 edit 的区别在诊断范围:除本文件外,还附上最多 5 个其他文件的 LSP 错误(MAX_PROJECT_DIAGNOSTICS_FILES)。
write.txt 与 edit.txt 都写着“没先用 Read 读过文件就会失败”,但 v1.18.30 的 write.ts、edit.ts 中没有对应检查,这句话目前只起引导作用。
apply_patch:给 GPT 系列的补丁格式
上一篇提到,GPT 系列模型拿到的是 apply_patch 而不是 edit 与 write。补丁是面向文件的精简 diff(节选自 packages/opencode/src/tool/apply_patch.txt:18):
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch解析在 packages/opencode/src/patch/index.ts,类型注释称与“Rust 实现”对齐。解析器相当宽容,外面包了 cat <<'EOF' 一类 heredoc 也能剥掉。执行分两段:
- 校验:逐个 hunk 计算新内容和 diff。Update 先用
@@后的上下文行定位,再用seekSequence寻找旧行,匹配逐级放宽:精确、忽略行尾空白、忽略首尾空白、把弯引号、各种破折号、省略号和不间断空格归一为 ASCII。任何一处失败都报apply_patch verification failed,此时还没有写任何文件。 - 应用:一次
ask同时询问全部文件的edit权限,元数据里带每个文件的 diff;通过后逐个写入、移动或删除,再格式化、发事件、收集诊断,输出以A、M、D开头的文件清单。
glob 与 grep:交给 ripgrep
两者都调用 core 的 Ripgrep 服务(packages/core/src/ripgrep.ts),结果上限都是 100 条:
| glob | grep | |
|---|---|---|
| rg 参数 | --files 加 --glob | --json --hidden --no-messages |
| 隐藏文件 | 不搜 | 搜 |
| 排除 | **/.git/** | 同左 |
| 输出 | 绝对路径列表 | 按文件分组的 Line N: 文本,单行超过 2000 字符截断 |
| 权限模式 | glob 模式 | 正则 |
两份说明都把“开放式、需要多轮搜索”的任务引向 task 工具,让子代理去探索;grep.txt 还叮嘱统计匹配数时用 Shell 直接跑 rg。
写类工具末尾的格式化与诊断由 Format、LSP 两个服务提供,配置与内置清单见 LSP 与格式化器。实验开关 OPENCODE_EXPERIMENTAL_LSP_TOOL 还会放出 lsp 工具,提供跳转定义、查找引用、hover、调用层级等 9 种操作。
下一篇:Shell 工具——bash 与 PowerShell 的选择、超时与输出截断,以及用 tree-sitter 解析命令做权限判定。