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:
parent
1cb362773b
commit
54b756c145
@ -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) もありません。
|
||||
|
||||
@ -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)도 없습니다.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 发起修改时,必须通过这些标签引用目标行。如果在此期间文件发生过变化,哈希验证就会失败,从而在代码被污染前直接驳回。不再有缩进空格错乱,彻底告别改错行的惨剧。
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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$/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user