# pi-agent-edit

[English](./README.md) | 简体中文

**一个由 agent 自己开发、为 agent 使用而设计的 edit 工具。** 在人类维护者的指导和审查下，AI agent 参与实现、测试与迭代。参与开发的 AI 模型（按维护者提供的名称列出）：**claude-opus-5.5**、**gemini-4-Argon**、**gpt-6.1-sol**。这些名称是开发贡献说明，不表示模型提供商的官方背书。

为 agent 设计的 Pi `edit` 工具替代品。注册同名工具 `edit` 覆盖内置版本，并包装官方 `read` 以捕获真实读取版本（Pi 加载时会提示 shadow），参数完全向后兼容 `{ path, edits: [{ oldText, newText }] }`。

## 解决内置 edit 的哪些问题

| 内置 edit 的不足 | 本扩展的做法 |
|---|---|
| 一旦某条 edit 走了模糊匹配，被触及行上的全角中文标点、弯引号会被静默改成 ASCII（earendil-works/pi#10655） | 归一化**只用于定位**，替换时原文件中匹配区之外的字符一个都不改 |
| 模糊匹配不处理缩进差异、连续空白长度差异（#7836） | 四级匹配：exact → 忽略行尾空白 → 忽略缩进（按 oldText 与文件逐行的缩进对应关系重建 newText 的缩进，统一使用文件自己的 tab/空格风格，不会产生混排）→ 宽松（空白折叠 + NFKC/引号/破折号/全角归一） |
| 模型从报错或 read 结果复制时常误带 `   12│` 行号前缀 | 当常规匹配失败时自动识别并剥离 `oldText`/`newText` 的行号前缀（包括本工具 diff 里的 `+   12│` 行；newText 中混有手写新行时只剥离带前缀的行） |
| 匹配失败只回报"找不到"，模型通常要重新 read 整个文件 | 返回最相似的候选区域（带行号和相似度，惰性缓存 + 提前剪枝加速）、逐行"file vs yours"差异；若 newText 在文件中唯一存在且不是 oldText 的一部分，提示"可能已应用"（同时仍给出候选区，避免误报掩盖真实问题） |
| oldText 出现多次只能报错，模型只能重新 read | 报错里给出最多 5 处出现的带行号上下文，并在最多扩展 6 行、400 字符内尝试给出“唯一 oldText”（从文件原样截取，可直接复制使用）；报错中最多列出 12 个匹配行号，可以立即作为 `nearLine` 重试（同一行多个匹配无法靠行号区分）——即使已读视图已过期，只要文件内容没变就接受；也支持 `replaceAll` 全部替换 |
| 只能按文本替换 | 新增 `startLine/endLine` 区间替换/删除、`afterLine` 插入（0 = 文件开头，精确处理空文件） |
| — | 行号操作有**陈旧保护**：`read` 记录工具实际读到的字节版本，`write` 记录输入内容的版本；每个改动块都没改变行数时保留原有行号视图。发生行数变化后，仍可直接使用最新成功 `edit` 报告中**实际展示的新文件行号**，省略区域和跨越未展示区域的范围不授权。`nearLine` 在多处匹配中选择时也受保护。会话切换、分叉、树跳转和压缩会清空视图 |
| 结果反馈不利于继续编辑 | 返回紧凑的带新行号的 diff（含上下文），模型一般不需要再 read |
| — | `dryRun` 预览；JSON 文件被改坏时给出警告 |
| — | 原子写入（临时文件 + fsync + rename，多硬链接文件自动原地写保护 inode），保留 BOM、CRLF、文件权限、属主/属组（无法恢复时改为原地写）、符号链接；只有目录不可写这类错误才回退原地写，磁盘满等错误直接失败、原文件不动；多条 edit 全有或全无（自动幂等去重完全相同的重复项） |
| — | **并发修改检测**：rename 前（原地写则在同一文件描述符上写入前）再次核对文件内容是否仍是本次编辑读取时的版本，被其他进程改过就拒绝写入，并要求重新读取 |
| — | **原地写可恢复**：不用 O_TRUNC，先覆盖再截断，原始内容保留在内存并备份到系统临时目录；写到一半失败会自动写回原文；连回滚也失败时，报错给出备份文件路径 |
| 容错匹配会把未改动行的空白、引号一起改掉 | 非精确匹配时，`newText` 中与 `oldText` 完全相同的行视为未改动，**原样保留文件中的那一行**（字符串里的空白、Makefile 的 tab、对齐空格、弯引号都不动）；只有真正改动的行才会重缩进 |
| — | Python、YAML、Makefile 等空白敏感文件：容错匹配、tab/空格混用、YAML 中的 tab 缩进、Makefile 中用空格开头的 recipe 行会在结果开头给出 `WARNING`（文本检查，不是语法分析） |
| — | 非 UTF-8 文件（GBK、Latin-1 等）直接拒绝编辑，而不是把全文的非 ASCII 字节替换成乱码；混合换行文件中被改动的 CRLF 行保持 CRLF |
| 部分模型会发 `old_str/new_str/start_line`、字符串形式的 `edits` | `prepareArguments` 兼容所有参数的 camelCase 与 snake_case 别名；行号参数接受 `"12"` 这样的整数字符串，`null` 视为未设置 |

## 参数

```jsonc
{
  "path": "src/app.ts",
  "dryRun": false, // 可选
  "edits": [
    // 每项只能用一种定位方式：
    { "oldText": "foo(1)", "newText": "foo(2)" },                 // 文本替换
    { "oldText": "x = 1", "newText": "x = 2", "nearLine": 120 },  // 多处出现时取最接近 120 行的
    { "oldText": "oldName", "newText": "newName", "replaceAll": true }, // 全部替换
    { "startLine": 10, "endLine": 12, "newText": "..." },         // 替换 10–12 行（newText 为空则删除）
    { "afterLine": 0, "newText": "import x from 'x';" }           // 在第 0 行后（文件开头）插入
  ]
}
```

所有 edit 都基于**调用前的原始文件**定位，不能重叠。`endLine` 只能和 `startLine` 搭配，`replaceAll`/`nearLine` 只能用于 `oldText`，混用会报错而不是被静默忽略。`path` 支持模型常写的 `@src/app.ts` 形式（同名的字面 `@` 文件优先）。

### 新建文件与连续行号编辑

- 通过成功的 `write` 创建文件后，不用额外 `read` 就能按行编辑。通过 shell 创建的文件没有工具视图，需先 `read`；上下文压缩或切换后也一样。如果宿主的 `write + then_run` 已写入文件，但后续命令失败导致整体工具报错，也不会建立视图，需先 `read`，不能仅凭文件存在推断写入已成功完成。
- `read` 的 `offset/limit` 可建立当前版本视图：hash 来自底层实际读取的 Buffer，不是截取后的显示文本，也不事后重读磁盘。这是版本校验，不代表每一行都已显示，仍需确认目标坐标。
- 编辑增删行后，只认最新成功报告**实际打印的新行号**：删除行的旧行号、省略的改动块、截断的中间区域都不能作为授权；`dryRun` 不建立新视图。
- 外部内容变化立即使上述坐标失效，即使文件大小和修改时间都被保留。此时重新 `read`，或改用 `oldText`。

## 安装

已发布到 npm，并收录于 [Pi 包画廊](https://pi.dev/packages/pi-agent-edit)：

```bash
pi install npm:pi-agent-edit
# 或仅在当前项目安装
pi install -l npm:pi-agent-edit
# 或仅试用一次
pi -e npm:pi-agent-edit
```

本地源码也可使用 `pi install ./pi-agent-edit` 安装。然后重新启动 Pi 或执行 `/reload`。

本扩展有意覆盖 `edit` 并包装官方 `read` 工具；加载时的 shadow 提示是预期行为。不要同时加载另一套 `edit` 或 `read` 替代工具。只保留一个安装来源，避免同时从本地扩展目录和 npm 重复加载。卸载使用 `pi remove npm:pi-agent-edit`，然后重启或 `/reload`。

请使用当前版本的 Pi（`@earendil-works/pi-coding-agent`）；本版本在 Pi 1.3.0 上验证。替换内置 edit 的能力以及具体参数格式取决于宿主版本，不保证兼容所有旧版 Pi 或第三方分支。

## 测试

`engine.ts` 和 `writer.ts` 不依赖 Pi，可独立测试：

```bash
npm i -D tsx
npx tsx --test test/engine.test.ts test/regression.engine.test.ts test/hardening.engine.test.ts test/writer.test.ts
```

`test/*extension.test.ts` 需要安装 Pi（peer 依赖），且 Pi 要求 Node >= 22.19.0（它用了 `node:fs` 的 `globSync`），Node 20 上这些用例连模块都加载不了。装好后 `npm test` 跑全部测试，`npm run typecheck` 做类型检查（已内置本地 `typescript` devDependency，不依赖全局 `tsc`）；两者在 CI（`.github/workflows/pi-agent-edit.yml`，Node 22/24）中对 `pi-agent-edit/**` 的改动自动运行。`regression.*.test.ts` 是 0.2.1 修复的回归用例，`hardening.*.test.ts` 和 `writer.test.ts` 是 0.2.2/0.2.3 的用例（`writer.test.ts` 用故障注入覆盖 rename/fsync 失败、原地写半途失败与回滚失败、并发修改）。属主相关的几条需要 root 权限，否则自动跳过。

## 许可证

[MIT](./LICENSE)。本扩展在本地运行，不需要独立的 API key 或模型服务；AI 模型参与开发，而不是在每次编辑时由扩展调用。

## 仍然存在的限制

- **检查与 rename 之间仍有极短的竞态窗口。** POSIX 没有“比较后再 rename”的原子操作；本扩展把检查放在 rename 之前的最后一步，窗口是微秒级，但不为零。它也不是文件锁：不会阻止别的编辑器之后覆盖本次修改。
- **原地写不是原子的，只是可恢复的。** 进程在写入中途被强杀（kill -9、断电）时没有机会回滚，文件可能是新旧内容的混合；备份文件只在本次调用期间存在。正常的 I/O 错误会被回滚。
- **网络盘和特殊挂载无法由本地测试证明。** NFS/SMB 上 rename、fsync 的语义取决于服务端和挂载参数；对目录 fsync 不被支持时会静默跳过。
- **空白检查是文本启发式，不是语法分析。** 关键修改仍应看 diff、跑编译或测试。
