refactor(hashline): switched header syntax to bracketed [path#tag]

- Replaced `¶path#hash` prefix with `[path#hash]` delimiters across parser, tokenizer, and grammar.
- Updated prompts, docs, and recovery paths to the new bracketed form.
This commit is contained in:
can1357
2026-06-06 15:36:01 +02:00
parent 0f568ccf87
commit 860eef3f2f
35 changed files with 206 additions and 180 deletions
@@ -1864,7 +1864,7 @@ export const SETTINGS_SCHEMA = {
tab: "editing",
label: "Hash Lines",
description:
"Include snapshot-tag headers and line numbers in read output for hashline edit mode (PATH#tag plus LINE:content)",
"Include snapshot-tag headers and line numbers in read output for hashline edit mode ([PATH#TAG] plus LINE:content)",
},
},
@@ -14,7 +14,7 @@ import { normalizeToLF } from "./normalize";
/**
* Upper bound on the file size we snapshot. A section tag is a content hash of
* the *whole* file, so minting one means holding the full normalized text in
* the store. Files above this cap emit no `path#tag` header — line-anchored
* the store. Files above this cap emit no `[path#tag]` header — line-anchored
* editing of multi-megabyte files is out of scope under the full-content model.
*/
export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
+1 -1
View File
@@ -275,7 +275,7 @@ function extractApprovalPath(args: unknown): string {
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
const input = typeof record.input === "string" ? record.input : undefined;
if (input) {
const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
const hashlineMatch = /^\[([^#\r\n]+)(?:#[0-9a-fA-F]{4})?\]/m.exec(input);
if (hashlineMatch?.[1]) return hashlineMatch[1];
const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
+7 -7
View File
@@ -2,7 +2,7 @@
* Edit tool renderer and LSP batching helpers.
*/
import { HL_FILE_PREFIX } from "@oh-my-pi/hashline";
import { HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
import { sanitizeText } from "@oh-my-pi/pi-utils";
@@ -328,12 +328,12 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
}
function parseHashlineInputPreviewHeader(line: string): string | null {
if (!line.startsWith(HL_FILE_PREFIX)) return null;
// Mirror hashline/input.ts: strip every leading file marker so canonical
// `¶ PATH` headers and stray `¶¶ PATH` / `¶¶¶PATH` runs render clean paths.
let prefixEnd = 0;
while (prefixEnd < line.length && line[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
const body = line.slice(prefixEnd).trim();
const trimmed = line.trimEnd();
if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
// Keep streaming previews tolerant while the closing bracket is still
// being generated; the parser enforces the final `[path#TAG]` shape.
const bodyEnd = trimmed.endsWith(HL_FILE_SUFFIX) ? trimmed.length - HL_FILE_SUFFIX.length : trimmed.length;
const body = trimmed.slice(HL_FILE_PREFIX.length, bodyEnd).trim();
const previewPath = normalizeHashlineInputPreviewPath(body);
return previewPath.length > 0 ? previewPath : null;
}
+1 -1
View File
@@ -424,7 +424,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
return previews.length > 0 ? previews : null;
},
renderStreamingFallback() {
// Never leak raw hashline syntax (`64:`, `|payload`, `path#hash`)
// Never leak raw hashline syntax (`64:`, `|payload`, `[path#hash]`)
// to the user — the streaming preview already projects every
// parseable op onto the real file via applyPartialTo, and an
// unparseable trailing chunk renders as "no preview yet" rather
@@ -14,7 +14,7 @@ Performs structural AST-aware rewrites via native ast-grep.
</instruction>
<output>
- Replacement summary, per-file replacement counts, and change diffs as `src/foo.ts#0a`, `-12:before`, `+12:after` lines in hashline mode
- Replacement summary, per-file replacement counts, and change diffs as `[src/foo.ts#1A2B]`, `-12:before`, `+12:after` lines in hashline mode
- Parse issues when files cannot be processed
</output>
@@ -18,7 +18,7 @@ Performs structural code search using AST matching via native ast-grep.
<output>
- Grouped matches with file path, byte range, line/column ranges, metavariable captures
- Match lines are numbered under a file snapshot tag header in hashline mode: `src/foo.ts#0a`, `*42:content` for the matched line, ` 43:content` for context
- Match lines are numbered under a file snapshot tag header in hashline mode: `[src/foo.ts#1A2B]`, `*42:content` for the matched line, ` 43:content` for context
- Summary counts (`totalMatches`, `filesWithMatches`, `filesSearched`) and parse issues when present
</output>
@@ -28,7 +28,7 @@ Append `:<sel>` to `path`. The bare path falls back to the default mode.
- Reading a directory path returns a depth-limited dirent listing.
{{#if IS_HL_MODE}}
- Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: `src/foo.ts#0a` then `41:def alpha():`. Copy the `PATH#TAG` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag.
- Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: `[src/foo.ts#1A2B]` then `41:def alpha():`. Copy the `[PATH#TAG]` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag.
{{else}}
{{#if IS_LINE_NUMBER_MODE}}
- Reading a file with an explicit selector returns lines prefixed with line numbers: `41|def alpha():`.
@@ -9,7 +9,7 @@ Searches files using powerful regex matching.
<output>
{{#if IS_HL_MODE}}
- Text output emits a file snapshot tag header per matched file plus numbered lines: `src/login.ts#1f`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers.
- Text output emits a file snapshot tag header per matched file plus numbered lines: `[src/login.ts#1A2B]`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers.
{{else}}
{{#if IS_LINE_NUMBER_MODE}}
- Text output is line-number-prefixed
+4 -4
View File
@@ -58,7 +58,7 @@ import {
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
const LOOSE_HASHLINE_HEADER_RE = /^\s*¶\S+#[^ \t\r\n]*\s*$/;
const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
async function loadFflate(): Promise<typeof import("fflate")> {
@@ -109,7 +109,7 @@ function stripWriteContentWithPotentialLooseHeader(lines: string[]): { text: str
/**
* Strip hashline display prefixes from write content.
*
* Only active when hashline edit mode is enabled — the model sees `PATH#HASH`
* Only active when hashline edit mode is enabled — the model sees `[PATH#HASH]`
* headers plus `LINE:` prefixes in read output and sometimes copies them into write content.
*/
function stripWriteContent(session: ToolSession, content: string): { text: string; stripped: boolean } {
@@ -122,7 +122,7 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
/**
* Record a snapshot of the freshly-written `content` for `absolutePath`
* so subsequent hashline edits address the new file with a current tag,
* and return the matching `displayPath#TAG` header. Returns `undefined`
* and return the matching `[displayPath#TAG]` header. Returns `undefined`
* when the session is not in hashline mode so callers can no-op cheaply.
*
* Mirrors the post-commit snapshot recording the hashline patcher performs
@@ -770,7 +770,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
context?: AgentToolContext,
): Promise<AgentToolResult<WriteToolDetails>> {
return untilAborted(signal, async () => {
// Strip hashline display prefixes (PATH#HASH + LINE:) if the model copied them from read output
// Strip hashline display prefixes ([PATH#HASH] + LINE:) if the model copied them from read output
const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
const internalRouter = InternalUrlRouter.instance();
if (internalRouter.canHandle(path)) {
@@ -184,7 +184,7 @@ describe("hashline normalization", () => {
describe("hashline parser — range-anchor syntax", () => {
it("keeps parsed edits reusable across different target snapshots", () => {
const section = Patch.parseSingle(["a.ts", `insert after ${tag(2, "bbb")}:`, repl("tail")].join("\n"));
const section = Patch.parseSingle(["[a.ts]", `insert after ${tag(2, "bbb")}:`, repl("tail")].join("\n"));
expect(section.applyTo("aaa\nbbb").text).toBe("aaa\nbbb\ntail");
expect(section.applyTo("aaa\nbbb\nccc").text).toBe("aaa\nbbb\ntail\nccc");
@@ -546,9 +546,9 @@ describe("hashline — snapshot tag binding", () => {
});
});
describe("splitHashlineInput — @ headers", () => {
it("extracts path, snapshot tag, and diff body from @path#tag header", () => {
const input = [`src/foo.ts#1A2B`, `${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n");
describe("splitHashlineInput — bracket headers", () => {
it("extracts path, snapshot tag, and diff body from [path#tag] header", () => {
const input = [`[src/foo.ts#1A2B]`, `${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n");
expect(splitHashlineInput(input)).toEqual({
path: "src/foo.ts",
fileHash: "1A2B",
@@ -557,7 +557,7 @@ describe("splitHashlineInput — @ headers", () => {
});
it("strips leading blank lines", () => {
expect(splitHashlineInput(`\nfoo.ts\ninsert head:\n${repl("x")}`)).toEqual({
expect(splitHashlineInput(`\n[foo.ts]\ninsert head:\n${repl("x")}`)).toEqual({
path: "foo.ts",
diff: `insert head:\n${repl("x")}`,
});
@@ -566,7 +566,7 @@ describe("splitHashlineInput — @ headers", () => {
it("normalizes cwd-prefixed absolute paths to cwd-relative paths", () => {
const cwd = process.cwd();
const absolute = path.join(cwd, "src", "foo.ts");
expect(splitHashlineInput(`${absolute}\ninsert head:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts");
expect(splitHashlineInput(`[${absolute}]\ninsert head:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts");
});
it("uses explicit fallback path only when input has recognizable operations", () => {
@@ -578,7 +578,7 @@ describe("splitHashlineInput — @ headers", () => {
});
it("splits multiple edit sections", () => {
const input = ["a.ts", "insert head:", repl("a"), "b.ts", "insert tail:", repl("b")].join("\n");
const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]", "insert tail:", repl("b")].join("\n");
expect(splitHashlineInputs(input)).toEqual([
{ path: "a.ts", diff: `insert head:\n${repl("a")}` },
{ path: "b.ts", diff: `insert tail:\n${repl("b")}` },
@@ -595,7 +595,7 @@ describe("splitHashlineInput — @ headers", () => {
});
it("silently drops a trailing header with no operations", () => {
const input = ["a.ts", "insert head:", repl("a"), "b.ts"].join("\n");
const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]"].join("\n");
expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `insert head:\n${repl("a")}` }]);
});
});
@@ -630,7 +630,7 @@ it("preflights write policy for every section before committing a batch", async
describe("hashline executor", () => {
it("rejects file creation and directs to the write tool", async () => {
await withTempDir(async tempDir => {
const input = `new.ts\ninsert head:\n${repl("export const x = 1;")}\n`;
const input = `[new.ts]\ninsert head:\n${repl("export const x = 1;")}\n`;
await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow(/write tool/);
expect(await Bun.file(path.join(tempDir, "new.ts")).exists()).toBe(false);
});
@@ -681,7 +681,7 @@ describe("hashline executor", () => {
await Bun.write(bPath, "bbb\n");
const session = makeHashlineSession(tempDir);
const aTag = recordFullSnapshot(getFileReadCache(session), aPath, "aaa\n");
const bHeader = "b.ts#FFFF";
const bHeader = "[b.ts#FFFF]";
const input = [
header("a.ts", aTag),
`${sameLineRange(tag(1, "aaa"))}`,
@@ -795,14 +795,14 @@ describe("hashlineEditParamsSchema — payload shape", () => {
it("tolerates provider extra fields without declaring `path`", () => {
expect(
hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `x.ts\ninsert head:\n${repl("x")}` }).success,
hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `[x.ts]\ninsert head:\n${repl("x")}` }).success,
).toBe(true);
});
it("accepts `_input` as a provider-emitted alias for `input`", () => {
const parsed = hashlineEditParamsSchema.safeParse({ _input: `x.ts\ninsert head:\n${repl("x")}` });
const parsed = hashlineEditParamsSchema.safeParse({ _input: `[x.ts]\ninsert head:\n${repl("x")}` });
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.input).toBe(`x.ts\ninsert head:\n${repl("x")}`);
if (parsed.success) expect(parsed.data.input).toBe(`[x.ts]\ninsert head:\n${repl("x")}`);
});
it("still requires `input`", () => {
@@ -1088,11 +1088,11 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () =>
it("splitter respects *** Abort like *** End Patch", () => {
const input = [
`a.ts`,
`[a.ts]`,
`insert after ${tag(1, "alpha")}:`,
repl("a-payload"),
sentinel,
`b.ts`,
`[b.ts]`,
`insert after ${tag(1, "beta")}:`,
repl("never-emitted"),
].join("\n");
@@ -1112,33 +1112,33 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () =>
describe("hashline parser — delete and empty-block semantics", () => {
it("inline delete deletes a single line", () => {
const text = "line1\nline2\nline3\n";
const { diff } = splitHashlineInput(`a.ts\ndelete 2\n`);
const { diff } = splitHashlineInput(`[a.ts]\ndelete 2\n`);
expect(applyDiff(text, diff)).toBe("line1\nline3\n");
});
it("inline delete deletes the range", () => {
const text = "line1\nline2\nline3\nline4\n";
const { diff } = splitHashlineInput(`a.ts\ndelete 2..3\n`);
const { diff } = splitHashlineInput(`[a.ts]\ndelete 2..3\n`);
expect(applyDiff(text, diff)).toBe("line1\nline4\n");
});
it("empty replace removes the range", () => {
const text = "line1\nline2\nline3\n";
const { diff } = splitHashlineInput(`a.ts\nreplace 2..2:\n`);
const { diff } = splitHashlineInput(`[a.ts]\nreplace 2..2:\n`);
expect(applyDiff(text, diff)).toBe("line1\nline3\n");
});
it("`2..2=replacement` (old format) parses as orphan body, not as inline payload", () => {
const { diff } = splitHashlineInput(`a.ts\n2..2=replacement\n`);
const { diff } = splitHashlineInput(`[a.ts]\n2..2=replacement\n`);
expect(() => parseHashline(diff)).toThrow(/payload line has no preceding hunk header/);
});
it("explicit empty literal rows insert blank lines when the anchor is repeated", () => {
const text = "line1\nline2\nline3\n";
const aboveDiff = splitHashlineInput(`a.ts\ninsert before 2:\n${repl("")}\n`).diff;
const aboveDiff = splitHashlineInput(`[a.ts]\ninsert before 2:\n${repl("")}\n`).diff;
expect(applyDiff(text, aboveDiff)).toBe("line1\n\nline2\nline3\n");
const belowDiff = splitHashlineInput(`a.ts\ninsert after 2:\n${repl("")}\n`).diff;
const belowDiff = splitHashlineInput(`[a.ts]\ninsert after 2:\n${repl("")}\n`).diff;
expect(applyDiff(text, belowDiff)).toBe("line1\nline2\n\nline3\n");
});
});
@@ -1146,28 +1146,28 @@ describe("hashline parser — delete and empty-block semantics", () => {
describe("hashline parser — explicit blank payload rows", () => {
it("raw blank lines between ops are ignored", () => {
const text = "a\nb\nc\nd\ne\n";
const ops = `a.ts\nreplace 1..1:\n${repl("A")}\n\nreplace 3..3:\n${repl("C")}\n`;
const ops = `[a.ts]\nreplace 1..1:\n${repl("A")}\n\nreplace 3..3:\n${repl("C")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n");
});
it("empty replacement payload rows are appended as blank payload lines", () => {
const text = "a\nb\nc\nd\ne\n";
const ops = `a.ts\nreplace 1..1:\n${repl("A")}\n${repl("")}\n${repl("")}\nreplace 3..3:\n${repl("C")}\n`;
const ops = `[a.ts]\nreplace 1..1:\n${repl("A")}\n${repl("")}\n${repl("")}\nreplace 3..3:\n${repl("C")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("A\n\n\nb\nC\nd\ne\n");
});
it("`replace N..N:` followed by two empty replace rows replaces the line with two blanks", () => {
const text = "a\nb\nc\nd\ne\n";
const ops = `a.ts\nreplace 2..2:\n${repl("")}\n${repl("")}\nreplace 4..4:\n${repl("D")}\n`;
const ops = `[a.ts]\nreplace 2..2:\n${repl("")}\n${repl("")}\nreplace 4..4:\n${repl("D")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n");
});
it("empty replace row inside payload between two content lines is preserved", () => {
const text = "a\nb\nc\n";
const ops = `a.ts\nreplace 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`;
const ops = `[a.ts]\nreplace 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("a\nfirst\n\nsecond\nc\n");
});
+2 -2
View File
@@ -276,7 +276,7 @@ describe("computeHashlineDiff", () => {
// preview/diff path MUST emit the SAME rejection so a successful preview
// never precedes a failing apply.
const result = await computeHashlineDiff(
{ input: `${relativePath}\ninsert tail:\n+second` },
{ input: `[${relativePath}]\ninsert tail:\n+second` },
tempDir,
new InMemorySnapshotStore(),
);
@@ -287,7 +287,7 @@ describe("computeHashlineDiff", () => {
});
test("returns a handled error when the source path is a local URL", async () => {
const result = await computeHashlineDiff(
{ input: "local://PLAN.md\ninsert tail:\n+x" },
{ input: "[local://PLAN.md]\ninsert tail:\n+x" },
tempDir,
new InMemorySnapshotStore(),
);
@@ -136,7 +136,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => {
});
test("does not surface stale hash errors while streaming", async () => {
const input = "a.ts#FFFF\nreplace 2..2:\n+const b = 22";
const input = "[a.ts#FFFF]\nreplace 2..2:\n+const b = 22";
const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never);
expect(previews).toHaveLength(1);
expect(previews?.[0]?.error).toBeUndefined();
@@ -170,7 +170,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => {
});
test("surfaces stale hash errors once streaming is complete", async () => {
const input = "a.ts#FFFF\nreplace 2..2:\n+const b = 22\n";
const input = "[a.ts#FFFF]\nreplace 2..2:\n+const b = 22\n";
const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never);
expect(previews).toHaveLength(1);
expect(previews?.[0]?.error).toContain("not from this session");
@@ -245,12 +245,12 @@ describe("apply_patch streaming preview (trailing partial line)", () => {
describe("matcherDigest", () => {
test("hashline: digests stripped `+` body rows only, never headers or op lines", () => {
const input = ["a.ts#AB12", "replace 1..2:", "+const x = 1;", "+const y = 2;", "delete 5", ""].join("\n");
const input = ["[a.ts#AB12]", "replace 1..2:", "+const x = 1;", "+const y = 2;", "delete 5", ""].join("\n");
expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input })).toBe("const x = 1;\nconst y = 2;");
});
test("hashline: grammar-only payload digests to empty, missing input to undefined", () => {
expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: "a.ts#AB12\ndelete 3\n" })).toBe("");
expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: "[a.ts#AB12]\ndelete 3\n" })).toBe("");
expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({})).toBeUndefined();
});
@@ -23,7 +23,7 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import type { ReadToolDetails } from "@oh-my-pi/pi-coding-agent/tools/read";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
const HASHLINE_HEADER_LINE = /^¶(\S+)#([0-9A-F]{4})$/m;
const HASHLINE_HEADER_LINE = /^\[([^#\r\n]+)#([0-9A-F]{4})\]$/m;
const COLUMN_CAP = 64;
const LONG_LINE_LEN = COLUMN_CAP * 3;
@@ -511,7 +511,7 @@ describe("write resolves conflicts via conflict://N", () => {
await read.execute("read-hashed", { path: "hashed.ts" });
const result = await write.execute("write-hashed", {
path: "conflict://1",
content: "hashed.ts#1a2b\n42:cleanline\n",
content: "[hashed.ts#1a2b]\n42:cleanline\n",
});
expect(getText(result)).toContain("auto-stripped hashline display prefixes");
const after = await Bun.file(filePath).text();
@@ -57,7 +57,7 @@ describe("editToolRenderer", () => {
const uiTheme = await getUiTheme();
const component = editToolRenderer.renderCall(
{
input: "packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview",
input: "[packages/coding-agent/src/edit/renderer.ts]\ninsert tail:\n+// preview",
},
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
@@ -75,9 +75,12 @@ describe("editToolRenderer", () => {
const component = new ToolExecutionComponent(
"edit",
{
input: ["*** Begin Patch", "¶crates/pi-natives/src/shell.rs", "EOF:", "|pub fn streaming_preview() {"].join(
"\n",
),
input: [
"*** Begin Patch",
"[crates/pi-natives/src/shell.rs]",
"insert tail:",
"+pub fn streaming_preview() {",
].join("\n"),
},
{},
hashlineTool,
@@ -86,8 +89,8 @@ describe("editToolRenderer", () => {
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("crates/pi-natives/src/shell.rs");
expect(rendered).not.toContain("EOF:");
expect(rendered).not.toContain("|pub fn streaming_preview() {");
expect(rendered).not.toContain("insert tail:");
expect(rendered).not.toContain("+pub fn streaming_preview() {");
expect(rendered).not.toContain("*** Begin Patch");
});
@@ -95,7 +98,7 @@ describe("editToolRenderer", () => {
const uiTheme = await getUiTheme();
const compactComponent = editToolRenderer.renderCall(
{
input: "foo bar.ts\nBOF:\n|// preview",
input: "[foo bar.ts]\ninsert head:\n+// preview",
},
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
@@ -103,7 +106,7 @@ describe("editToolRenderer", () => {
const quotedComponent = editToolRenderer.renderCall(
{
input: "'baz qux.ts'\nBOF:\n|// preview",
input: "['baz qux.ts']\ninsert head:\n+// preview",
},
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
@@ -115,33 +118,33 @@ describe("editToolRenderer", () => {
expect(quotedRendered).toContain("baz qux.ts");
});
it("strips canonical `¶` and longer `¶` runs from hashline input headers", async () => {
it("strips bracket delimiters from hashline input headers", async () => {
const uiTheme = await getUiTheme();
// Canonical `PATH` form — the parser strips the marker and the
// Canonical `[PATH]` form — the parser strips the delimiters and the
// renderer keeps the title clean.
const canonical = editToolRenderer.renderCall(
{
input: "packages/coding-agent/src/slash-commands/builtin-registry.ts\nBOF:\n|// preview",
input: "[packages/coding-agent/src/slash-commands/builtin-registry.ts]\ninsert head:\n+// preview",
},
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
);
// Even longer runs should still produce the clean path.
const triple = editToolRenderer.renderCall(
{ input: "¶¶¶a/b/c.ts\nBOF:\n|// preview" },
// While streaming, the closing bracket may not have arrived yet.
const partial = editToolRenderer.renderCall(
{ input: "[a/b/c.ts\ninsert head:\n+// preview" },
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
);
const canonicalRendered = Bun.stripANSI(canonical.render(160).join("\n"));
const tripleRendered = Bun.stripANSI(triple.render(160).join("\n"));
const partialRendered = Bun.stripANSI(partial.render(160).join("\n"));
expect(canonicalRendered).toContain("packages/coding-agent/src/slash-commands/builtin-registry.ts");
expect(canonicalRendered).not.toMatch(/packages\/coding-agent/);
expect(tripleRendered).toContain("a/b/c.ts");
expect(tripleRendered).not.toMatch(/¶+a\/b\/c\.ts/);
expect(canonicalRendered).not.toMatch(/\[packages\/coding-agent/);
expect(partialRendered).toContain("a/b/c.ts");
expect(partialRendered).not.toMatch(/\[a\/b\/c\.ts/);
});
it("uses hashline input headers for completed single-file result path", async () => {
@@ -157,7 +160,7 @@ describe("editToolRenderer", () => {
{ expanded: false, isPartial: false, renderContext: { editMode: "hashline" } },
uiTheme,
{
input: "packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview",
input: "[packages/coding-agent/src/edit/renderer.ts]\ninsert tail:\n+// preview",
},
);
@@ -182,7 +185,7 @@ describe("editToolRenderer", () => {
// The trailing payload line carries no newline — the common shape for a
// single-line edit. The streaming pass trims that in-flight line, so the
// preview only becomes computable once args are marked complete.
const input = `memory.ts#${tag}\nreplace 2..2:\n+export const b = 22;`;
const input = `[memory.ts#${tag}]\nreplace 2..2:\n+export const b = 22;`;
const component = new ToolExecutionComponent("edit", { input }, { snapshots }, hashlineTool, uiStub, tmpDir);
component.setArgsComplete();
@@ -208,7 +211,7 @@ describe("editToolRenderer", () => {
const snapshots = new InMemorySnapshotStore();
const tag = snapshots.record(filePath, content);
const input = `memory.ts#${tag}\nreplace 2..2:\n+export const b = 22;\n`;
const input = `[memory.ts#${tag}]\nreplace 2..2:\n+export const b = 22;\n`;
const component = new ToolExecutionComponent(
"edit",
{ __partialJson: input },
@@ -251,7 +251,7 @@ describe("SearchTool internal URL resolution", () => {
const text = getResultText(result);
expect(text).toContain("needle");
// No hashline section headers or numbered editable lines for immutable sources.
expect(text).not.toMatch(/^¶.*#[0-9A-F]{4}$/m);
expect(text).not.toMatch(/^\[[^#\r\n]+#[0-9A-F]{4}\]$/m);
expect(text).not.toMatch(/^\*?\s*\d+:/m);
});
@@ -291,7 +291,7 @@ describe("SearchTool internal URL resolution", () => {
const text = getResultText(result);
expect(text).toContain("needle");
// Mutable local:// sources keep a hashline section header plus numbered match lines.
expect(text).toMatch(/^¶.*#[0-9A-F]{4}$/m);
expect(text).toMatch(/^\[[^#\r\n]+#[0-9A-F]{4}\]$/m);
expect(text).toMatch(/^\*\d+:.*needle/m);
});
@@ -194,7 +194,7 @@ describe("tool path arrays", () => {
const text = getText(result);
const details = result.details as { fileCount?: number; missingPaths?: string[] } | undefined;
expect(text).toMatch(/^packages\/grep\.txt#[0-9A-F]{4}/m);
expect(text).toMatch(/^\[packages\/grep\.txt#[0-9A-F]{4}\]/m);
expect(text).toContain("Skipped missing paths: missing.txt");
expect(text).not.toContain("apps");
expect(details?.fileCount).toBe(1);
+1 -1
View File
@@ -349,7 +349,7 @@ describe("TtsrManager snapshot matching", () => {
streamKey: "toolcall:tc-1",
};
const patch = [
"src/repo.ts#AB12",
"[src/repo.ts#AB12]",
"replace block 1:",
"+export async function isRepository(cwd: string): Promise<boolean> {",
"+\treturn repo.isRepository(cwd);",
@@ -30,7 +30,7 @@ function resultText(result: { content: { type: string; text?: string }[] }): str
.join("\n");
}
const HASHLINE_HEADER_LINE = /^¶(\S+)#([0-9A-F]{4})$/;
const HASHLINE_HEADER_LINE = /^\[([^#\r\n]+)#([0-9A-F]{4})\]$/;
describe("write tool hashline header", () => {
let tmpDir: string;
@@ -47,7 +47,7 @@ describe("write tool hashline header", () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("insert heads a fresh path#TAG header that maps to the written content", async () => {
it("inserts a fresh [path#TAG] header that maps to the written content", async () => {
const filePath = path.join(tmpDir, "module.ts");
const session = createSession(tmpDir);
const tool = new WriteTool(session);
@@ -111,7 +111,7 @@ describe("write tool hashline header", () => {
const result = await tool.execute("call-1", { path: filePath, content });
const text = resultText(result);
expect(text.startsWith("")).toBe(false);
expect(text.startsWith("[")).toBe(false);
expect(text).toBe(`Successfully wrote ${content.length} bytes to ${path.relative(tmpDir, filePath)}`);
});
});
+17 -16
View File
@@ -23,10 +23,10 @@ const snapshots = new InMemorySnapshotStore();
const before = `const greeting = "hi";\nexport { greeting };\n`;
await fs.writeText("hello.ts", before);
const tag = snapshots.recordContiguous("hello.ts", 1, before.split("\n"), { fullText: before });
const tag = snapshots.record("hello.ts", before);
const patcher = new Patcher({ fs, snapshots });
const patch = Patch.parse(String.raw`hello.ts#${tag}
@@ 1..1 @@
const patch = Patch.parse(String.raw`[hello.ts#${tag}]
replace 1..1:
+const greeting = "hello";`);
const result = await patcher.apply(patch);
@@ -39,19 +39,19 @@ console.log(await fs.readText("hello.ts"));
See [`src/prompt.md`](./src/prompt.md) for the user-facing description and
[`src/grammar.lark`](./src/grammar.lark) for the formal grammar.
Each file section starts with `PATH#TAG`. The tag is a 3-hex opaque
pointer into the `SnapshotStore` that minted it; it is not content-derived
and is not meaningful outside that store. The patcher protects against
stale anchors by resolving the tag, verifying the recorded snapshot lines
against live file content, and refusing or attempting session-aware
recovery on mismatch.
Each file section starts with `[PATH#TAG]`. The tag is a 4-hex
content hash of the full normalized file text recorded by the
`SnapshotStore`, and it is not meaningful outside that store. The patcher
protects against stale anchors by resolving the tag, verifying the live file
still matches the recorded content hash, and refusing or attempting
session-aware recovery on mismatch.
Inside a section:
- `@@ A..B @@` — open a hunk on lines A..B (use `@@ A,A @@` for a single line; bare `@@ A @@` is also accepted).
- `@@ BOF @@` / `@@ EOF @@` — virtual hunks at the beginning/end of file.
- `replace A..B:` — replace lines A..B with following `+TEXT` body rows.
- `replace block A:` — replace the syntactic block beginning on line A.
- `delete A..B` / `delete block A` — delete concrete lines or a resolved block.
- `insert before A:` / `insert after A:` / `insert head:` / `insert tail:` — insert following body rows.
- `+TEXT` — literal body row (use `+` alone for a blank line).
- `&A..B` — repeat original file lines A..B inline (`&A` for one line).
- Empty body — delete the selected range.
## Abstractions
@@ -67,9 +67,10 @@ text-document protocol, a Git tree, anything.
### `SnapshotStore`
Required. Hashline tags are opaque store pointers, so `Patcher` must receive
the store that minted them. Recovery replays edits against the cached pre-edit
snapshot and 3-way-merges onto current content when the live file diverged.
Required. Hashline tags are full-file content hashes recorded per path, so
`Patcher` must receive the store that observed them. Recovery replays edits
against the cached pre-edit snapshot and 3-way-merges onto current content
when the live file diverged.
### `Patcher`
+4 -3
View File
@@ -6,8 +6,9 @@
import type { Cursor } from "./types";
/** File-section header prefix: `path#hash`. */
export const HL_FILE_PREFIX = "";
/** File-section header delimiters: `[path#hash]`. */
export const HL_FILE_PREFIX = "[";
export const HL_FILE_SUFFIX = "]";
/** Payload sigil for literal body rows. */
export const HL_PAYLOAD_REPLACE = "+";
@@ -118,7 +119,7 @@ export function describeAnchorExamples(linePrefix = ""): string {
/** Format a hashline section header for a file path and snapshot tag. */
export function formatHashlineHeader(filePath: string, fileHash: string): string {
return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}`;
return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}${HL_FILE_SUFFIX}`;
}
/** Formats a single numbered line as `LINE:TEXT`. */
+1 -1
View File
@@ -3,7 +3,7 @@ begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?
file_patch: file_header hunk+
file_header: "" filename "#" file_hash LF
file_header: "[" filename "#" file_hash "]" LF
file_hash: /[0-9A-F]{4}/
filename: /[^#\r\n]+/
+21 -19
View File
@@ -1,6 +1,6 @@
/**
* Top-level patch parser. Splits an authored hashline input into a list of
* {@link PatchSection}s, each rooted at a `PATH#HASH` header, then exposes
* {@link PatchSection}s, each rooted at a `[PATH#HASH]` header, then exposes
* a {@link Patch} class that gives lazy access to the parsed edits per
* section.
*
@@ -10,7 +10,7 @@
import * as path from "node:path";
import { applyEdits } from "./apply";
import { resolveBlockEdits } from "./block";
import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
import { parsePatch, parsePatchStreaming } from "./parser";
import { Tokenizer } from "./tokenizer";
import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types";
@@ -47,15 +47,17 @@ function stripApplyPatchPathNoise(pathText: string): string {
}
/**
* Best-effort recovery for `¶`-prefixed lines the strict tokenizer
* Best-effort recovery for bracketed header lines the strict tokenizer
* rejects. Strips apply_patch keyword noise (`Update File:`, `Update:`,
* etc.) and an extra leading `***` (some models emit a hybrid `¶***foo.ts`
* shape), then expects `PATH(#HASH)?`.
* etc.) and an extra leading `***` (some models emit a hybrid
* `[***foo.ts#HASH]` shape), then expects `PATH(#HASH)?`.
* Returns `null` when no clean path can be salvaged.
*/
function tryParseRecoveryHeader(line: string, cwd?: string): RawSection | null {
if (!line.startsWith(HL_FILE_PREFIX)) return null;
const body = stripApplyPatchPathNoise(line.slice(HL_FILE_PREFIX.length).trim());
if (!line.startsWith(HL_FILE_PREFIX) || !line.endsWith(HL_FILE_SUFFIX)) return null;
const body = stripApplyPatchPathNoise(
line.slice(HL_FILE_PREFIX.length, line.length - HL_FILE_SUFFIX.length).trim(),
);
if (body.length === 0) return null;
// Trailing `#XXXX` is the tag; everything before it is the path. The
@@ -99,9 +101,9 @@ interface RawSection {
}
/**
* Parse a `PATH[#hash]` header line. Returns `null` for lines that do
* not start with ``. Throws the strict "Input header must be …" error
* when a `¶`-prefixed line fails the strict shape (so malformed paths
* Parse a `[PATH]` or `[PATH#hash]` header line. Returns `null` for lines that do
* not start with `[`. Throws the strict "Input header must be …" error
* when a bracketed line fails the strict shape (so malformed paths
* surface immediately instead of being silently re-classified as payload).
*/
function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null {
@@ -111,18 +113,18 @@ function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null
const token = TOKENIZER.tokenize(trimmed);
if (token.kind !== "header") {
// Recovery: try to extract a path from the raw line after stripping
// apply_patch noise. This handles `*** Update File:foo.ts#CB5` and
// apply_patch noise. This handles `[*** Update File:foo.ts#CB5A]` and
// the half-dozen variants models actually emit.
const recovered = tryParseRecoveryHeader(trimmed, cwd);
if (recovered !== null) return recovered;
throw new Error(
`Input header must be ${HL_FILE_PREFIX}PATH or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}TAG with a ${HL_FILE_HASH_LENGTH}-hex content-hash tag; got ${JSON.stringify(trimmed)}.`,
`Input header must be ${HL_FILE_PREFIX}PATH${HL_FILE_SUFFIX} or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}TAG${HL_FILE_SUFFIX} with a ${HL_FILE_HASH_LENGTH}-hex content-hash tag; got ${JSON.stringify(trimmed)}.`,
);
}
const parsedPath = normalizeHashlinePath(token.path, cwd);
if (parsedPath.length === 0) {
throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
throw new Error(`Input header "${HL_FILE_PREFIX}${HL_FILE_SUFFIX}" is empty; provide a file path.`);
}
return token.fileHash !== undefined
? { path: parsedPath, fileHash: token.fileHash, diff: "" }
@@ -165,7 +167,7 @@ function normalizeFallbackInput(input: string, options: SplitOptions): string {
if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
if (fallbackPath.length === 0) return input;
return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`;
return `${HL_FILE_PREFIX}${fallbackPath}${HL_FILE_SUFFIX}\n${input}`;
}
function splitRawSections(input: string, options: SplitOptions = {}): RawSection[] {
@@ -180,13 +182,13 @@ function splitRawSections(input: string, options: SplitOptions = {}): RawSection
if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(firstTrimmed)) {
throw new Error(
"unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
"File sections start with `¶path#HASH`; use `replace`, `delete`, or `insert` ops.",
`File sections start with \`${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}HASH${HL_FILE_SUFFIX}\`; use \`replace\`, \`delete\`, or \`insert\` ops.`,
);
}
const preview = JSON.stringify(firstLine.slice(0, 120));
throw new Error(
`input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH" on the first non-blank line for anchored edits; got: ${preview}. ` +
`Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}0A3" then edit ops.`,
`input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH${HL_FILE_SUFFIX}" on the first non-blank line for anchored edits; got: ${preview}. ` +
`Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX}" then edit ops.`,
);
}
@@ -207,7 +209,7 @@ function splitRawSections(input: string, options: SplitOptions = {}): RawSection
if (token.kind === "envelope-end" || token.kind === "abort") break;
if (token.kind === "envelope-begin") continue;
// Route every `¶`-prefixed line through parseHashlineHeaderLine so
// Route every bracket-prefixed line through parseHashlineHeaderLine so
// malformed headers still raise the strict "Input header must be …"
// diagnostic (the tokenizer alone would silently classify them as
// payload).
@@ -343,7 +345,7 @@ export class PatchSection {
/**
* A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
* at a `PATH#HASH` header. Construct via {@link Patch.parse}.
* at a `[PATH#HASH]` header. Construct via {@link Patch.parse}.
*
* `Patch` is pure data: parsing is line-anchored and does not look at the
* filesystem. To apply a patch, hand it to {@link Patcher.apply}.
+2 -2
View File
@@ -5,7 +5,7 @@
* them.
*/
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
/** Lines of context shown either side of a hash mismatch. */
export const MISMATCH_CONTEXT = 2;
@@ -124,5 +124,5 @@ export const HEADTAIL_DRIFT_WARNING =
* this single builder to stay in lockstep.
*/
export function missingSnapshotTagMessage(sectionPath: string): string {
return `Missing hashline snapshot tag for edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag\` from your latest read/search output. To create a new file, use the write tool.`;
return `Missing hashline snapshot tag for edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX}\` from your latest read/search output. To create a new file, use the write tool.`;
}
+4 -4
View File
@@ -6,7 +6,7 @@
* plus a couple of lines of surrounding context. The {@link MismatchError}
* formats this into a message at construction time.
*/
import { formatNumberedLine, HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
import { formatNumberedLine, HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
import { MISMATCH_CONTEXT } from "./messages";
const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/;
@@ -15,7 +15,7 @@ export function formatFullAnchorRequirement(raw?: string): string {
const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
return (
`a bare line number from read/search output plus the section header content-hash tag ` +
`(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]} and line "160")${received}`
`(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX} and line "160")${received}`
);
}
@@ -99,12 +99,12 @@ export class MismatchError extends Error {
if (!hashRecognized) {
return [
`Edit rejected${pathText}: hash ${HL_FILE_HASH_SEP}${details.expectedFileHash} is not from this session.`,
`The current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. Re-read the file with \`read\` to copy a current ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag header — never invent the tag and never reuse one from a prior session.`,
`The current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. Re-read the file with \`read\` to copy a current ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX} header — never invent the tag and never reuse one from a prior session.`,
];
}
return [
`Edit rejected${pathText}: file changed between read and edit.`,
`Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If a prior edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash header from that edit's response; otherwise re-read the file with \`read\` to refresh the tag before retrying.`,
`Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If a prior edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash${HL_FILE_SUFFIX} header from that edit's response; otherwise re-read the file with \`read\` to refresh the tag before retrying.`,
];
}
+1 -1
View File
@@ -43,7 +43,7 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
return (
`apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
"File sections start with `path#HASH` (no `Update File:` / `Add File:` keyword). " +
"File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " +
"Use `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
);
}
+2 -2
View File
@@ -64,9 +64,9 @@ export interface PatchSectionResult {
persisted: string;
/** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
written: string;
/** 3-hex opaque snapshot tag for `after`. Use to anchor follow-up edits. */
/** 4-hex content-hash tag for `after`. Use to anchor follow-up edits. */
fileHash: string;
/** Hashline section header (`path#tag`) of the post-edit content. */
/** Hashline section header (`[path#tag]`) of the post-edit content. */
header: string;
/** 1-indexed first changed line in `after`, or `undefined` for noops. */
firstChangedLine?: number;
+3 -1
View File
@@ -14,9 +14,11 @@
* otherwise turn every content line into a (malformed) op.
*/
import { HL_FILE_HASH_LENGTH } from "./format";
const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/;
const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/;
const HL_HEADER_RE = /^\s*¶\S+#[0-9a-fA-F]{3}\s*$/;
const HL_HEADER_RE = new RegExp(`^\\s*\\[[^#\\r\\n]+#[0-9a-fA-F]{${HL_FILE_HASH_LENGTH}}\\]\\s*$`);
const DIFF_PLUS_RE = /^[+](?![+])/;
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
+9 -9
View File
@@ -1,7 +1,7 @@
Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `delete` has no body.
<headers>
Every file section starts with `PATH#TAG`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist.
Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist.
</headers>
<ops>
@@ -23,9 +23,9 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
</body-rows>
<rules>
- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `PATH#TAG` header; use the bare LINE numbers.
- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `[PATH#TAG]` header; use the bare LINE numbers.
- Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
- Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the `PATH#TAG` and lines from the edit response (or re-`read`), never on pre-edit numbers.
- Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the `[PATH#TAG]` and lines from the edit response (or re-`read`), never on pre-edit numbers.
- A line number is an offset, not a structural boundary: never `insert after N` into a construct you have not read, and never start or end a `replace`/`delete` range mid-expression or mid-block. If unsure what is on those lines, `read` them first.
- On a stale-tag rejection — or any result you cannot fully account for — STOP and re-`read`. Never stack more line-numbered edits onto output you have not re-grounded; that compounds corruption.
- One hunk per range; the body is the final content, never an old/new pair.
@@ -37,7 +37,7 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
<example>
Original (the exact shape `read` returns):
```
greet.py#A1B2
[greet.py#A1B2]
1:def greet(name):
2: msg = "Hello, " + name
3: print(msg)
@@ -46,14 +46,14 @@ Original (the exact shape `read` returns):
Insert a guard after line 1:
```
greet.py#A1B2
[greet.py#A1B2]
insert after 1:
+ if not name: name = "stranger"
```
Replace line 2 with two lines:
```
greet.py#A1B2
[greet.py#A1B2]
replace 2..2:
+ greeting = "Hi"
+ msg = f"{greeting}, {name}"
@@ -61,13 +61,13 @@ replace 2..2:
Delete line 3:
```
greet.py#A1B2
[greet.py#A1B2]
delete 3
```
Add a header and trailer:
```
greet.py#A1B2
[greet.py#A1B2]
insert head:
+# generated header
insert tail:
@@ -76,7 +76,7 @@ insert tail:
Replace the whole `greet` function block — `replace block 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
```
greet.py#A1B2
[greet.py#A1B2]
replace block 1:
+def greet(name):
+ print(f"Hello, {name}")
+15 -10
View File
@@ -3,7 +3,7 @@
*
* Format shape:
* ```
* path/to/file.ts#0A3
* [path/to/file.ts#1A2B]
* replace 5..7:
* +literal new line
* ```
@@ -15,6 +15,7 @@ import {
HL_FILE_HASH_LENGTH,
HL_FILE_HASH_SEP,
HL_FILE_PREFIX,
HL_FILE_SUFFIX,
HL_HEADER_COLON,
HL_INSERT_AFTER,
HL_INSERT_BEFORE,
@@ -45,6 +46,7 @@ const CHAR_LOWER_F = 102;
const CHAR_PAYLOAD_REPLACE = HL_PAYLOAD_REPLACE.charCodeAt(0);
const CHAR_COLON = HL_HEADER_COLON.charCodeAt(0);
const FILE_PREFIX_LENGTH = HL_FILE_PREFIX.length;
const FILE_SUFFIX_LENGTH = HL_FILE_SUFFIX.length;
function isDigitCode(code: number): boolean {
return code >= CHAR_ZERO && code <= CHAR_NINE;
@@ -137,7 +139,7 @@ export function parseLid(raw: string, lineNum: number): Anchor {
if (number === null || skipWhitespace(raw, number.nextIndex, end) !== end) {
throw new Error(
`line ${lineNum}: expected a line number such as ${describeAnchorExamples("119")}; ` +
`got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash from your latest read for file-version binding.`,
`got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash${HL_FILE_SUFFIX} from your latest read for file-version binding.`,
);
}
return { line: number.line };
@@ -312,17 +314,20 @@ function tryParseHunkHeader(line: string): ParsedHunkHeader | null {
function tryParseHeader(line: string): { path: string; fileHash?: string } | null {
if (!line.startsWith(HL_FILE_PREFIX)) return null;
const end = trimEndIndex(line);
if (FILE_PREFIX_LENGTH >= end) return null;
if (FILE_PREFIX_LENGTH + FILE_SUFFIX_LENGTH >= end) return null;
if (!line.endsWith(HL_FILE_SUFFIX, end)) return null;
const bodyEnd = end - FILE_SUFFIX_LENGTH;
if (FILE_PREFIX_LENGTH >= bodyEnd) return null;
// The snapshot tag, when present, is the trailing `#XXXX` block. We
// detect it from the suffix so the path may legitimately contain
// whitespace (e.g. `OneDrive - Company/file.ts`).
let pathEnd = end;
// The snapshot tag, when present, is the trailing `#XXXX` block inside the
// bracketed header. We detect it from the suffix so the path may
// legitimately contain whitespace (e.g. `OneDrive - Company/file.ts`).
let pathEnd = bodyEnd;
let fileHash: string | undefined;
const trailingHashStart = end - HL_FILE_HASH_LENGTH - 1;
const trailingHashStart = bodyEnd - HL_FILE_HASH_LENGTH - 1;
if (trailingHashStart >= FILE_PREFIX_LENGTH && line.charCodeAt(trailingHashStart) === CHAR_HASH) {
let allHex = true;
for (let probe = trailingHashStart + 1; probe < end; probe++) {
for (let probe = trailingHashStart + 1; probe < bodyEnd; probe++) {
if (!isHexDigitCode(line.charCodeAt(probe))) {
allHex = false;
break;
@@ -330,7 +335,7 @@ function tryParseHeader(line: string): { path: string; fileHash?: string } | nul
}
if (allHex) {
pathEnd = trailingHashStart;
fileHash = line.slice(trailingHashStart + 1, end).toUpperCase();
fileHash = line.slice(trailingHashStart + 1, bodyEnd).toUpperCase();
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ export interface SplitOptions {
/** Resolves absolute paths inside hashline headers to cwd-relative form. */
cwd?: string;
/**
* Fallback path used when the input lacks a `PATH` header but contains
* Fallback path used when the input lacks a `[PATH]` header but contains
* recognizable hashline operations. Lets streaming previews work before
* the model has written the header.
*/
+11 -11
View File
@@ -91,8 +91,8 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => {
const text = "function x() {\n if (y) {\n }\n}\n";
it("applyTo resolves a block edit and matches the equivalent `replace`", () => {
const blockSection = Patch.parseSingle(`${PATH}#1A2B\nreplace block 2:\n+ if (y || z) {\n+ }`);
const replaceSection = Patch.parseSingle(`${PATH}#1A2B\nreplace 2..3:\n+ if (y || z) {\n+ }`);
const blockSection = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+ if (y || z) {\n+ }`);
const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nreplace 2..3:\n+ if (y || z) {\n+ }`);
const blockResult = blockSection.applyTo(text, stubResolver);
const replaceResult = replaceSection.applyTo(text);
@@ -102,12 +102,12 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => {
});
it("applyTo throws when a block edit has no resolver", () => {
const section = Patch.parseSingle(`${PATH}#1A2B\nreplace block 2:\n+X`);
const section = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+X`);
expect(() => section.applyTo(text)).toThrow("replace block");
});
it("applyPartialTo drops an unresolvable block edit instead of throwing", () => {
const section = Patch.parseSingle(`${PATH}#1A2B\nreplace block 2:\n+X`);
const section = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+X`);
// No resolver → drop. The lone block edit vanishes, so the text is unchanged.
const result = section.applyPartialTo(text);
expect(result.text).toBe(text);
@@ -123,7 +123,7 @@ describe("Patcher with a block resolver", () => {
const tag = snapshots.record(PATH, text);
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver });
const result = await patcher.apply(Patch.parse(`${PATH}#${tag}\nreplace block 2:\n+ if (y || z) {\n+ }`));
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+ if (y || z) {\n+ }`));
expect(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("function x() {\n if (y || z) {\n }\n}\n");
@@ -140,7 +140,7 @@ describe("Patcher with a block resolver", () => {
// `block 2` resolves against the SNAPSHOT → span [2,3] → replace
// "line1","line2"; recovery 3-way-merges the change onto the live file.
const result = await patcher.apply(Patch.parse(`${PATH}#${tag}\nreplace block 2:\n+NEW`));
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+NEW`));
expect(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("line0\nNEW\nline3\nline4\nline5\n");
@@ -155,7 +155,7 @@ describe("Patcher with a block resolver", () => {
const bogus = live === "FFFF" ? "0000" : "FFFF";
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver });
await expect(patcher.apply(Patch.parse(`${PATH}#${bogus}\nreplace block 2:\n+NEW`))).rejects.toBeInstanceOf(
await expect(patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nreplace block 2:\n+NEW`))).rejects.toBeInstanceOf(
MismatchError,
);
expect(fs.get(PATH)).toBe(liveText);
@@ -167,7 +167,7 @@ describe("Patcher with a block resolver", () => {
const tag = snapshots.record(PATH, text);
const patcher = new Patcher({ fs, snapshots, blockResolver: () => null });
await expect(patcher.apply(Patch.parse(`${PATH}#${tag}\nreplace block 2:\n+X`))).rejects.toThrow(
await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+X`))).rejects.toThrow(
"could not resolve a syntactic block",
);
expect(fs.get(PATH)).toBe(text);
@@ -201,13 +201,13 @@ describe("delete block", () => {
});
it("applyTo deletes the resolved block span", () => {
const section = Patch.parseSingle(`${PATH}#1A2B\ndelete block 2`);
const section = Patch.parseSingle(`[${PATH}#1A2B]\ndelete block 2`);
// stub span [2,3] → drop " if (y) {" and " }".
expect(section.applyTo(text, stubResolver).text).toBe("function x() {\n}\n");
});
it("applyPartialTo drops an unresolvable delete-block edit instead of throwing", () => {
const section = Patch.parseSingle(`${PATH}#1A2B\ndelete block 2`);
const section = Patch.parseSingle(`[${PATH}#1A2B]\ndelete block 2`);
expect(section.applyPartialTo(text).text).toBe(text);
});
@@ -217,7 +217,7 @@ describe("delete block", () => {
const tag = snapshots.record(PATH, text);
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver });
const result = await patcher.apply(Patch.parse(`${PATH}#${tag}\ndelete block 2`));
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\ndelete block 2`));
expect(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("function x() {\n}\n");
+22 -10
View File
@@ -9,7 +9,7 @@ const FILE = "a\nb\nc\nd\ne";
describe("hashline section headers", () => {
it("accepts paths with spaces in anchored section headers", () => {
const section = Patch.parseSingle("dir with spaces/file.ts#1a2b\nreplace 1..1:\n+after");
const section = Patch.parseSingle("[dir with spaces/file.ts#1a2b]\nreplace 1..1:\n+after");
expect(section.path).toBe("dir with spaces/file.ts");
expect(section.fileHash).toBe("1A2B");
@@ -17,7 +17,7 @@ describe("hashline section headers", () => {
});
it("recovers apply_patch-contaminated headers whose paths contain spaces", () => {
const section = Patch.parseSingle("*** Update File: dir with spaces/file.ts#1A2B\nreplace 1..1:\n+after");
const section = Patch.parseSingle("[*** Update File: dir with spaces/file.ts#1A2B]\nreplace 1..1:\n+after");
expect(section.path).toBe("dir with spaces/file.ts");
expect(section.fileHash).toBe("1A2B");
@@ -25,29 +25,41 @@ describe("hashline section headers", () => {
});
it("rejects trailing junk after a snapshot tag", () => {
expect(() => Patch.parse("src/a.ts#1A2B copied from read\nreplace 1..1:\n+after")).toThrow(
expect(() => Patch.parse("[src/a.ts#1A2B copied from read]\nreplace 1..1:\n+after")).toThrow(
/Input header must be/,
);
expect(() => Patch.parse("src/a.ts#1A2B:812\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
expect(() => Patch.parse("[src/a.ts#1A2B:812]\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
});
it("rejects trailing junk after a snapshot tag even with apply_patch noise", () => {
expect(() => Patch.parse("Update File: src/a.ts#1A2B copied from read\nreplace 1..1:\n+after")).toThrow(
expect(() => Patch.parse("[Update File: src/a.ts#1A2B copied from read]\nreplace 1..1:\n+after")).toThrow(
/Input header must be/,
);
expect(() => Patch.parse("Update File: src/a.ts#1A2B:812\nreplace 1..1:\n+after")).toThrow(
expect(() => Patch.parse("[Update File: src/a.ts#1A2B:812]\nreplace 1..1:\n+after")).toThrow(
/Input header must be/,
);
});
it("rejects malformed snapshot tags", () => {
expect(() => Patch.parse("src/a.ts#1A2\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
expect(() => Patch.parse("src/a.ts#1A2G\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
expect(() => Patch.parse("src/a.ts#1A2B5\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
expect(() => Patch.parse("[src/a.ts#1A2]\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
expect(() => Patch.parse("[src/a.ts#1A2G]\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
expect(() => Patch.parse("[src/a.ts#1A2B5]\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
});
it("rejects malformed snapshot tags even with apply_patch noise", () => {
expect(() => Patch.parse("Update File: src/a.ts#1A2G\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
expect(() => Patch.parse("[Update File: src/a.ts#1A2G]\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
});
it("reports bracket syntax with a 4-hex example when the header is missing", () => {
try {
Patch.parse("delete 38..40");
throw new Error("expected missing-header error");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
expect(message).toContain('input must begin with "[PATH#HASH]"');
expect(message).toContain('Example: "[src/foo.ts#1A2B]"');
expect(message).not.toContain("#0A3");
}
});
});
+10 -10
View File
@@ -25,7 +25,7 @@ describe("Patcher snapshot tag integrity", () => {
const tag = snapshots.record(PATH, "before\n");
const patcher = new Patcher({ fs, snapshots });
const result = await patcher.apply(Patch.parse(`${PATH}#${tag}\nreplace 1..1:\n+after`));
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 1..1:\n+after`));
expect(result.sections[0]?.op).toBe("update");
expect(result.sections[0]?.fileHash).toMatch(/^[0-9A-F]{4}$/);
@@ -45,14 +45,14 @@ describe("Patcher snapshot tag integrity", () => {
expect(snapshots.byHash(PATH, tag)).toBeNull();
const patcher = new Patcher({ fs, snapshots });
const result = await patcher.apply(Patch.parse(`${PATH}#${tag}\nreplace 3..3:\n+L3`));
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 3..3:\n+L3`));
expect(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("l1\nl2\nL3\nl4\nl5\n");
});
it("normalizes lowercase section tags while parsing", () => {
const section = Patch.parseSingle(`${PATH}#1a2b\nreplace 1..1:\n+after`);
const section = Patch.parseSingle(`[${PATH}#1a2b]\nreplace 1..1:\n+after`);
expect(section.fileHash).toBe("1A2B");
});
@@ -65,7 +65,7 @@ describe("Patcher snapshot tag integrity", () => {
const patcher = new Patcher({ fs, snapshots });
try {
await patcher.apply(Patch.parse(`${PATH}#${tag}\nreplace 1..1:\n+after`));
await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 1..1:\n+after`));
throw new Error("expected MismatchError");
} catch (error) {
expect(error).toBeInstanceOf(MismatchError);
@@ -89,7 +89,7 @@ describe("Patcher snapshot tag integrity", () => {
const bogus = live === "FFFF" ? "0000" : "FFFF";
try {
await patcher.apply(Patch.parse(`${PATH}#${bogus}\nreplace 1..1:\n+after`));
await patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nreplace 1..1:\n+after`));
throw new Error("expected MismatchError");
} catch (error) {
expect(error).toBeInstanceOf(MismatchError);
@@ -109,7 +109,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
const snapshots = new InMemorySnapshotStore();
const patcher = new Patcher({ fs, snapshots });
await expect(patcher.apply(Patch.parse(`${PATH}\ninsert tail:\n+c`))).rejects.toThrow(
await expect(patcher.apply(Patch.parse(`[${PATH}]\ninsert tail:\n+c`))).rejects.toThrow(
/Missing hashline snapshot tag.*use the write tool/s,
);
expect(fs.get(PATH)).toBe("a\nb\n");
@@ -120,7 +120,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
const snapshots = new InMemorySnapshotStore();
const patcher = new Patcher({ fs, snapshots });
await expect(patcher.apply(Patch.parse(`${PATH}\nreplace 1..1:\n+X`))).rejects.toThrow(
await expect(patcher.apply(Patch.parse(`[${PATH}]\nreplace 1..1:\n+X`))).rejects.toThrow(
/Missing hashline snapshot tag/,
);
});
@@ -130,7 +130,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
const snapshots = new InMemorySnapshotStore();
const patcher = new Patcher({ fs, snapshots });
await expect(patcher.apply(Patch.parse(`ghost.ts#1A2B\ninsert tail:\n+c`))).rejects.toThrow(
await expect(patcher.apply(Patch.parse(`[ghost.ts#1A2B]\ninsert tail:\n+c`))).rejects.toThrow(
/File not found.*use the write tool/is,
);
});
@@ -143,7 +143,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
const stale = live === "0000" ? "FFFF" : "0000";
const patcher = new Patcher({ fs, snapshots });
const result = await patcher.apply(Patch.parse(`${PATH}#${stale}\ninsert tail:\n+c`));
const result = await patcher.apply(Patch.parse(`[${PATH}#${stale}]\ninsert tail:\n+c`));
const section = result.sections[0];
expect(section?.op).toBe("update");
@@ -158,7 +158,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
const tag = snapshots.record(PATH, content);
const patcher = new Patcher({ fs, snapshots });
const result = await patcher.apply(Patch.parse(`${PATH}#${tag}\ninsert tail:\n+c`));
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\ninsert tail:\n+c`));
const section = result.sections[0];
expect(section?.op).toBe("update");