# 读、写、改、搜

> 五个文件工具的实现：Read 怎样分流文本、图片、PDF 与视频并计入预算，read-file-state 怎样落实“先读后改”，Edit 的八级匹配与唯一性要求，文件系统 adapter 的原子写与编码，以及默认搜索为何改走 Bash 里的 bfs 与 ugrep、这些原生工具怎样分发到各平台。

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.net/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.net/manual/zcode/file-tools
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

模型读写代码靠五个内置工具：`Read`、`Write`、`Edit`、`Glob`、`Grep`，实现在 `apps/zcode-cli/packages/core/src/tool/handlers`。它们不直接调用 `node:fs`，而是经 `FileSystemPort` 交给 `apps/zcode-cli/packages/adapters/src/fs` 里的 `NodeFileSystemAdapter`；图片交给基于 Jimp 的 `ImageProcessorPort`，PDF 交给调用 Poppler 命令行的 `PdfDocumentPort`。改文件之前的新鲜度检查靠一张 read-file-state 表，搜索则依赖随发行物分发的三个原生工具：bfs、ugrep、ripgrep。

有一点先说在前面：默认配置下模型看不到 `Glob` 和 `Grep`。只要 Bash 可用，运行时就把这两个工具从注册表里摘掉，让模型直接在 Bash 里写 `find` 和 `grep`，再由注入的 Shell 函数把它们换成 bfs 和 ugrep。工具怎样注册与过滤见[工具契约、注册表与可见性](https://daiw.net/manual/zcode/tool-contract)，调用流水线见[执行器：调度、审批、超时与结果](https://daiw.net/manual/zcode/tool-executor)，Bash 本身见[Bash：解析、只读判定与后台任务](https://daiw.net/manual/zcode/bash)。

## 怎么用

| 工具 | 参数 | 只读 | 默认审批 | 要点 |
| --- | --- | --- | --- | --- |
| `Read` | `file_path`、`offset`、`limit`，模型支持 PDF 时加 `pages` | 是 | 否 | 文本带行号；图片、视频、PDF 各有分支 |
| `Write` | `file_path`、`content` | 否 | 是 | 整文件覆盖；覆盖已有文件前必须完整读过 |
| `Edit` | `file_path`、`old_string`、`new_string`、`replace_all` | 否 | 是 | `old_string` 须唯一；精确匹配失败再试七种宽松策略 |
| `Glob` | `pattern`、`path` | 是 | 否 | 按修改时间倒序，最多 100 条；默认不可见 |
| `Grep` | `pattern`、`path`、`glob`、`type`、`output_mode`、`-A`、`-B`、`-C`、`-n`、`-i`、`-o`、`head_limit`、`offset`、`multiline` | 是 | 否 | 默认只列文件，最多 250 条；默认不可见 |

参数定义在契约包（如 `apps/zcode-cli/packages/contracts/src/tools/read.ts:65`、`apps/zcode-cli/packages/contracts/src/tools/edit.ts:18`、`apps/zcode-cli/packages/contracts/src/tools/grep.ts:22`），只读、并发安全、超时与结果预算写在各 handler 的 `ToolEntry` 里（如 `apps/zcode-cli/packages/core/src/tool/handlers/read.ts:464`、`apps/zcode-cli/packages/core/src/tool/handlers/edit.ts:254`）。Read、Glob、Grep 并发安全，Write、Edit 不是；五个工具的超时都是 30 秒且不许调用方改，唯一例外是 Read 的 PDF 分页（150 秒，`handlers/read.ts:510`）。

- **审批**：Write 与 Edit 的权限类别是 `edit`，build 模式下逐次询问，edit 模式直接放行（`apps/zcode-cli/packages/core/src/permission/service.ts:518`）。细节见[权限模式与规则](https://daiw.net/manual/zcode/permission)。
- **路径**：描述要求绝对路径，实际上相对路径会按当前工作目录解析，工作区之外的路径也不拦，注释给的理由是子 Agent 可能要查看用户指定的兄弟仓库（`apps/zcode-cli/packages/core/src/tool/path-policy.ts:32`、`:36`）。
- **设置**：桌面端“增强 Find 和 Grep”默认打开（`packages/shared/src/validationAppSettings.ts:461`，文案见 `packages/ui/src/i18n/locales/zh-CN.ts:1713`）。关掉后 Bash 里的 `find`、`grep` 不再被替换，但 `Glob`、`Grep` 也不会因此回来。

## Read：按类型分流

```mermaid
flowchart TD
  A["Read 输入"] --> V{"schema 预检"}
  V -->|"设备文件或二进制扩展名"| E["tool_use_error"]
  V --> I{"扩展名与模型能力"}
  I -->|"jpg png gif webp"| IMG["readImageFile 压到预算内"]
  I -->|"mp4 mov webm mkv m4v avi"| VID["readVideoFile 原样 base64"]
  I -->|"pdf 且模型支持 PDF"| PDF["readPdfFile 整份或 Poppler 分页"]
  I -->|"其余"| C{"read-file-state 同范围且未变"}
  C -->|"是"| U["file_unchanged 短句"]
  C -->|"否"| T["readTextFileRange 加行号"]
  T --> S["写入 read-file-state"]
```

预检在 schema 层完成：12 个会阻塞或无限输出的设备路径（`/dev/zero`、`/dev/stdin` 等）与 19 种二进制扩展名（`.zip`、`.so`、`.wasm` 等）直接拒绝（`apps/zcode-cli/packages/contracts/src/tools/read.ts:29`、`:50`、`:129`），错误文本以 `<tool_use_error>` 包裹返回给模型（`apps/zcode-cli/packages/core/src/tool/handlers/read.ts:266`）。之后按图片、视频、PDF、文本的顺序分流（`handlers/read.ts:167`）。

**文本**的几个数字：

- 不给 `limit` 时文件不能超过 256 KiB（`READ_MAX_FILE_SIZE_BYTES`，`apps/zcode-cli/packages/contracts/src/tools/read.ts:15`），超了报错并提示改用 offset、limit（`apps/zcode-cli/packages/adapters/src/fs/text-range-reader.ts:37`）。给了 `limit` 就不看文件大小：10 MiB 以内整读再切行，更大的流式逐行读（`text-range-reader.ts:19`、`:30`）。
- 输出上限约 25000 token（`apps/zcode-cli/packages/contracts/src/tools/read.ts:16`），按字符数除以 3 估算，中文字符按两个计（`apps/zcode-cli/packages/core/src/context/utils.ts:12`，除数在 `packages/shared/src/usage-stats.ts:9`）。超限时看是不是“首次整文件读”：是，就二分出不超过 85% 预算（21250 token）的最长行前缀作为 partial view 返回，并提示从哪一行续读；不是，直接报错（`apps/zcode-cli/packages/core/src/tool/handlers/read-text.ts:18`、`:161`、`:238`）。
- 行号格式是“行号 + 制表符”（`read-text.ts:85`）；空文件或 offset 越界时返回一段 `<system-reminder>` 提醒（`read-text.ts:62`）。
- 工具描述写着“Reads up to 2000 lines by default”（`apps/zcode-cli/packages/core/src/tool/handlers/read.ts:61`），但 Read 并不按行截断：没有 `limit` 时只传字节上限（`read-text.ts:47`）。2000 行只出现在 partial view 的续读建议里，以及用户 `@` 引用超过 256 KiB 的文本附件时（`apps/zcode-cli/packages/core/src/runtime/helpers/attachments.ts:257`）。
- 同一范围再读、文件的整数毫秒 mtime 与大小都没变时，不再返回正文，只回一句“Wasted call — file unchanged since your last Read.”（`apps/zcode-cli/packages/core/src/tool/handlers/read.ts:55`、`:190`、`:335`）。文件不存在时会在父目录里找同名不同扩展名、或编辑距离不超过 3 的文件名，附一句“Did you mean”（`handlers/read.ts:412`）。
- `.ipynb` 没有专门分支，按 JSON 文本读；契约里的 `notebook` 输出类型没有生产方（`handlers/read.ts:95`）。

解码在 `detectTextEncoding`（`apps/zcode-cli/packages/adapters/src/fs/text-metadata.ts:29`）：先认 UTF-8 与 UTF-16LE 的 BOM；出现 NUL 字节或控制字节超过 30% 判为二进制；合法 UTF-8 就按 UTF-8；否则依次试 GB2312、GBK、GB18030，用 iconv-lite 解码再编码，字节一致才采用（允许末尾丢掉至多 3 个字节，`text-metadata.ts:131`）。CRLF 读入时统一成 LF，原文件的换行风格另行记下（`text-metadata.ts:189`）。

**图片**限 jpg、jpeg、png、gif、webp（`apps/zcode-cli/packages/core/src/tool/handlers/read-image.ts:134`），读入上限 20 MiB，预算四项同时生效：base64 不超过 5 MiB、原始字节不超过其四分之三、长边不超过 2000 像素、每个 base64 字符折 0.125 个 token 且总数不超过 25000（`apps/zcode-cli/packages/contracts/src/tools/read.ts:18`，判定在 `apps/zcode-cli/packages/adapters/src/image/image-budget.ts:43`）。最紧的是 token 一项：25000 ÷ 0.125 = 200000 个 base64 字符，折合原图约 150000 字节，所以多数截图都会被重新编码。压缩阶梯在 `apps/zcode-cli/packages/adapters/src/image/jimp-compression.ts:118`：原图合格就原样；否则先在原尺寸内保持格式（PNG 以 deflate 9 无损优化一次，JPEG 依次试 80、60、40、20 的质量）；再缩到 2000 像素长边；再按 0.75、0.5、0.25 逐级缩小；最后以质量 20 的 JPEG 把长边压到 1000 至 200 像素（`jimp-compression.ts:32`）。WebP 不能转码，超预算直接报错（`apps/zcode-cli/packages/adapters/src/image/webp-passthrough.ts:13`）。交给模型的只有图片块，尺寸只留在结构化输出里，注释说这样 provider 可见内容不会随是否缩放而变（`apps/zcode-cli/packages/core/src/tool/handlers/read.ts:113`）。

**PDF** 只在模型的 `inputFormat.supportsPdf` 为真时才有分支，描述与 schema 也只在这时多出 `pages`（`apps/zcode-cli/packages/core/src/tool/handlers/read-pdf.ts:57`、`:65`）；否则 `.pdf` 落回文本分支。不带 `pages` 时整份 PDF 作为 file 块交给模型，限 20 MiB、10 页，页数用 `pdfinfo` 查（`apps/zcode-cli/packages/contracts/src/tools/read-pdf.ts:5`、`:7`，`apps/zcode-cli/packages/adapters/src/pdf/index.ts:37`）。带 `pages` 时每次最多 20 页、文件不超过 100 MiB，渲染超时 120 秒（同一契约文件 `tools/read-pdf.ts:6`、`:8`、`:11`），由 `pdftoppm -jpeg -r 100` 渲染（`apps/zcode-cli/packages/adapters/src/pdf/index.ts:76`），每页再过一遍图片预算，工具超时因此放宽到 150 秒（`apps/zcode-cli/packages/core/src/tool/handlers/read-pdf.ts:230`、`:51`）。Poppler 不随包分发，没装时报 “pdftoppm is not installed” 并给出安装命令（`apps/zcode-cli/packages/adapters/src/pdf/index.ts:139`）。

**视频**限 mp4、m4v、mov、webm、mkv、avi，不超过 30 MiB（`apps/zcode-cli/packages/core/src/runtime/helpers/attachment-video.ts:9`、`packages/shared/src/zcode-media-policy.ts:2`），不转码，文件头注释说 CLI 不引入 ffmpeg（`apps/zcode-cli/packages/core/src/tool/handlers/read-video.ts:1`）。

媒体结果真正进模型请求前还有两道投影（`apps/zcode-cli/packages/core/src/runtime/helpers/media-budget.ts:51`）：模型不支持的类型换成一段文字占位（`apps/zcode-cli/packages/core/src/runtime/helpers/media-capability.ts:30`）；所有媒体按编码后体积合计不超过 40 MiB，最近一条真实用户消息里的附件受保护，其余从新到旧保留，放不下的换成 “Media omitted” 占位（`media-budget.ts:28`、`:203`、`:257`）。

## 先读后改：read-file-state

每个 `AgentRuntime` 持有一张 `ReadFileStateMap`（`apps/zcode-cli/packages/core/src/runtime/agent-runtime.ts:278`），键是“规范化路径 + offset + limit”（`apps/zcode-cli/packages/core/src/tool/read-file-state.ts:18`），值记着模型看到的内容、读取时间、来源工具，以及 revision、整数毫秒 mtime 与大小（`apps/zcode-cli/packages/core/src/tool/types.ts:203`）。`isPartialView` 只在正文被 token 上限截断时为真，offset、limit 的范围读不算（`apps/zcode-cli/packages/core/src/tool/handlers/read.ts:371`）。Write 写之前的检查（`apps/zcode-cli/packages/core/src/tool/handlers/write.ts:278`）：

```ts
function assertWritableExistingFileIsFresh(
  filePath: string,
  currentRead: FileSystemReadTextResult,
  readFileState: ReadFileStateMap | undefined,
): void {
  const lastRead = findLatestReadFileState(readFileState, filePath);
  if (!lastRead || lastRead.isPartialView) {
    throw createCoreError(CoreErrorType.ToolExecutionFailed, WRITE_NOT_READ_MESSAGE, {
      context: {
        code: "write_file_not_read",
        filePath,
      },
      recoverable: true,
    });
  }

  if (!hasReadStateChanged(lastRead, currentRead)) return;
  if (isStrictFullRead(lastRead) && lastRead.content === currentRead.content) return;

  throw createCoreError(CoreErrorType.ToolExecutionFailed, WRITE_STALE_MESSAGE, {
    context: {
      code: "write_file_stale",
      filePath,
    },
    recoverable: true,
  });
}
```

- **取哪条记录**：同一路径下读取时间最新的一条，不论整读还是范围读。注释解释了原因：若优先整读记录，格式化器改完文件、模型按提示做了范围重读之后，Edit 仍拿旧记录比对，会一直误报 stale（`read-file-state.ts:46`）。
- **没读过**：没有记录，或最新记录是 partial view，报 “File has not been read yet”。被 token 上限截断的整读因此不能直接改，要先用 offset、limit 读到目标段落。
- **读后变了**：Write 先比 revision（形如 `mtime:<整数毫秒>:size:<字节数>`），再比 mtime 是否前进、大小是否变化（`write.ts:306`）；Edit 顺序相反，先 mtime 与大小、后 revision（`handlers/edit.ts:444`）。变了但上次是完整整读、内容逐字未变，照样放行，只被 `touch` 过的文件不会误判。其余情况报 “File has been modified since read, either by the user or by a linter”。
- **写后更新**：成功后用新内容覆盖该路径的整读记录，来源标成 Write 或 Edit（`write.ts:339`、`handlers/edit.ts:572`），回执末尾是 “file state is current in your context — no need to Read it back”（`write.ts:42`）。此时再完整 Read 一遍同一文件，只会得到上面那句 file_unchanged。

写盘时还有第二道闸：读到的 revision 作为 `expectedRevision` 传给 adapter，写前重新 stat 比对，不一致抛 `stale_write`（`apps/zcode-cli/packages/adapters/src/fs/index.ts:473`），挡住“检查之后、写入之前”被改的窗口。

另外三个地方会动这张表：

- **Bash**：命令里带 `--fix`、`--write`、`black`、`cargo fmt` 等格式化标记时，跑完扫一遍已读文件，mtime 晚于命令开始的，在结果后追加 “This command modified … Call Read before editing.”（`apps/zcode-cli/packages/core/src/tool/handlers/bash-read-file-state.ts:20`、`:189`）；反过来，`cat`、`head`、`tail`、`sed -n`、单条 `grep` 读过且输出没被截断的文件会回填成已读（`bash-read-file-state.ts:95`、`:99`）。
- **压缩**：上下文压缩后整张表清空（`apps/zcode-cli/packages/core/src/runtime/methods/compact-active.ts:625`），同时把最近读过的至多 5 个文件以提醒形式放回上下文，单个超过约 5000 token 或合计超过 50000 token 的只留一句“需要时再读”的提示（`apps/zcode-cli/packages/core/src/runtime/helpers/compact-post-reminders.ts:20`），见[上下文压缩](https://daiw.net/manual/zcode/compaction)。
- **恢复会话**：每次成功的 Read、Write、Edit 把一份带 revision、mtime、大小的快照写进 tool part 的 metadata（`apps/zcode-cli/packages/core/src/tool/read-file-state-metadata.ts:22`）。恢复时只重建整文件读，范围读不跨恢复保留，也不会去读当前磁盘“补全”状态，免得把用户手动保存的内容当成 Agent 已读（`apps/zcode-cli/packages/core/src/agent/read-file-state-hydrator.ts:90`、`:125`），见[SQLite 会话库](https://daiw.net/manual/zcode/session-store)。

## Edit：八级匹配

Edit 的检查顺序（`handlers/edit.ts:104`）：`old_string` 与 `new_string` 相同直接失败；文件不存在时，`old_string` 为空视为新建，否则报不存在；文件超过 1 GiB 拒绝；`old_string` 为空但文件有内容拒绝；`.ipynb` 让模型改用 NotebookEdit，但内置工具里并没有这个工具，名字只出现在排序表里（`apps/zcode-cli/packages/core/src/tool/provider-visible-order.ts:17`）；然后是上一节的读状态检查。失败以错误码返回，定义在 `apps/zcode-cli/packages/contracts/src/tools/edit.ts:179`。匹配本身在 `apps/zcode-cli/packages/core/src/tool/edit-matchers.ts:41`：

```ts
export function findEditMatch(input: {
  content: string;
  search: string;
  replaceAll: boolean;
}): EditMatchResult {
  const exact = collectExactCandidates(input.content, input.search);
  if (exact.length > 0) {
    return toMatchResult("exact", exact);
  }

  const strategies: EditMatchStrategy[] = [
    "quote_normalized",
    "line_number_prefix_stripped",
    "escape_normalized",
    "unicode_escape_normalized",
    "line_trimmed",
    "indentation_flexible",
    "block_anchor",
  ];

  for (const strategy of strategies) {
    if (input.replaceAll && BROAD_MATCHERS.has(strategy)) continue;
    const candidates = collectCandidates(strategy, input.content, input.search);
    if (candidates.length === 0) continue;
    return toMatchResult(strategy, candidates);
  }

  return { status: "not_found" };
}
```

| 策略 | 做法 |
| --- | --- |
| `exact` | 原样子串 |
| `quote_normalized` | 弯引号折成直引号后比对，取回文件里的原片段 |
| `line_number_prefix_stripped` | 每一行都带 Read 的行号前缀（数字加制表符，或数字加冒号空格）时去掉再找 |
| `escape_normalized` | 把 `\n`、`\t`、`\"` 这类转义还原成真实字符 |
| `unicode_escape_normalized` | 把 `\uXXXX` 还原成字符 |
| `line_trimmed` | 逐行去掉首尾空白后整块相等 |
| `indentation_flexible` | 至少两行，去掉公共缩进后相等 |
| `block_anchor` | 至少三行，首尾两行去空白后相等，中间各行按编辑距离算的平均相似度不低于 0.8 |

每一级找到候选就停。候选片段不止一种即为歧义（`edit-matchers.ts:132`）；选定片段后还要数它在文件里出现几次，超过一次且没开 `replace_all` 就报 “Found N matches of the string to replace”（`apps/zcode-cli/packages/core/src/tool/handlers/edit.ts:225`）。`replace_all` 会跳过后三种“宽”策略（`edit-matchers.ts:25`、`:62`），免得一口气替换掉一批只是长得像的代码块。替换文本也有配套处理：按转义匹配上的，`new_string` 做同样的反转义（`edit-matchers.ts:75`）；按引号匹配上而原文是弯引号的，`new_string` 里的直引号按上下文换成左右弯引号（`edit-matchers.ts:82`）；`new_string` 为空且被删片段后紧跟换行时，连换行一起删；替换用回调函数，避免 `$&`、`$$` 被当成替换模式（`handlers/edit.ts:608`、`:631`）。比对前 CRLF 统一成 LF，写回时按原文件风格还原（`handlers/edit.ts:164`、`:513`）。

写完用 jsdiff 的 `structuredPatch` 生成带 3 行上下文的 hunk，最多计算 5 秒（`apps/zcode-cli/packages/core/src/tool/diff.ts:4`、`:5`）。hunk 只进结构化输出供界面渲染，模型看到的是一句 “The file … has been updated successfully.”（`apps/zcode-cli/packages/core/src/tool/handlers/edit.ts:67`）。Write 覆盖已有文件时同样附 patch，新建时为空数组（`write.ts:148`）。

## Write 与文件系统 adapter

Write 先尝试读原文件，既为了新鲜度检查，也为了沿用原编码与换行风格；读不到就当新建（`write.ts:98`）。写入固定带 `createParents: true` 与 `atomic: true`（`write.ts:125`）；写进记忆目录的 Markdown 会被补上来源会话 ID（`write.ts:118`），见[项目记忆](https://daiw.net/manual/zcode/memory)。

adapter 这一侧（`apps/zcode-cli/packages/adapters/src/fs/index.ts:291`）：

- **编码**：GB 系编码用 iconv-lite 写回，编码后再解码必须与原文一致，否则拒绝写入，免得把 GBK 文件里存不下的字符悄悄换掉（`text-metadata.ts:62`、`:115`）。
- **原子写**：`atomicWrite` 先 `lstat`，目标是符号链接就拒绝；在同目录创建 `<原名>.tmp.<pid>.<随机串>`，以 `O_EXCL` 与 `O_NOFOLLOW` 打开，复制原文件权限位（保住脚本的执行位），`fsync` 后 `rename` 覆盖；任何一步失败就删掉临时文件，退回以 `O_TRUNC` 与 `O_NOFOLLOW` 原地覆盖（`fs/index.ts:700`、`:717`、`:740`）。
- **边界**：端口只接受绝对路径，Node 错误码被映射成 `not_found`、`permission_denied`、`is_directory` 等稳定类别，取消单独记为 `cancelled`（`fs/index.ts:779`、`:804`）。

## 搜索：默认交给 Bash

默认分支由一个常量打开（`apps/zcode-cli/packages/core/src/embedded-search/capability.ts:4`），唯一的前提是 Bash 在工具面里：没被会话白名单排除，也没被 `--disallowedTools` 禁用（`apps/zcode-cli/packages/core/src/runtime/methods/embedded-search-branch.ts:11`，`apps/zcode-cli/packages/cli/src/arguments.ts:125`）。满足时 `Glob`、`Grep` 在首次注册就被跳过（`apps/zcode-cli/packages/core/src/tool/handlers/index.ts:206`），会话 Shell 选定、刷新工具面时再注销一次（`embedded-search-branch.ts:21`），给模型的清单也再过滤一遍（`embedded-search-branch.ts:48`）。提示词跟着变：Bash 描述里“不要用 Bash 跑这些命令”的名单去掉了 `find` 和 `grep`（`apps/zcode-cli/packages/core/src/tool/handlers/bash-prompt.ts:6`），Explore 子 Agent 的指引改成 “Use `find` via Bash”（`apps/zcode-cli/packages/core/src/subagent/explore.ts:21`）。

```mermaid
flowchart LR
  M["模型"] -->|"Bash 可用（默认）"| B["Bash 工具"]
  B --> P["注入的 find 与 grep 函数"]
  P -->|"native-binaries（默认）"| N["bfs、ugrep、rg 二进制"]
  P -->|"internal-cli"| X["zcode __internal-search"]
  X --> S["系统 find 与 grep"]
  M -->|"Bash 被禁用"| T["Glob 与 Grep 工具"]
  T --> W["JS 遍历"]
  T --> R["Worker 里的 WASM ripgrep"]
```

### find 与 grep 被换成了什么

Bash 起进程前带上一段 `embedded-search` prelude，条件是分支打开、有搜索后端、会话 Shell 是 POSIX 或 Git Bash（`apps/zcode-cli/packages/core/src/tool/handlers/bash.ts:395`、`apps/zcode-cli/packages/core/src/embedded-search/shell.ts:14`）。prelude 由 `apps/zcode-cli/packages/adapters/src/exec/embedded-search-prelude.ts:34` 拼出，核心是三组常量（`embedded-search-prelude.ts:15`）：

```ts
// ugrep 的 -z/-Z 与 GNU grep 的 null-data 语义不同；这些参数必须绕回系统 grep。
const GREP_BYPASS_CASE_PATTERN =
  "-*-filter*|-*-pager*|-*-view*|-*-format-open*|-*-config*|---*|-@*|-*-save-config*|-[Zz]*|-[!-]*[Zz]*|--null|--null-data";

const BFS_DEFAULT_ARGS = ["-S", "dfs", "-regextype", "findutils-default"] as const;

const UGREP_DEFAULT_ARGS = [
  "-G",
  "--ignore-files",
  "--hidden",
  "-I",
  "--exclude-dir=.git",
  "--exclude-dir=.svn",
  "--exclude-dir=.hg",
  "--exclude-dir=.bzr",
  "--exclude-dir=.jj",
  "--exclude-dir=.sl",
] as const;
```

- `find` 变成 `bfs -S dfs -regextype findutils-default`：深度优先遍历，正则方言对齐 GNU findutils。
- `grep` 变成 ugrep：基本正则、读取忽略文件、包含隐藏文件、跳过二进制、排除六种版本库目录。参数里出现 `-z`、`-Z`、`--null`，或 ugrep 特有的 `--filter`、`--pager`、`--view`、`--config` 一类时，整条命令原样交给系统 grep。
- 每个函数先用 `command -v` 确认后端存在，不在就退回同名系统命令（`embedded-search-prelude.ts:179`）。Git Bash 下不覆盖 `find`，因为 Windows 不分发 bfs；`cmd` 与旧式 Shell 不支持函数，不注入（`embedded-search-prelude.ts:42`、`:58`）。PATH 上没有 `rg` 而后端带着一个 rg 路径时，再补一个 `rg` 函数（`embedded-search-prelude.ts:142`）。
- “增强 Find 和 Grep”关掉时只剩这个 `rg` 兜底（`embedded-search-prelude.ts:44`，开关经 `apps/zcode-cli/packages/core/src/tool/executor/call-runner.ts:401` 传入）。

契约里还定义了第三种后端 `argv0-dispatch`：同一个可执行文件以 `ARGV0=bfs`、`ARGV0=ugrep`、`ARGV0=rg` 的身份被调起，再按 argv0 分派成三个工具（`apps/zcode-cli/packages/adapters/src/exec/embedded-search-prelude.ts:108`、`:131`、`:152`）。prelude 能生成这种函数，但仓库里没有任何地方产生这种后端（`apps/zcode-cli/packages/contracts/src/interfaces/execution.port.ts:85`）。

### 后端与 `__internal-search`

后端在 bootstrap 里决定（`apps/zcode-cli/packages/bootstrap/src/app/embedded-search-backend.ts:7`）。默认是 `native-binaries`：三个命令分别取 `ZCODE_BFS_BINARY`、`ZCODE_UGREP_BINARY`、`ZCODE_RG_BINARY`，没设就用裸命令名靠 PATH 查找（`packages/shared/src/runtime-tool-runtime.ts:23`）。设了 `ZCODE_EMBEDDED_SEARCH_COMMAND` 则改用 `internal-cli`：函数调用 `<该命令> __internal-search find …` 或 `… grep …`（`embedded-search-backend.ts:11`）。

`__internal-search` 是 CLI 的隐藏入口，在参数解析之前分流（`apps/zcode-cli/packages/cli/src/run.ts:287`）。它只认 `find` 和 `grep`，其余返回 2；实际做的是 spawn 系统同名命令，grep 前面补 `-G -I` 与六个 `--exclude-dir`，但没有 ugrep 的 `--ignore-files` 与 `--hidden`（`apps/zcode-cli/packages/cli/src/internal-search/embedded-search-cli.ts:12`、`:102`）。这层转发替原生命令处理三件事：下游 `head` 读够后关闭管道时吞掉 EPIPE 并停掉子进程；取消时先 SIGTERM、750 毫秒后 SIGKILL，Windows 用 `taskkill /T /F`；收到 SIGINT、SIGTERM、SIGHUP 时按 130、143、129 返回退出码（`embedded-search-cli.ts:39`、`:139`、`:266`）。这种后端没有 `rg` 兜底（`embedded-search-prelude.ts:147`）。

### Glob 与 Grep 工具

Bash 不可用时才登场的这两个工具走的是另一套实现：

- **Glob**：纯 JavaScript 递归遍历，只跳过六种版本库目录，不读 `.gitignore`，`node_modules` 也会被扫到（`apps/zcode-cli/packages/adapters/src/fs/index.ts:1708`、`:54`）；模式转成正则，支持 `*`、`**`、`?` 与花括号多选，不含斜杠的模式同时匹配文件名（`fs/index.ts:1733`、`:1746`）；按 mtime 倒序截取前 100 条，截断时附一句提示（`fs/index.ts:430`，`apps/zcode-cli/packages/core/src/tool/handlers/glob.ts:20`、`:152`）。
- **Grep**：用 npm 包 `ripgrep`（0.3.1，见 `apps/zcode-cli/packages/adapters/package.json`）提供的 WASI 版 ripgrep，放在 Worker 线程里跑，注释说在主线程跑会占住事件循环，用户停止无从响应（`fs/index.ts:96`、`:1056`）；只把搜索根目录预开放给 WASI（`fs/index.ts:977`）；固定参数含 `--no-config`、`--hidden`、`--max-columns 500`，同样排除版本库目录（`fs/index.ts:933`）；30 秒超时后终止 Worker（`fs/index.ts:53`、`:1095`）；WASM 跑不起来时退回 JavaScript 正则逐文件扫（`fs/index.ts:466`、`:576`）。
- `type` 不是 ripgrep 的 `--type`，而是按一张 20 项的扩展名表换成 `--glob`，表外的值当扩展名用（`fs/index.ts:1018`、`:1850`）。`head_limit` 默认 250、传 0 不限（`fs/index.ts:52`、`:1811`），命中文件按 mtime 倒序（`fs/index.ts:1272`），给模型的内容上限 20000 字节（`apps/zcode-cli/packages/core/src/tool/handlers/grep.ts:23`）。

## 原生工具怎样分发

三件工具的版本钉在 `scripts/native-search-tools-config.mjs:25`：bfs 4.1.1、ugrep 7.8.4、ripgrep 14.1.1。ripgrep 用官方预编译包（配置里标为 `official`，缺包时报 “missing Microsoft ripgrep asset”，`native-search-tools-config.mjs:111`、`:370`）；bfs 与 ugrep 由 ZCode 自己从钉死版本与 SHA-256 的源码包构建（`native-search-tools-config.mjs:43`），Unix 构建脚本把依赖的 Oniguruma、PCRE2、zlib、bzip2、zstd、Brotli 都编成静态库，Linux 以 glibc 2.28 为基线，macOS 部署目标 12.0（`native-search-tools-config.mjs:11`，`scripts/native-search-tools-unix.mjs:144`）。

| 目标 | bfs | ugrep | ripgrep |
| --- | --- | --- | --- |
| macOS arm64、x64 | 4.1.1-1 | 7.8.4-1 | 14.1.1-1 |
| Linux arm64、x64 | 4.1.1-2 | 7.8.4-1 | 14.1.1-1（musl 版） |
| Windows arm64、x64 | 无 | 7.8.4-1 | 14.1.1-1 |
| 远端 macOS | 无 | 无 | 13.0.0-10 |
| 远端 Linux | 4.1.1-2 | 7.8.4-1 | 14.1.1-1 |

18 个归档直接提交在 `apps/zcode-cli/dependencies/native-search`，每个都记在 `SHA256SUMS` 里，许可证与来源清单放在 `third-party/native-search`（`apps/zcode-cli/dependencies/README.md:9`、`:15`）。准备脚本先校验全部归档的哈希再动缓存，然后检查解出的可执行文件架构，不下载、也不走镜像（`scripts/prepare-native-search-tools.mjs:40`，`apps/zcode-cli/dependencies/README.md:40`）。之后分三路：

- **桌面端**：electron-builder 把它们放进 `resources/tools/<工具>`（`packages/desktop/electron-builder.config.js:632`、`:639`）；服务层找到后设置 `ZCODE_*_BINARY`，并把目录追加到 PATH 末尾，用户自己装的 rg 优先（`packages/services/src/runtime-tools/runtimeToolResolver.ts:144`、`:163`）。
- **CLI 单文件包**：构建时作为 SEA 资产嵌入，键为 `zcode-runtime-tools/<工具>/<sha256>/<文件名>`（`apps/zcode-cli/packages/cli/scripts/sea-runtime-tool-assets.mjs:11`、`:49`）；启动时逐个校验大小与哈希，解到 `~/.zcode/cache/runtime_tools/<目标>/<工具>/<版本>-<sha256>/`（根目录可由 `ZCODE_STORAGE_DIR` 改），再写回环境变量（`apps/zcode-cli/packages/cli/src/sea-runtime-tools.ts:52`、`:96`，调用在 `apps/zcode-cli/packages/cli/src/main.ts:52`）。
- **远端工作区**：macOS 远端只部署 rg 13，Linux 远端部署三件工具（`packages/shared/src/runtime-tool-runtime.ts:41`、`:64`），见[远程工作区与手机远控](https://daiw.net/manual/zcode/remote)。

依赖目录的 README 把维护指南链接到 `third-party/README.md`（`apps/zcode-cli/dependencies/README.md:20`），但仓库里没有这个文件。

下一篇：[WebFetch 与 WebSearch](https://daiw.net/manual/zcode/web-tools)——抓网页与搜网页：出站护栏、预批准域名、缓存，以及为什么搜索要借模型提供方的原生能力。
