diff --git a/README.ja.md b/README.ja.md index 80de7ca6f..31de23e8d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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) もありません。 diff --git a/README.ko.md b/README.ko.md index 213b0b394..2c72b5457 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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)도 없습니다. diff --git a/README.md b/README.md index 1ce701e7a..e314903b5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-cn.md b/README.zh-cn.md index 0968baace..8d0c34dcf 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -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 发起修改时,必须通过这些标签引用目标行。如果在此期间文件发生过变化,哈希验证就会失败,从而在代码被污染前直接驳回。不再有缩进空格错乱,彻底告别改错行的惨剧。 diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 3852d64fd..b6f4db30d 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -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 { - 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( diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts index 14f2f23b8..0f41874bb 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -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("/tmp/demo.ts") expect(lines[1]).toBe("file") expect(lines[2]).toBe("") - 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("") }) @@ -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("") }) @@ -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 }) }) diff --git a/src/tools/hashline-edit/diff-utils.ts b/src/tools/hashline-edit/diff-utils.ts index f2f74732c..7104a3a42 100644 --- a/src/tools/hashline-edit/diff-utils.ts +++ b/src/tools/hashline-edit/diff-utils.ts @@ -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") } diff --git a/src/tools/hashline-edit/edit-operations.test.ts b/src/tools/hashline-edit/edit-operations.test.ts index 24c0cdce2..539106605 100644 --- a/src/tools/hashline-edit/edit-operations.test.ts +++ b/src/tools/hashline-edit/edit-operations.test.ts @@ -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"]) diff --git a/src/tools/hashline-edit/edit-text-normalization.ts b/src/tools/hashline-edit/edit-text-normalization.ts index beb6ac87d..8e38c50cf 100644 --- a/src/tools/hashline-edit/edit-text-normalization.ts +++ b/src/tools/hashline-edit/edit-text-normalization.ts @@ -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 { diff --git a/src/tools/hashline-edit/hash-computation.test.ts b/src/tools/hashline-edit/hash-computation.test.ts index d73a2db2d..fe4fff0cd 100644 --- a/src/tools/hashline-edit/hash-computation.test.ts +++ b/src/tools/hashline-edit/hash-computation.test.ts @@ -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$/) }) }) diff --git a/src/tools/hashline-edit/hash-computation.ts b/src/tools/hashline-edit/hash-computation.ts index 8371887d6..ec6773a9c 100644 --- a/src/tools/hashline-edit/hash-computation.ts +++ b/src/tools/hashline-edit/hash-computation.ts @@ -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 { diff --git a/src/tools/hashline-edit/hashline-edit-diff.ts b/src/tools/hashline-edit/hashline-edit-diff.ts index 9ea1f6135..901828e79 100644 --- a/src/tools/hashline-edit/hashline-edit-diff.ts +++ b/src/tools/hashline-edit/hashline-edit-diff.ts @@ -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` } } diff --git a/src/tools/hashline-edit/tool-description.ts b/src/tools/hashline-edit/tool-description.ts index f95ea9020..4a9a6dd5d 100644 --- a/src/tools/hashline-edit/tool-description.ts +++ b/src/tools/hashline-edit/tool-description.ts @@ -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 } diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts index e821bea16..fc401cbf9 100644 --- a/src/tools/hashline-edit/validation.test.ts +++ b/src/tools/hashline-edit/validation.test.ts @@ -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", () => { diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts index 5f09610f6..f81ccbaa4 100644 --- a/src/tools/hashline-edit/validation.ts +++ b/src/tools/hashline-edit/validation.ts @@ -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 {