refactor(hashline): change content separator from colon to pipe

Change LINE#HASH:content format to LINE#HASH|content across the entire
codebase. The pipe separator is more visually distinct and avoids
conflicts with TypeScript colons in code content.

15 files updated: implementation, prompts, tests, and READMEs.
This commit is contained in:
minpeter 2026-02-24 06:01:24 +09:00
parent 1cb362773b
commit 54b756c145
15 changed files with 49 additions and 49 deletions

View File

@ -217,9 +217,9 @@ MCPサーバーがあなたのコンテキスト予算を食いつぶしてい
[oh-my-pi](https://github.com/can1357/oh-my-pi) に触発され、**Hashline**を実装しました。エージェントが読むすべての行にコンテンツハッシュがタグ付けされて返されます:
```
11#VK: function hello() {
22#XJ: return "world";
33#MB: }
11#VK| function hello() {
22#XJ| return "world";
33#MB| }
```
エージェントはこのタグを参照して編集します。最後に読んだ後でファイルが変更されていた場合、ハッシュが一致せず、コードが壊れる前に編集が拒否されます。空白を正確に再現する必要もなく、間違った行を編集するエラー (stale-line) もありません。

View File

@ -216,9 +216,9 @@ MCP 서버들이 당신의 컨텍스트 예산을 다 잡아먹죠. 우리가
[oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감을 받아, **Hashline**을 구현했습니다. 에이전트가 읽는 모든 줄에는 콘텐츠 해시 태그가 붙어 나옵니다:
```
11#VK: function hello() {
22#XJ: return "world";
33#MB: }
11#VK| function hello() {
22#XJ| return "world";
33#MB| }
```
에이전트는 이 태그를 참조해서 편집합니다. 마지막으로 읽은 후 파일이 변경되었다면 해시가 일치하지 않아 코드가 망가지기 전에 편집이 거부됩니다. 공백을 똑같이 재현할 필요도 없고, 엉뚱한 줄을 수정하는 에러(stale-line)도 없습니다.

View File

@ -220,9 +220,9 @@ The harness problem is real. Most agent failures aren't the model. It's the edit
Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi), we implemented **Hashline**. Every line the agent reads comes back tagged with a content hash:
```
11#VK: function hello() {
22#XJ: return "world";
33#MB: }
11#VK| function hello() {
22#XJ| return "world";
33#MB| }
```
The agent edits by referencing those tags. If the file changed since the last read, the hash won't match and the edit is rejected before corruption. No whitespace reproduction. No stale-line errors.

View File

@ -218,9 +218,9 @@ Harness 问题是真的。绝大多数所谓的 Agent 故障,其实并不是
受 [oh-my-pi](https://github.com/can1357/oh-my-pi) 的启发,我们实现了 **Hashline** 技术。Agent 读到的每一行代码,末尾都会打上一个强绑定的内容哈希值:
```
11#VK: function hello() {
22#XJ: return "world";
33#MB: }
11#VK| function hello() {
22#XJ| return "world";
33#MB| }
```
Agent 发起修改时,必须通过这些标签引用目标行。如果在此期间文件发生过变化,哈希验证就会失败,从而在代码被污染前直接驳回。不再有缩进空格错乱,彻底告别改错行的惨剧。

View File

@ -56,7 +56,7 @@ function transformLine(line: string): string {
return line
}
const hash = computeLineHash(parsed.lineNumber, parsed.content)
return `${parsed.lineNumber}#${hash}:${parsed.content}`
return `${parsed.lineNumber}#${hash}|${parsed.content}`
}
function transformOutput(output: string): string {
@ -137,7 +137,7 @@ function extractFilePath(metadata: unknown): string | undefined {
}
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
if (output.output.includes("Updated file (LINE#ID:content):")) {
if (output.output.includes("Updated file (LINE#ID|content):")) {
return
}
@ -153,7 +153,7 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
const content = await file.text()
const hashlined = toHashlineContent(content)
output.output = `${output.output}\n\nUpdated file (LINE#ID:content):\n${hashlined}`
output.output = `${output.output}\n\nUpdated file (LINE#ID|content):\n${hashlined}`
}
export function createHashlineReadEnhancerHook(

View File

@ -47,8 +47,8 @@ describe("hashline-read-enhancer", () => {
//#then
const lines = output.output.split("\n")
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/)
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/)
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
expect(lines[10]).toBe("1: keep this unchanged")
})
@ -78,8 +78,8 @@ describe("hashline-read-enhancer", () => {
expect(lines[0]).toBe("<path>/tmp/demo.ts</path>")
expect(lines[1]).toBe("<type>file</type>")
expect(lines[2]).toBe("<content>")
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/)
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/)
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
expect(lines[6]).toBe("(End of file - total 2 lines)")
expect(lines[7]).toBe("</content>")
})
@ -105,9 +105,9 @@ describe("hashline-read-enhancer", () => {
//#then
const lines = output.output.split("\n")
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:# Oh-My-OpenCode Features$/)
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:$/)
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:Hashline test$/)
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|# Oh-My-OpenCode Features$/)
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|$/)
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}\|Hashline test$/)
expect(lines[4]).toBe("(End of file - total 3 lines)")
})
@ -133,8 +133,8 @@ describe("hashline-read-enhancer", () => {
//#then
const lines = output.output.split("\n")
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/)
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
expect(lines[5]).toBe("</file>")
})
@ -160,8 +160,8 @@ describe("hashline-read-enhancer", () => {
//#then
const lines = output.output.split("\n")
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/)
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
})
it("appends LINE#ID output for write tool using metadata filepath", async () => {
@ -181,9 +181,9 @@ describe("hashline-read-enhancer", () => {
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toContain("Updated file (LINE#ID:content):")
expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1/)
expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2/)
expect(output.output).toContain("Updated file (LINE#ID|content):")
expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1/)
expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2/)
fs.rmSync(tempDir, { recursive: true, force: true })
})

View File

@ -9,7 +9,7 @@ export function toHashlineContent(content: string): string {
const hashlined = contentLines.map((line, i) => {
const lineNum = i + 1
const hash = computeLineHash(lineNum, line)
return `${lineNum}#${hash}:${line}`
return `${lineNum}#${hash}|${line}`
})
return hasTrailingNewline ? hashlined.join("\n") + "\n" : hashlined.join("\n")
}

View File

@ -141,7 +141,7 @@ describe("hashline edit operations", () => {
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "1#VK:first\n2#NP:second")
const result = applySetLine(lines, anchorFor(lines, 2), "1#VK|first\n2#NP|second")
//#then
expect(result).toEqual(["line 1", "first", "second", "line 3"])

View File

@ -1,4 +1,4 @@
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}:/
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\|/
const DIFF_PLUS_RE = /^[+](?![+])/
function equalsIgnoringWhitespace(a: string, b: string): boolean {

View File

@ -60,7 +60,7 @@ describe("computeLineHash", () => {
})
describe("formatHashLine", () => {
it("formats single line as LINE#ID:content", () => {
it("formats single line as LINE#ID|content", () => {
//#given
const lineNumber = 42
const content = "const x = 42"
@ -69,12 +69,12 @@ describe("formatHashLine", () => {
const result = formatHashLine(lineNumber, content)
//#then
expect(result).toMatch(/^42#[ZPMQVRWSNKTXJBYH]{2}:const x = 42$/)
expect(result).toMatch(/^42#[ZPMQVRWSNKTXJBYH]{2}\|const x = 42$/)
})
})
describe("formatHashLines", () => {
it("formats all lines as LINE#ID:content", () => {
it("formats all lines as LINE#ID|content", () => {
//#given
const content = "a\nb\nc"
@ -84,9 +84,9 @@ describe("formatHashLines", () => {
//#then
const lines = result.split("\n")
expect(lines).toHaveLength(3)
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:a$/)
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:b$/)
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:c$/)
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|a$/)
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|b$/)
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}\|c$/)
})
})

View File

@ -13,7 +13,7 @@ export function computeLineHash(lineNumber: number, content: string): string {
export function formatHashLine(lineNumber: number, content: string): string {
const hash = computeLineHash(lineNumber, content)
return `${lineNumber}#${hash}:${content}`
return `${lineNumber}#${hash}|${content}`
}
export function formatHashLines(content: string): string {

View File

@ -14,16 +14,16 @@ export function generateHashlineDiff(oldContent: string, newContent: string, fil
const hash = computeLineHash(lineNum, newLine)
if (i >= oldLines.length) {
diff += `+ ${lineNum}#${hash}:${newLine}\n`
diff += `+ ${lineNum}#${hash}|${newLine}\n`
continue
}
if (i >= newLines.length) {
diff += `- ${lineNum}# :${oldLine}\n`
diff += `- ${lineNum}# |${oldLine}\n`
continue
}
if (oldLine !== newLine) {
diff += `- ${lineNum}# :${oldLine}\n`
diff += `+ ${lineNum}#${hash}:${newLine}\n`
diff += `- ${lineNum}# |${oldLine}\n`
diff += `+ ${lineNum}#${hash}|${newLine}\n`
}
}

View File

@ -5,7 +5,7 @@ WORKFLOW:
2. Pick the smallest operation per logical mutation site.
3. Submit one edit call per file with all related operations.
4. If same file needs another call, re-read first.
5. Use anchors as "LINE#ID" only (never include trailing ":content").
5. Use anchors as "LINE#ID" only (never include trailing "|content").
VALIDATION:
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }

View File

@ -24,7 +24,7 @@ describe("parseLineRef", () => {
it("accepts refs copied with markers and trailing content", () => {
//#given
const ref = ">>> 42#VK:const value = 1"
const ref = ">>> 42#VK|const value = 1"
//#when
const result = parseLineRef(ref)
@ -49,7 +49,7 @@ describe("validateLineRef", () => {
const lines = ["function hello() {"]
//#when / #then
expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}:/)
expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}\|/)
})
it("shows >>> mismatch context in batched validation", () => {
@ -58,7 +58,7 @@ describe("validateLineRef", () => {
//#when / #then
expect(() => validateLineRefs(lines, ["2#ZZ"]))
.toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}:two/)
.toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}\|two/)
})
})
@ -90,7 +90,7 @@ describe("legacy LINE:HEX backward compatibility", () => {
const lines = ["function hello() {"]
//#when / #then
expect(() => validateLineRef(lines, "1:ab")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}:/)
expect(() => validateLineRef(lines, "1:ab")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}\|/)
})
it("extracts legacy ref from content with markers", () => {

View File

@ -115,7 +115,7 @@ export class HashlineMismatchError extends Error {
const content = fileLines[line - 1] ?? ""
const hash = computeLineHash(line, content)
const prefix = `${line}#${hash}:${content}`
const prefix = `${line}#${hash}|${content}`
if (mismatchByLine.has(line)) {
output.push(`>>> ${prefix}`)
} else {