跳到主要内容

数据截至 (上游 commit 0004b748b71c)

落到磁盘:九级降级匹配与影子 git 快照

30 秒导读: 模型说"把这段旧代码换成新代码",可它手里的旧代码是从上下文里回忆出来的,跟磁盘上的字节几乎从不一致。这一章讲 Kilo Code 怎么用九级由严到宽的降级匹配把这段话精确落到真实文件上,怎么用唯一性闸门做到"宁可报错也不改错地方",以及改错了之后怎么靠一个与工作区分离的影子 git 仓库一键退回。

这是整个 Kilo Code 里工程含量最高的一段。前面几章讲的是"模型怎么被调起来"(02-agent-loop.md)、"它的手能伸到哪"(03-tools-and-permission.md);这一章讲的是手落下去的那一瞬间

本章不讲权限闸门(那在 03),也不讲上下文怎么裁剪(那在 05-context-management.md)。


1. 这一章要解决的那个问题

1.1 场景

模型发出一次 edit 工具调用,参数就三个:

  • filePath —— 改哪个文件
  • oldString —— 要被替换掉的那段旧代码
  • newString —— 换成什么

参数定义见 packages/opencode/src/tool/edit.ts:74Parameters)。

听起来这就是一次 String.replace问题出在 oldString 的来源:它不是程序从磁盘现读的字节,而是模型从对话上下文里回忆出来的一段文本。回忆会失真。

1.2 三种翻车方式

翻车方式具体表现对策
改不了oldString 一个字节都对不上,indexOf 返回 -1九级降级匹配(replacer 级联)
改错地方oldString 在文件里出现多次,替换到了错误的那一处唯一性闸门:不唯一就报错,绝不猜
改坏了位置对、替换成功,但新代码本身是错的影子 git 快照 + 回退链路 + LSP 诊断回灌

1.3 三道防线怎么串起来

怎么读这张图:从上往下是一次 edit 调用的完整生命周期,左侧是三道防线各自负责的段落。

模型给出 (filePath, oldString, newString)
|
防线一 ─────────────────────────v──────────────────────────
九级降级匹配 replace() 逐级放宽,命中即停
容忍失真 |
找不到 → 报错,本次编辑作废
|
防线二 ─────────────────────────v──────────────────────────
唯一性闸门 命中的那段在全文中唯一吗?
宁可报错 |
不唯一 → 报错,让模型补上下文
|
------ 写盘 ------
|
防线三 ─────────────────────────v──────────────────────────
影子 git 快照 每轮开始前已提交一次 tree
后悔药 改坏了 → checkout 那个 hash 退回
|
LSP 诊断回灌进工具输出

2. 为什么 oldString 几乎从不逐字节相同

这一节先把"失真"拆开,因为后面九级降级匹配的每一级,都是在对付其中某一类失真。

2.1 失真来源清单

失真来源怎么产生的哪一级兜底
行号前缀read 工具输出的每行都带 12: 前缀,模型容易把它抄进 oldStringprompt 层专门警告
行尾符磁盘是 CRLF、模型上下文里全是 LF写盘前统一转换(非 replacer)
缩进模型把整块代码"顶格"重写,或者空格/制表符混用② 行级 trim、⑤ 整块去缩进
空白折叠模型把多个空格写成一个、换行写成空格④ 空白归一
转义模型输出的是 \n 两个字符,磁盘上是真换行⑥ 反转义
首尾空白oldString 前后多带了空行⑦ 首尾修剪
中段漂移首尾几行还对得上,中间被前一次编辑改了③ 块锚点、⑧ 上下文锚点

2.2 行号前缀这个坑

read 工具的输出格式是"行号 + 冒号 + 空格 + 内容",见 packages/opencode/src/tool/read.ts:355

output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")

所以 edit 的工具说明里,有整整一段在教模型怎么剥掉这个前缀packages/opencode/src/tool/edit.txt:5):前缀格式是"行号 + 冒号 + 空格",那个空格之后才是真正的文件内容,绝不能把前缀的任何部分写进 oldString

顺带一提,read 还会截断:单行超过 2000 字符会被切(MAX_LINE_LENGTHpackages/opencode/src/tool/read.ts:25),整次输出超过 50 KB 也会被切(MAX_BYTES,同文件 :26)。被截断过的行,模型无论如何都拼不回原样。


3. 九级降级匹配

3.1 思路:一把梯子,从最严格往最宽松走

核心想法只有一句:先用最严格的方式找,找不到就放宽一点再找,一旦找到就立刻停。

严格的级别不会误伤(找到就一定是对的),宽松的级别容错强但有猜错风险。所以顺序必须是"由严到宽",而不是反过来。

代码里这把梯子是一个数组,元素类型统一为 Replacerpackages/opencode/src/tool/edit.ts:244,类型定义仍在原位):

export type Replacer = (content: string, find: string) => Generator<string, void, unknown>

注意签名:replacer 不做替换,它只是一个生成器,吐出"文件里可能对应 oldString 的那些真实片段"。真正的替换和裁决在 replace() 里统一做。这个分工是整段代码干净的关键。

3.2 阶梯图(分三档看)

怎么读这张图:从上往下依次尝试,命中即停;九级归成三档,档位越低越宽松、越危险。

┌─ 第一档:精确 ────────────────────────────┐
│ ① 原样匹配 │
└──────────────┬───────────────────────────┘
│ 没找到
┌─ 第二档:容忍空白与转义 ───────────────────┐
│ ② 行级 trim ④ 空白归一 ⑤ 整块去缩进 │
│ ⑥ 反转义 ⑦ 首尾修剪 │
└──────────────┬───────────────────────────┘
│ 没找到
┌─ 第三档:靠首尾锚点猜中间 ─────────────────┐
│ ③ 块锚点(带相似度打分) ⑧ 上下文锚点 │
│ ⑨ 多处出现 │
└──────────────┬───────────────────────────┘
│ 全部落空
v
抛错,本次编辑作废

(图里的编号是级联里的实际顺序,不是分档顺序——③ 块锚点确实排在第 3 位就上场,见下表。)

3.3 九级全表

级联的真实顺序写死在 packages/opencode/src/tool/edit.ts:721-731 的数组字面量里:

顺序符号名白话容忍什么定义位置
SimpleReplacer原样交出去什么都不容忍,纯 indexOftool/edit.ts:268
LineTrimmedReplacer每行两头 trim 后逐行比行首尾空白差异tool/edit.ts:272
BlockAnchorReplacer首行 + 末行当锚点,中间用相似度打分中段内容漂移(≥3 行才启用)tool/edit.ts:312
WhitespaceNormalizedReplacer所有连续空白压成一个空格再比空格数量、换行 vs 空格tool/edit.ts:447
IndentationFlexibleReplacer整块减去公共最小缩进再比整体缩进层级不同tool/edit.ts:491
EscapeNormalizedReplacer\n \t \" 等还原成真字符再比模型把转义序列当字面量输出tool/edit.ts:519
TrimmedBoundaryReplacer整段两头 trim 后再比多带的前后空行tool/edit.ts:582
ContextAwareReplacer首尾行锚点 + 中间行至少 50% 逐行相同中段少量漂移(≥3 行才启用)tool/edit.ts:608
MultiOccurrenceReplacer把所有精确出现位置都吐一遍(见 §10 的诚实备注)tool/edit.ts:568

3.4 原理演示

这段在演示"级联 + 命中即停"的骨架,不是源码:

// 示意,非源码
function apply(content, oldStr, newStr) {
for (const replacer of LADDER) { // 由严到宽
for (const candidate of replacer(content, oldStr)) {
const at = content.indexOf(candidate)
if (at === -1) continue // 这个候选不在文件里,跳过
if (at !== content.lastIndexOf(candidate)) continue // 不唯一,不敢动
return content.slice(0, at) + newStr + content.slice(at + candidate.length)
}
}
throw new Error("找不到,或者找到了但不唯一")
}

重点看两件事:候选一定要回到原文里做一次 indexOf 验证(replacer 可能吐出不存在的字符串),以及唯一性检查发生在替换之前

3.5 真实实现

裁决逻辑在 replace()packages/opencode/src/tool/edit.ts:709)。核心循环只有十行:

for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
if (index === -1) continue
notFound = false
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:732-747

edit.ts 顶部三行注释老实交代了这套 replacer 的出处:cline 的 diff-apply 实验和 gemini-cli 的 editCorrectorpackages/opencode/src/tool/edit.ts:1-4)。这是这一代编码 agent 之间互相抄作业抄得最狠的一块。

3.6 唯一性闸门:宁可报错,也不改错地方

上面那段代码里最重要的是这一行:

if (index !== lastIndex) continue

意思是:这段候选文本在文件里出现了不止一次 → 放弃,去试下一个候选/下一级。 不选第一处、不选最后一处、不按行号猜——一个都不选。

这条闸门的代价是两种不同的错误消息(packages/opencode/src/tool/edit.ts:750-755):

结局内部标志抛出的消息(大意)想让模型做什么
九级全落空notFound === true找不到 oldString,必须逐字匹配,包括空白、缩进、行尾重新 read 一次再试
找到了但都不唯一notFound === false找到多处匹配,请提供更多上下文让匹配唯一oldString 写长一点

模型只有明确要求 replaceAll: true 时,多处匹配才被允许,而且是全改——不存在"改其中一处"这种模糊语义。

3.7 相似度打分:levenshtein 与两个阈值

只有第 ③ 级 BlockAnchorReplacer 用到打分。它的场景是:oldString 的首行和末行还能在文件里对上,但中间几行已经漂了——那么这段"首尾框住的区域"到底算不算命中?

打分办法:只比中间那些行(首尾行是锚点,已经匹配过了),逐行算 Levenshtein 编辑距离(packages/opencode/src/tool/edit.ts:253),转成 1 - 距离 / 较长者长度 的相似度,再平均。

阈值有两个(packages/opencode/src/tool/edit.ts:247-248):

常量用在什么场景含义
SINGLE_CANDIDATE_SIMILARITY_THRESHOLD0.65全文只框出一个候选区域中段相似度 ≥ 0.65 才接受
MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD0.65框出多个候选区域取相似度最高那个,且它必须 ≥ 0.65

这个取舍很有意思,值得单独说清楚:

  • 单候选阈值早期是 0.0(无条件接受),当前 commit 已收紧到 0.65 旧逻辑是"既然首尾锚点在全文里只框出这一处,那它多半就是目标";现在单候选也要过相似度闸,锚点巧合导致替换一大块并不相似内容的风险被收窄。
  • 多候选才真正打分。 有歧义时才值得花 levenshtein 的钱去挑,而且挑出来的赢家还要过同一道 0.65 及格线,否则整级放弃、继续降级。
  • 打分之后仍然要过唯一性闸门。 BlockAnchorReplacer 吐出的是一段原文子串,回到 replace() 里照样要做 index !== lastIndex 检查。所以"打分选中了一段、但这段文本在文件里出现多次"依然会被拒。两层保险是叠加的。

4. 写盘前后的边角工程

匹配算法只是中段。前后还有三件事,做不好一样出事。

4.1 每文件一把信号量

两次 edit 并发改同一个文件,会出现经典的"读—改—写"丢失更新:后写的那次覆盖掉前一次。

对策是按解析后的绝对路径缓存一把 Semaphorepackages/opencode/src/tool/edit.ts:62-72):

const locks = new Map<string, Semaphore.Semaphore>()

function lock(filePath: string) {
const resolvedFilePath = AppFileSystem.resolve(filePath)
const hit = locks.get(resolvedFilePath)
if (hit) return hit
const next = Semaphore.makeUnsafe(1)
locks.set(resolvedFilePath, next)
return next
}

整个"读文件 → 匹配 → 请求权限 → 写盘 → 跑格式化"都套在 lock(filePath).withPermits(1)(...) 里(packages/opencode/src/tool/edit.ts:117)。注意权限询问也在锁内——因为询问期间用户可能等好几秒,这段时间必须继续独占该文件。

有一条针对性的并发测试:两次编辑打到同一文件的不同段落,第一次故意在 ask 里 sleep,最终两处改动都要保留(packages/opencode/test/tool/edit.test.ts:528-571)。

4.2 行尾三件套

模型上下文里的代码几乎一律是 LF,Windows 仓库里的文件常常是 CRLF。如果不处理,oldString 会因为 \r 全军覆没;就算匹配上了,写回去也会把整个文件的行尾搅乱。

三个小函数(packages/opencode/src/tool/edit.ts:49-60):

函数干什么
normalizeLineEndings\r\n 全部压成 \n
detectLineEnding文件里只要出现过一次 \r\n,就判定整个文件是 CRLF
convertToLineEnding\n 还原成目标行尾

用法是"以磁盘为准"(packages/opencode/src/tool/edit.ts:164-168):

const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending)

先把模型给的两段文本洗成 LF,再统一转成文件本来的行尾,然后才拿去匹配。这样匹配和写回用的都是同一套行尾,文件的行尾风格不会被 agent 改掉。有一整组测试覆盖 LF/CRLF 的四种组合(packages/opencode/test/tool/edit.test.ts:339)。

4.3 diff 三件套

编辑完要给三方看结果:权限弹窗要看、会话记录要存、UI 要渲染。三个工具函数:

符号位置作用
buildFileDifftool/edit.ts:28产出 { file, patch, additions, deletions },即 Snapshot.FileDiff
trimDifftool/edit.ts:666把 patch 里所有内容行的公共最小缩进削掉,让深层嵌套代码的 diff 在窄面板里也好读
MAX_DIFF_CONTENTtool/edit.ts:25500 000 字符上限;超了就不生成 patch 文本,additions/deletions 直接记 0

MAX_DIFF_CONTENT 是个纯粹的自保阀门:对一个几 MB 的文件跑 diffLines 会把事件循环卡死。快照那一侧还有一个更小的兄弟常量 MAX_DIFF_SIZE = 256 KBpackages/opencode/src/snapshot/index.ts:56),用于在写入会话记录前丢弃过大的 patch 文本(packages/opencode/src/session/message-v2.ts:189)。

buildFileDiff 的结果会被塞进权限请求的 metadata.filediff 里(packages/opencode/src/tool/edit.ts:139),所以用户在批准之前就能看到完整改动。权限机制本身见 03-tools-and-permission.md


5. 另外两条写盘路径

edit 不是唯一能碰磁盘的工具。加上 writeapply_patch,一共三条。

5.1 三条路径的取舍

工具模型要给什么适合什么匹配容错入口
edit一段旧文本 + 一段新文本改动小、位置明确九级降级tool/edit.ts:83
write整个文件的完整内容新建文件、整体重写不需要匹配tool/write.ts:30
apply_patch一份自定义格式的补丁,可含多文件一次改多个文件、含增/删/改名四趟匹配(见下)tool/apply_patch.ts:26

三者共用同一套下游:权限询问、编码/BOM 保持、格式化、File.Event.Edited 广播、LSP 诊断收集。write 甚至直接从 editimport { trimDiff, buildFileDiff }packages/opencode/src/tool/write.ts:14)。

一个容易忽略的细节:oldString === "" 时,edit 会走创建新文件分支(packages/opencode/src/tool/edit.ts:119-152),跟 write 的行为重合。

5.2 apply_patch:补丁格式与它自己的降级匹配

补丁格式是一个信封套若干文件段(说明见 packages/opencode/src/tool/apply_patch.txt):

*** 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,分工清楚:

函数位置干什么
parsePatchpatch/index.ts:185*** Begin Patch / *** End Patch 信封,切成 hunk 列表
parsePatchHeaderpatch/index.ts:70Add File: / Delete File: / Update File: / Move to:
parseUpdateFileChunkspatch/index.ts:103@@ 段落拆成 old_lines / new_lines / change_context
stripHeredocpatch/index.ts:176模型有时会把补丁包在 cat <<'EOF' 里,先剥掉
deriveNewContentsFromChunkspatch/index.ts:307把 chunk 落到真实文件内容上

这里有第二套降级匹配。 seekSequencepackages/opencode/src/patch/index.ts:460)用四趟递进的比较器去找一段行序列:

比较方式容忍
1a === b——
2a.trimEnd() === b.trimEnd()行尾空白
3a.trim() === b.trim()行首尾空白
4normalizeUnicode(a.trim()) === normalizeUnicode(b.trim())中英文标点差异

第 4 趟的 normalizeUnicodepackages/opencode/src/patch/index.ts:418)专治一类很具体的 LLM 毛病:把直引号写成弯引号 、把连字符写成 、把三个点写成 、把空格写成不换行空格。

另外 tryMatchpackages/opencode/src/patch/index.ts:429)支持 EOF 锚点:hunk 标了 *** End of File 时,先从文件末尾倒着对齐试一次,再退回正向扫描。

对比一下两套匹配的哲学:

  • edit 的九级是面向一整段文本的,最后靠"全文唯一"来兜底安全。
  • apply_patch 的四趟是面向行序列的,靠 lineIndex 单调前移来保证多个 hunk 按顺序落位(packages/opencode/src/patch/index.ts:386),找不到就直接抛错、整份补丁作废。

apply_patch全有或全无:任何一个 hunk 匹配失败,run 在写盘之前就 fail 掉(packages/opencode/src/tool/apply_patch.ts:151),已经算好的 fileChanges 一个都不落盘。

5.3 "读过才能改":一条只写在 prompt 里的约束

edit 的工具说明第一条就是(packages/opencode/src/tool/edit.txt:4):

你必须在对话中至少用一次 Read 工具之后才能编辑。不这么做,本工具会报错。

但代码里没有这个检查。 上游曾有一个 FileTime 模块记录"这个文件在本会话里被读过的时间戳",编辑前比对;这个模块已被上游删除,测试文件里留着一行明确的注释:

// FileTime was removed upstream; edit/write no longer require a prior read.

packages/opencode/test/kilocode/tool-encoding.test.ts:89

也就是说,这条约束从硬闸门退化成了 prompt 里的软约束。现在真正拦着"没读就瞎改"的,是本章前面那三道防线本身:没读过文件,oldString 大概率过不了九级匹配。

read 这边留下的痕迹是 warm()packages/opencode/src/tool/read.ts:103)——读文件时顺手把它 touchFile 进 LSP,为后面编辑完拿诊断预热。


6. 后悔药:影子 git 仓库

6.1 直觉

给工作区配一个平行宇宙的 git:它跟踪同一批文件,但仓库数据存在别处,跟你自己的 .git 完全不发生关系。

  • 你的 git status 里看不到它,你的提交历史不会被污染。
  • agent 每轮开始前,它偷偷做一次提交。
  • 改坏了,就 checkout 回那个提交。

实现在 packages/opencode/src/snapshot/index.ts

6.2 仓库长什么样

关键就是 git 的 --git-dir / --work-tree 可以分开指:

真实工作区 影子仓库
<worktree>/ ~/.local/share/.../snapshot/
├── .git/ ← 你的 └── <projectID>/
├── src/ ↑ └── <hash(worktree)>/
└── ... │ ├── objects/
│ ├── info/exclude
同一批文件 ──┘ └── index

路径构造(packages/opencode/src/snapshot/index.ts:107):

gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree))

每条 git 命令都套上同一个前缀(packages/opencode/src/snapshot/index.ts:111):

const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]

外加三组固定配置(packages/opencode/src/snapshot/index.ts:47-49):core.longpaths(Windows 长路径)、core.symlinkscore.autocrlf=false绝不让 git 动行尾,否则快照会跟工作区打架)、core.quotepath=false(非 ASCII 文件名不被转义)。

6.3 六个动作

Snapshot.Interfacepackages/opencode/src/snapshot/index.ts:60)对外只有六个动作:

动作底层 git什么时候被调位置
trackadd + write-tree每轮/每步开始前,返回 tree hashsnapshot/index.ts:354
patchdiff --cached --name-only一步结束时,问"这一步改了哪些文件"snapshot/index.ts:423
restoreread-tree + checkout-index -a -f把整个工作区拉回某个 hashsnapshot/index.ts:461
revertcheckout <hash> -- <files>只回退指定文件snapshot/index.ts:487
diffdiff --cached出一段 unified diff 文本snapshot/index.ts:602
diffFulldiff --numstat + --name-status出结构化的 FileDiff[] 给 UIsnapshot/index.ts:622

注意 track 用的是 write-tree不是 commitpackages/opencode/src/snapshot/index.ts:406):它只把当前索引写成一个 tree 对象拿到 hash,不建 commit、不动 HEAD、不需要 author/committer。省事,也彻底避开了"影子仓库有没有分支"这个问题。

谁在调 track?主循环。session/processor.ts 里三处:进入处理器时先照一张(:133)、每个 step 开始时补照(:644)、step 结束时再照一张(:677)。step 结束后立刻用 patch() 算出这一步动过哪些文件,写成一个 type: "patch" 的消息 part(packages/opencode/src/session/processor.ts:688-699)。这个 part 就是后面回退的凭据。

6.4 gitignore 怎么处理

影子仓库是新建的,它不认识你项目的 .gitignore。两步解决:

  1. 把源仓库的 info/exclude 抄过来。 excludes()git rev-parse --git-path info/exclude 问出源仓库那个文件的绝对路径(packages/opencode/src/snapshot/index.ts:227),sync() 把它的内容写进影子仓库的 info/exclude,后面还能追加额外条目(:235)。
  2. 每次暂存前,拿源仓库的规则现问一遍。 ignore()源仓库的 .gitcheck-ignore --no-index --stdin -zpackages/opencode/src/snapshot/index.ts:137)。--no-index 是关键:即使某个文件已经被影子仓库跟踪了,也照样按 pattern 判断——这样 .gitignore 新增一条规则时,已入库的文件也会被 drop() 掉(:169git rm --cached)。

另外还有个体积闸门:单文件超过 2 MB(limitpackages/opencode/src/snapshot/index.ts:46)且是未跟踪文件的,会被写进 info/exclude 而不是塞进快照(:292-310)。防的是"agent 生成了一个 500 MB 的日志,快照仓库跟着爆炸"。

6.5 回收

影子仓库会无限长大,所以有个后台回收循环(packages/opencode/src/snapshot/index.ts:891-896):启动 1 分钟后开始,之后每小时一次,跑 cleanup()

cleanup() 做两件事(packages/opencode/src/snapshot/index.ts:336-355):先按 7 天保留期清掉过期的 pin(retention:56),再跑 git gc --prune=7.daysprune:55)。


7. 回退链路:把"撤销到某条消息"变成一次 checkout

7.1 用户看到的是什么

用户在某条消息上点"撤销到这里"。期望是:这条消息之后的对话没了,并且磁盘上的文件也退回到那时候的样子

7.2 流程

怎么读这张图:从上往下是 SessionRevert.revert 的执行顺序。

输入: { sessionID, messageID, partID? }
|
v
[1] 扫全部消息,找到目标点之后的所有 type:"patch" part
| (每个 patch part 带着 hash + files)
v
[2] rev.snapshot = 已有的 ?: 现照一张 track()
| (先给"现在这个状态"留个后路)
v
[3] 先算 diff —— 必须在改文件之前算,否则就没得算了
|
v
[4] snap.revert(patches) —— 逐个 hash checkout 对应文件
|
v
[5] 把 rev 写进 SessionTable.revert 字段(软删除,不真删消息)

对应源码 packages/opencode/src/session/revert.ts:41-125。第 [3] 步的顺序有一条 kilocode 自己加的注释明确说明了原因(:76-80):diff 必须在文件被回退之前算,因为那时磁盘上还留着 AI 的改动,diff 才反映"正在被撤销的东西"。

7.3 snap.revert 的实际动作

Snapshot.revertpackages/opencode/src/snapshot/index.ts:492)拿到的是 { hash, files } 列表,对每个文件做 git checkout <hash> -- <file>:506)。

两个工程细节:

  • checkout 失败要分情况。 失败可能是"这个文件当时不存在",也可能是别的原因。所以先 ls-tree 问一句:该 hash 的树里有没有这个路径?有 → 保留文件、只记日志;没有 → 说明这文件是 AI 新建的,直接删掉(packages/opencode/src/snapshot/index.ts:523-544)。
  • 批处理但避开路径冲突。 同 hash 的相邻文件最多攒 100 个一起 checkout,但只要两个路径存在包含关系(a === bab 的前缀目录),就断开这一批(clashpackages/opencode/src/snapshot/index.ts:547-560)。批量失败就整批降级成逐文件重来。

7.4 revert 字段:软删除

回退不删消息,只在会话行上打个标记。SessionTable 里的这一列(packages/core/src/session/sql.ts:50):

revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>()

四个字段各司其职:

字段作用
messageID撤销到哪条消息(它之后的消息在 UI 上灰掉/隐藏)
partID精确到消息里的哪个 part
snapshot撤销之前的现场 hash——这是"撤销的撤销"的凭据
diff被撤销掉的那段改动的 unified diff,给 UI 展示

于是有了对称的两个操作:

  • unrevertpackages/opencode/src/session/revert.ts:127)—— snap.restore(session.revert.snapshot) 把工作区拉回撤销前,再 clearRevert 清标记。反悔是免费的。
  • cleanuppackages/opencode/src/session/revert.ts:154)—— 只有当用户在撤销后继续发新消息时才调用,这时才真正把被撤销的消息/part 从存储里移除。

也就是说:撤销本身是可逆的,只有"撤销后继续对话"这个动作才把它固化。


8. 诊断闭环:改完立刻挨骂

匹配成功、写盘成功,不代表改对了。所以 edit 的最后一段是把编译器/linter 的报错塞回工具输出里,让模型在同一轮内就看到自己捅的娄子。

8.1 三行代码的闭环

packages/opencode/src/tool/edit.ts:223-227

yield* lsp.touchFile(filePath, "document")
const diagnostics = yield* lsp.diagnostics()
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`

output 就是工具调用的返回文本,会原样进入下一轮的对话上下文。模型下一步大概率会自己去修。

8.2 每一环干什么

环节位置干什么
LSP.touchFilelsp/lsp.ts:360通知所有相关 language server "这个文件变了",然后等诊断刷新
client.waitForDiagnosticslsp/client.ts:630document / full 两种模式等,超时就算了
waitForFreshPushlsp/client.ts:464等 server 主动推送(push 模式),带版本号校验和防抖
waitForDocumentDiagnosticslsp/client.ts:499主动拉(pull 模式)与等推送赛跑,谁先到算谁
LSP.diagnosticslsp/lsp.ts:380把所有 server 的诊断按文件路径合并成一张表
Diagnostic.reportlsp/diagnostic.ts:20只留 severity === 1(ERROR),每文件最多 20 条
filterDiagnosticstool/diagnostics.ts:11存进会话记录前,只保留本次编辑到的文件

有两处很克制的裁剪,值得单独点出:

  • 只报 ERROR,不报 WARN/INFO/HINTpackages/opencode/src/lsp/diagnostic.ts:21)。warning 灌进上下文纯属噪音,还烧 token。
  • 每文件最多 20 条MAX_PER_FILEpackages/opencode/src/lsp/diagnostic.ts:3),超出的写成 ... and N more

filterDiagnostics 是 kilocode 自己加的,注释写明了动机:LSP 返回的是整个项目所有文件的诊断,全量存进 session payload 会让大项目里每次工具调用膨胀 100 KB 以上(packages/opencode/src/tool/diagnostics.ts:4-10)。注意这只影响存储edit 塞进 output 的那段本来就只取当前文件。

write 这边策略略有不同:它除了本文件,还会额外报最多 5 个其他文件的错(MAX_PROJECT_DIAGNOSTICS_FILESpackages/opencode/src/tool/write.ts:22)——因为整文件重写更容易把下游文件搞崩。

8.3 诊断从哪来

packages/opencode/src/lsp/server.ts 里是一张 language server 清单,每项声明扩展名 + 怎么启动:Deno(:68)、TypeScript/tsgo(:101)、Vue(:123)、ESLint(:152)、Oxlint(:209)、Biome(:282)等。注意 TypeScript 那条挂在 KILO_EXPERIMENTAL_LSP_TOOL 开关后面(:97-101)——没开时不 spawn,诊断闭环对 TS 项目就是空转。


9. 巧妙之处(可以偷的技术)

一、replacer 用生成器而不是"直接替换"。 每个 replacer 只负责"吐候选",替换和裁决集中在 replace() 一处(packages/opencode/src/tool/edit.ts:709)。于是加一级新策略只需要往数组里塞一个函数,安全性规则(唯一性、回原文验证)自动继承,不会有哪一级偷偷绕过检查。

二、"宁可报错也不改错地方"写成了一行代码。 if (index !== lastIndex) continuepackages/opencode/src/tool/edit.ts:745)。整套容错的胆子有多大,全靠这一行兜住。

三、--git-dir 与工作区分离。 用 git 现成的能力做版本化,但一个字节都不污染用户的仓库(packages/opencode/src/snapshot/index.ts:107-111)。比自己写快照存储省掉了差量、压缩、GC 全套。

四、write-tree 而非 commit 只要一个内容寻址的 hash,就别去建 commit(packages/opencode/src/snapshot/index.ts:406)。没有 HEAD、没有分支、没有 author 配置的烦恼。

五、diff 必须先于回退计算。 一条顺序约束,配了注释解释原因(packages/opencode/src/session/revert.ts:88-97rev.snapshot 兜底在 :84)。这种"顺序即正确性"的地方最容易被后来者重构掉。

六、撤销本身可撤销。 revert 先给现状照一张快照存进 revert.snapshotunrevert 直接 restore 回去(packages/opencode/src/session/revert.ts:84 / :112)。用户敢点撤销,是因为撤销不是单程票。

七、check-ignore --no-index 让"已经跟踪的文件"也接受 pattern 判定,于是 .gitignore 的后续修改能追溯生效(packages/opencode/src/snapshot/index.ts:137-166)。


10. 边界与局限(诚实清单)

九级里的第 ⑨ 级基本是死代码。 MultiOccurrenceReplacer 吐的是精确匹配(跟第 ① 级 SimpleReplacer 同一个字符串),只是吐多次。replaceAll: true 时第 ① 级就已经返回,replaceAll: false 时它吐的每一份候选都会栽在同一个唯一性检查上。所以它无法在 SimpleReplacer 失败的场景里成功。(inferred)

单候选块也过相似度闸。 早期版本这里曾是 0.0(恒真、无条件接受);当前 commit 已改成 SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65packages/opencode/src/tool/edit.ts:247),判断仍是 similarity >= 阈值:376)——中段与首尾的 levenshtein 相似度现在真的参与单候选路径的取舍,替换掉一大段并不相似内容的风险被收窄了。

"读过才能改"没有代码强制。 prompt 里承诺会报错(packages/opencode/src/tool/edit.txt:4),实际上上游删掉了 FileTime 后就没人拦了(packages/opencode/test/kilocode/tool-encoding.test.ts:89)。

快照依赖 git 存在。 enabled() 里第一条就是 if (state.vcs !== "git") return falsepackages/opencode/src/snapshot/index.ts:221-225)。非 git 项目、配置里关掉 snapshot、或者 ACP 客户端,全都没有后悔药。

大文件不进快照。 超过 2 MB 的未跟踪文件被写进 exclude(packages/opencode/src/snapshot/index.ts:46:292-310)。agent 生成的大产物,回退不回来。

快照保留 7 天。 prune / retention 都是 7 天(packages/opencode/src/snapshot/index.ts:44-45)。想撤销两周前的会话,对象可能已经被 gc 掉了。

九级匹配保证不了语义正确。 它只保证"落到了模型指的那个位置"。落对位置但改错逻辑,只能靠第 8 节的诊断回灌和人来兜。

generateUnifiedDiff 是个简化实现。 packages/opencode/src/patch/index.ts:486 处的函数自带注释说"真实实现应该用正经的 diff 算法"。它产出的 unified_diff 字段在 apply_patch 主路径上没被使用——那条路径用的是 diff 包的 createTwoFilesPatchpackages/opencode/src/tool/apply_patch.ts:154)。


11. 代码地图

主题文件路径关键符号
编辑工具入口与参数packages/opencode/src/tool/edit.tsEditToolParameters
九级级联与裁决packages/opencode/src/tool/edit.tsreplaceReplacer
九个 replacerpackages/opencode/src/tool/edit.tsSimpleReplacerLineTrimmedReplacerBlockAnchorReplacerWhitespaceNormalizedReplacerIndentationFlexibleReplacerEscapeNormalizedReplacerTrimmedBoundaryReplacerContextAwareReplacerMultiOccurrenceReplacer
相似度打分与阈值packages/opencode/src/tool/edit.tslevenshteinSINGLE_CANDIDATE_SIMILARITY_THRESHOLDMULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD
按文件加锁packages/opencode/src/tool/edit.tslocklocks
行尾处理packages/opencode/src/tool/edit.tsdetectLineEndingconvertToLineEndingnormalizeLineEndings
diff 生成与裁剪packages/opencode/src/tool/edit.tsbuildFileDifftrimDiffMAX_DIFF_CONTENT
整文件写入packages/opencode/src/tool/write.tsWriteToolMAX_PROJECT_DIAGNOSTICS_FILES
多文件补丁工具packages/opencode/src/tool/apply_patch.tsApplyPatchTool
补丁格式解析packages/opencode/src/patch/index.tsparsePatchparsePatchHeaderparseUpdateFileChunksstripHeredoc
补丁落盘与四趟匹配packages/opencode/src/patch/index.tsderiveNewContentsFromChunkscomputeReplacementsseekSequencetryMatchnormalizeUnicode
读文件(行号前缀、截断)packages/opencode/src/tool/read.tsReadToolcollectMAX_LINE_LENGTHMAX_BYTES
影子仓库服务packages/opencode/src/snapshot/index.tsSnapshot.Interfacelayer
快照六个动作packages/opencode/src/snapshot/index.tstrackpatchrestorerevertdiffdiffFull
gitignore 与体积闸门packages/opencode/src/snapshot/index.tsignoredropsyncexcludesaddlimit
快照回收packages/opencode/src/snapshot/index.tscleanuppruneretention
快照数据结构packages/opencode/src/snapshot/index.tsPatchFileDiffSummaryFileDiffMAX_DIFF_SIZE
主循环里照快照packages/opencode/src/session/processor.tscreatesnapshot.track 三处调用)
撤销/反撤销/固化packages/opencode/src/session/revert.tsrevertunrevertcleanupRevertInput
会话表的 revert 列packages/opencode/src/session/session.sql.tsSessionTable.revert
撤销标记读写packages/opencode/src/session/session.tssetRevertclearRevert
patch part 定义packages/opencode/src/session/message-v2.tsPatchPart
诊断裁剪packages/opencode/src/tool/diagnostics.tsfilterDiagnostics
诊断格式化packages/opencode/src/lsp/diagnostic.tsreportprettyMAX_PER_FILE
LSP 服务门面packages/opencode/src/lsp/lsp.tstouchFilediagnostics
等诊断刷新packages/opencode/src/lsp/client.tswaitForDiagnosticswaitForFreshPushwaitForDocumentDiagnosticswaitForFullDiagnostics
language server 清单packages/opencode/src/lsp/server.tsTypescriptDenoESLintOxlintBiomeVue
编辑工具测试packages/opencode/test/tool/edit.test.tsdescribe("concurrent editing")describe("line endings")

继续读: 05-context-management.md 讲这一切产生的文本怎么塞进有限的上下文窗口;03-tools-and-permission.md 讲写盘前那道权限闸门;回到 index.md 看全景。