数据截至 (上游 commit 565d53515b54)
hashline:内容哈希锚定的编辑语言
30 秒导读: hashline 是 oh-my-pi 里
edit工具背后的补丁语言。模型改代码时,不重打要改的旧行,只写"改第 12 到 15 行,换成这些新内容",再附上一枚 4 位十六进制的整文件内容哈希当凭证。哈希认证"我数的行号还对得上现在的文件",于是补丁首次即中、又省 token;一旦文件已经变了,哈希对不上,坏补丁先被拒,再走恢复。
本章讲清三件事:① 这门补丁语言长什么样、为什么这样设计能省 token;② "内容哈希锚定"到底锚的是什么、怎么在文件漂移时先拦坏补丁;③ 它怎么挂到 coding-agent 的 edit 工具上。
工具怎么注册进 agent、edit 的参数 schema 长什么样,是第 3 章的视角,本章不重复;这里只讲 hashline 这门语言本身的原理。
1. 这是什么(零基础也能懂)
一句话定义: hashline 是一门"指着行号说话"的补丁语言——模型用 read 看文件时,每行前面被标了行号、文件头被盖了一枚内容哈希;改文件时,模型就照着那些行号下指令,配上那枚哈希做凭证。
它解决谁的什么问题。 让大模型可靠地改一个真实项目的代码,是编码 agent 最难的一环。传统两条老路都有硬伤:
| 做法 | 怎么定位要改的地方 | 硬伤 |
|---|---|---|
| search / replace(如 aider) | 把要改的整段旧代码原样重打一遍,让工具去搜 | 旧代码越长越费 token;模型多打/少打一个空格就搜不到 |
unified diff(@@ -a,b +c,d @@) | 给出上下文行 + 增删标记 | 模型算不准行号偏移、上下文行也要重打;格式一错整块废 |
| hashline | 只报行号 + 哈希凭证,不重打旧代码 | 需要 read 先盖哈希、行号会随每次编辑作废(本章会讲怎么治) |
用起来什么样。 一次 read 之后模型看到的是带行号、带哈希头的文件:
[greet.py#A1B2]
1:def greet(name):
2: msg = "Hello, " + name
3: print(msg)
4:greet("world")
要把第 2 行换成两行、并在第 1 行后插一句守卫,模型回一段 hashline 补丁:
[greet.py#A1B2]
INS.POST 1:
+ if not name: name = "stranger"
SWAP 2.=2:
+ greeting = "Hi"
+ msg = f"{greeting}, {name}"
注意补丁里完全没有出现旧的第 2 行内容 msg = "Hello, " + name——要删的旧行,只用行号点名(SWAP 2.=2),工具照行号删掉;body 里只有新内容(每行 + 开头)。这就是省 token 的来源。
一句话直觉。 把它想成"报座位号,不描述长相":旧的 search/replace 是"把那个穿红衣服、戴眼镜、坐在窗边的人请出去"(得把人从头描述一遍);hashline 是"请 12 排 3 座的人出去"(报个号就行),而那枚文件哈希,就是"确认这还是同一场、座位表没换过"的票根。
本节不碰底层。记住一点:行号负责"指哪"、哈希负责"证明这张座位表还没过期",下面全从这句展开。
2. 顶层全景(它大概怎么转)
先看主线。 hashline 从来不是"模型说改就改",而是一条 read → 出补丁 → 认证 → 落盘 → 换新哈希的环:
┌─────────┐ 盖哈希+标行号 ┌──────────┐ 照行号写补丁 ┌──────────┐
│ read/ │ ─────────────────> │ 模型 │ ───────────────> │ edit │
│ search │ [path#A1B2] │(在回合里) │ SWAP 2.=2: … │ 工具 │
└─────────┘ 1:… 2:… 3:… └──────────┘ └────┬─────┘
▲ │
│ 快照存进 SnapshotStore(整文件文本 + 哈希 + 看过的行) │ 认证 + 应用
│ ▼
│ ┌──────────────── ────────────────────────────────────────────────┐
└──┤ Patcher:比对哈希 → 一致就应用 / 漂移就恢复 / 恢复不了就拒(re-read)│
│ 应用后重新算哈希,回一个新的 [path#新哈希] │
└────────────────────────────────────────────────────────────────┘
怎么读这张图: 左上角 read 是补丁的唯一合法源头——它给文件盖哈希、标行号,并把当时的整文件文本存进快照库(下方);模型只能照 read 给出的行号和哈希写补丁;edit 工具(右)交给 Patcher 认证,认证靠的就是快照库里那份文本。每次应用成功都铸一枚新哈希,老哈希当场作废。
部件一句话职责:
| 部件 | 干什么 | 在哪(packages/hashline/src/) |
|---|---|---|
computeFileHash | 把整文件文本压成 4 位十六进制哈希(座位表的"票根") | format.ts:112 |
Tokenizer | 把补丁文本逐行分类成 header / op / body-row | tokenizer.ts:440 classifyLine |
Executor | 把 op + body 降解成一串底层 Edit(插入/删除) | parser.ts:387 #flushPending |
applyEdits | 纯函数:把 Edit 应用到文本,顺手修常见边界错 | apply.ts:1180 |
SnapshotStore | 按路径存整文件历史版本 + 哈希 + "看过的行" | snapshots.ts:155 InMemorySnapshotStore |
Patcher | 总调度:读盘、认证哈希、失配走恢复、写回、铸新哈希 | patcher.ts:217 |
Recovery | 哈希对不上时,把补丁重放到旧快照再三路合并到现文件 | recovery.ts:165 |
MismatchError | 恢复也救不了时,抛出"re-read"诊断 | mismatch.ts:55 |
下面按"语言 → 锚定 → 检测 → 恢复 → 容错"五层,由浅入深钻进去。
3. 核心原理
3.1 补丁语言:一个 header,几种动词,只有 + 的 body
它要解决的小问题: 用最少的字符,无歧义地表达"在文件的哪些行、做什么改动"。
语言的形状由 grammar.lark:1-28 的形式文法定义,拆成三段:
- 文件头
[PATH#TAG]——TAG是 4 位十六进制哈 希,每段必带,没有无哈希写法(grammar.lark:6file_header)。 - 动词头(hunk header)——一行一个操作,点名要动的原始行号。
- body 行——只在带
:的动词头下出现,每行+TEXT,就是要写进去的字面内容。
动词全表(源自 prompt.md 与 tokenizer.ts:298 scanHunkAnchor):
| 动词头 | 含义 | 带 body? | 关键点 |
|---|---|---|---|
SWAP N.=M: | 把原始第 N 到 M 行换成 body | 是 | 区间含 M;body 长度与区间无关(1 行换 10 行仍是 N.=M) |
DEL N.=M | 删掉第 N 到 M 行 | 否 | 无冒号、无 body |
INS.PRE N: / INS.POST N: | 在第 N 行前 / 后插入 body | 是 | 纯插入,绝不用加宽的 SWAP 代替 |
INS.HEAD: / INS.TAIL: | 插到文件最前 / 最后 | 是 | 位置稳定,与行号无关(下文有用) |
SWAP.BLK N: / DEL.BLK N | 换 / 删"从第 N 行开始的整个语法块" | 视情况 | 块的结尾由 tree-sitter 解析(见 §3.5) |
INS.BLK.POST N: | 插到第 N 行所在块的结尾之后(同级) | 是 | |
REM / MV DEST | 删整个文件 / 移动重命名 | 否 / 视情况 | 文件级操作 |
body 行的铁律是这门语言省 token 的核心:每行只能是 +TEXT,绝不写 -旧行、也不写 裸的上下文行(prompt.md <body-rows>)。要删的行靠动词头的区间点名,body 里只放最终想要的新内容。这条规则在解析器里是硬拒的——一个以 - 开头的裸 body 行会直接抛错(parser.ts:287 MINUS_ROW_REJECTED)。
降解:动词头怎么变成底层动作。 Executor.#flushPending(parser.ts:387)把每个 hunk 拆成统一的 Edit(类型见 types.ts:26)。以 SWAP N.=M: 为例(parser.ts:419-423):
// 示意,非源码:SWAP 的降解思路
// 1) 每个 body 行 → 一条 "replacement" 插入,锚在区间起点 N 之前
// 2) 区间 [N, M] 里每一行 → 一条 delete
// 于是"替换"= 先在 N 处插新内容,再删掉 N..M 的旧行
也就是说,applyEdits 眼里的世界只有两种原 子操作:在某行插入、删除某行。所有动词都被降到这两种(SWAP = 插 + 删,DEL = 删,INS.* = 插)。
防污染: 解析器还专门拦截模型习惯性混入的别家补丁格式——*** Update File:、@@ -a,b +c,d @@ 这类 apply_patch / unified-diff 残留会被 detectApplyPatchContamination(parser.ts:46)识别,给出"这不是 hashline,请用 SWAP/DEL/INS"的定向报错,而不是把它们当成乱码。
3.2 内容哈希锚定:哈希锚的是"快照身份",不是每一行
这是全章最容易误解的一点,先把话说死:
#TAG不是每行一枚哈希,而是整个文件一枚哈希。 行号负责"指哪一行",哈希负责"证明这份行号表还没过期"。二者合起来才叫"内容哈希锚定"。
哈希怎么算。 computeFileHash(format.ts:112)对整文件规范化后的文本取 xxHash32 的低 16 位,补成 4 位大写十六进制:
// 示意,非源码:computeFileHash 的核心
const normalized = normalizeFileHashText(text); // 先削每行尾部空白(容忍 CRLF / 显示截断)
const low16 = xxHash32(normalized, 0) & 0xffff; // 取低 16 位
return low16.toString(16).padStart(4, "0").toUpperCase(); // → 如 "A1B2"
规范化那步(format.ts:103 normalizeFileHashText)很关键:它先把每行尾部的空格/制表/回车削掉,所以 CRLF 换行、或 read 显示时被截掉的尾随空格,都不会让哈希失配。
为什么这样锚定既省 token 又安全,三点分层:
- 省 token: 模型要定位一处改动,只需
SWAP 12.=15:五个 token 级别 的开销 + 新内容;不必像 search/replace 那样把第 12–15 行的旧代码原样重打一遍。旧代码越长,省得越多。 - 首次即中: 行号是
read刚给的、逐字对齐的(format.ts:133formatNumberedLine输出N:TEXT),模型不需要"猜"上下文,匹配不靠模糊搜索,天然一击命中。 - 安全: 一枚整文件哈希就够认证——因为任何一处改动都会改变整文件哈希。模型手里的行号,只有在"文件仍然逐字等于当初盖哈希的那份快照"时才有效;
Patcher用现盘文本重新算哈希、和#TAG一比,就知道行号还认不认(§3.3)。
读融合(read fusion):同一份内容,同一枚哈希。 SnapshotStore.record(snapshots.ts:197)按内容哈希去重:反复 read 同一份未变的文件,只会复用同一枚 tag、刷新最近使用时间(snapshots.ts:208),不会堆版本。这让"多次 read 融到一个锚点"成立,模型无论从哪次 read 抄哈希都一样。
锚点会死。 每次成功应用都会 #recordFullSnapshot 铸一枚新哈希并重新编号(patcher.ts:558);老的 #TAG 与老行号当场作废。所以 prompt.md 反复强调:每次编辑后必须从 edit 的响应或一次新 read 重新取号(<critical> 第 1 条)。