diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a97189a8b6..455fc2f075 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -251,7 +251,12 @@ async function executeLegacy(params: LegacyEditParams, ctx: Tool.Context) { } } -async function executeHashline(params: HashlineEditParams, ctx: Tool.Context, autocorrect: boolean) { +async function executeHashline( + params: HashlineEditParams, + ctx: Tool.Context, + autocorrect: boolean, + aggressiveAutocorrect: boolean, +) { const sourcePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) const targetPath = params.rename ? path.isAbsolute(params.rename) @@ -345,6 +350,7 @@ async function executeHashline(params: HashlineEditParams, ctx: Tool.Context, au trailing: parsed.trailing, edits: params.edits, autocorrect, + aggressiveAutocorrect, }) const output = serializeHashlineContent({ lines: next.lines, @@ -484,6 +490,7 @@ export const EditTool = Tool.define("edit", { hashlineParams, ctx, config.experimental?.hashline_autocorrect !== false || Bun.env.OPENCODE_HL_AUTOCORRECT === "1", + Bun.env.OPENCODE_HL_AUTOCORRECT === "1", ) }, }) diff --git a/packages/opencode/src/tool/edit.txt b/packages/opencode/src/tool/edit.txt index eaee266337..4b729cfc83 100644 --- a/packages/opencode/src/tool/edit.txt +++ b/packages/opencode/src/tool/edit.txt @@ -18,6 +18,7 @@ Hashline schema (default behavior): - Use strict anchor references from `Read` output: `LINE#ID`. - Hashline mode can be turned off with `experimental.hashline_edit: false`. - Autocorrect cleanup is on by default and can be turned off with `experimental.hashline_autocorrect: false`. +- Default autocorrect only strips copied `LINE#ID:`/`>>>` prefixes; set `OPENCODE_HL_AUTOCORRECT=1` to opt into heavier cleanup heuristics. - When `Read` returns `LINE#ID:`, prefer hashline operations. - Operations: - `set_line { line, text }` @@ -28,4 +29,5 @@ Hashline schema (default behavior): - `append { text }` - `prepend { text }` - `replace { old_text, new_text, all? }` -- In hashline mode, provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and must be retried with updated references. +- In hashline mode, provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and should be retried with the returned `retry with` anchors. +- Fallback guidance: GPT-family models can use `apply_patch` as fallback; non-GPT models should fallback to legacy `oldString/newString` payloads. diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 82e7ac1667..9fb59ab8fd 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -9,6 +9,8 @@ import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" import path from "path" import { assertExternalDirectory } from "./external-directory" +import { Config } from "../config/config" +import { hashlineRef } from "./hashline" const MAX_LINE_LENGTH = 2000 @@ -116,6 +118,7 @@ export const GrepTool = Tool.define("grep", { } const totalMatches = matches.length + const useHashline = (await Config.get()).experimental?.hashline_edit !== false const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`] let currentFile = "" @@ -129,7 +132,11 @@ export const GrepTool = Tool.define("grep", { } const truncatedLineText = match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText - outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) + if (useHashline) { + outputLines.push(` ${hashlineRef(match.lineNum, match.lineText)}:${truncatedLineText}`) + } else { + outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) + } } if (truncated) { diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt index adf583695a..0874117f15 100644 --- a/packages/opencode/src/tool/grep.txt +++ b/packages/opencode/src/tool/grep.txt @@ -2,7 +2,8 @@ - Searches file contents using regular expressions - Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) - Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") -- Returns file paths and line numbers with at least one match sorted by modification time +- Returns file paths with matching lines sorted by modification time +- Output format follows edit mode: `Line N:` when hashline mode is disabled, `N#ID:` when hashline mode is enabled - Use this tool when you need to find files containing specific patterns - If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead diff --git a/packages/opencode/src/tool/hashline.ts b/packages/opencode/src/tool/hashline.ts index ecc8e9b578..ef4606740b 100644 --- a/packages/opencode/src/tool/hashline.ts +++ b/packages/opencode/src/tool/hashline.ts @@ -8,6 +8,7 @@ export const HASHLINE_ALPHABET = "ZPMQVRWSNKTXJBYH" const HASHLINE_ID_LENGTH = 2 const HASHLINE_ID_REGEX = new RegExp(`^[${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}}$`) const HASHLINE_REF_REGEX = new RegExp(`(\\d+)#([${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}})(?=$|\\s|:)`) +const LOW_SIGNAL_CONTENT_RE = /^[^a-zA-Z0-9]+$/ type TextValue = string | string[] @@ -75,12 +76,18 @@ export const HashlineEdit = z.discriminatedUnion("type", [ export type HashlineEdit = z.infer +function isLowSignalContent(normalized: string) { + if (normalized.length === 0) return true + if (normalized.length <= 2) return true + return LOW_SIGNAL_CONTENT_RE.test(normalized) +} + export function hashlineID(lineNumber: number, line: string): string { let normalized = line if (normalized.endsWith("\r")) normalized = normalized.slice(0, -1) normalized = normalized.replace(/\s+/g, "") - void lineNumber - const hash = Bun.hash.xxHash32(normalized) & 0xff + const seed = isLowSignalContent(normalized) ? `${normalized}:${lineNumber}` : normalized + const hash = Bun.hash.xxHash32(seed) & 0xff const high = (hash >>> 4) & 0x0f const low = hash & 0x0f return `${HASHLINE_ALPHABET[high]}${HASHLINE_ALPHABET[low]}` @@ -123,29 +130,29 @@ function toLines(text: TextValue) { } const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[ZPMQVRWSNKTXJBYH]{2}:/ -const DIFF_PLUS_RE = /^[+-](?![+-])/ +const WRAPPER_PREFIX_RE = /^\s*(?:>>>|>>)\s?/ + +function stripByMajority(lines: string[], test: (line: string) => boolean, rewrite: (line: string) => string) { + const nonEmpty = lines.filter((line) => line.length > 0) + if (nonEmpty.length === 0) return lines + + const matches = nonEmpty.filter(test).length + if (matches === 0 || matches < nonEmpty.length * 0.5) return lines + + return lines.map(rewrite) +} function stripNewLinePrefixes(lines: string[]) { - let hashPrefixCount = 0 - let diffPlusCount = 0 - let nonEmpty = 0 - for (const line of lines) { - if (line.length === 0) continue - nonEmpty++ - if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount++ - if (DIFF_PLUS_RE.test(line)) diffPlusCount++ - } - if (nonEmpty === 0) return lines - - const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5 - const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5 - if (!stripHash && !stripPlus) return lines - - return lines.map((line) => { - if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "") - if (stripPlus) return line.replace(DIFF_PLUS_RE, "") - return line - }) + const stripped = stripByMajority( + lines, + (line) => HASHLINE_PREFIX_RE.test(line), + (line) => line.replace(HASHLINE_PREFIX_RE, ""), + ) + return stripByMajority( + stripped, + (line) => WRAPPER_PREFIX_RE.test(line), + (line) => line.replace(WRAPPER_PREFIX_RE, ""), + ) } function equalsIgnoringWhitespace(a: string, b: string) { @@ -306,35 +313,51 @@ function mismatchContext(lines: string[], line: number) { .join("\n") } +function mismatchSummary(lines: string[], mismatch: { expected: string; line: number }) { + if (mismatch.line < 1 || mismatch.line > lines.length) { + return `- expected ${mismatch.expected} -> line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})` + } + return `- expected ${mismatch.expected} -> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}` +} + function throwMismatch(lines: string[], mismatches: Array<{ expected: string; line: number }>) { const seen = new Set() - const unique = mismatches.filter((m) => { - const key = `${m.expected}:${m.line}` + const unique = mismatches.filter((mismatch) => { + const key = `${mismatch.expected}:${mismatch.line}` if (seen.has(key)) return false seen.add(key) return true }) - const body = unique - .map((m) => { - if (m.line < 1 || m.line > lines.length) { + const preview = unique.slice(0, 2).map((mismatch) => mismatchSummary(lines, mismatch)) + const hidden = unique.length - preview.length + const count = unique.length + const linesOut = [ + `Hashline edit rejected: ${count} anchor mismatch${count === 1 ? "" : "es"}. Re-read the file and retry with the updated anchors below.`, + ...preview, + ...(hidden > 0 ? [`- ... and ${hidden} more mismatches`] : []), + ] + + if (Bun.env.OPENCODE_HL_MISMATCH_DEBUG === "1") { + const body = unique + .map((mismatch) => { + if (mismatch.line < 1 || mismatch.line > lines.length) { + return [ + `>>> expected ${mismatch.expected}`, + `>>> current line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`, + ].join("\n") + } return [ - `>>> expected ${m.expected}`, - `>>> current line ${m.line} is out of range (1-${Math.max(lines.length, 1)})`, + `>>> expected ${mismatch.expected}`, + mismatchContext(lines, mismatch.line), + `>>> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`, ].join("\n") - } + }) + .join("\n\n") + linesOut.push("", body) + } - const current = hashlineRef(m.line, lines[m.line - 1]) - return [`>>> expected ${m.expected}`, mismatchContext(lines, m.line), `>>> retry with ${current}`].join("\n") - }) - .join("\n\n") - - throw new Error( - [ - "Hashline edit rejected: file changed since last read. Re-read the file and retry with updated LINE#ID anchors.", - body, - ].join("\n\n"), - ) + throw new Error(linesOut.join("\n")) } function validateAnchors(lines: string[], refs: Array<{ raw: string; line: number; id: string }>) { @@ -412,6 +435,7 @@ export function applyHashlineEdits(input: { trailing: boolean edits: HashlineEdit[] autocorrect?: boolean + aggressiveAutocorrect?: boolean }) { const lines = [...input.lines] const originalLines = [...input.lines] @@ -420,6 +444,7 @@ export function applyHashlineEdits(input: { const replaceOps: Array> = [] const ops: Splice[] = [] const autocorrect = input.autocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1" + const aggressiveAutocorrect = input.aggressiveAutocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1" const parseText = (text: TextValue) => { const next = toLines(text) if (!autocorrect) return next @@ -572,7 +597,7 @@ export function applyHashlineEdits(input: { } let text = op.text - if (autocorrect) { + if (autocorrect && aggressiveAutocorrect) { if (op.kind === "set_line" || op.kind === "replace_lines") { const start = op.startLine ?? op.start + 1 const end = op.endLine ?? start + op.del - 1 diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a20c72da4e..176d329614 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -137,6 +137,9 @@ export namespace ToolRegistry { ) { const config = await Config.get() const tools = await all() + const hashline = config.experimental?.hashline_edit !== false + const usePatch = + model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") const result = await Promise.all( tools .filter((t) => { @@ -145,14 +148,12 @@ export namespace ToolRegistry { return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA } - if (config.experimental?.hashline_edit !== false) { - if (t.id === "apply_patch") return false + if (hashline) { + if (t.id === "apply_patch") return usePatch return true } // use apply tool in same format as codex - const usePatch = - model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") if (t.id === "apply_patch") return usePatch if (t.id === "edit" || t.id === "write") return !usePatch diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index e774580df6..0070a7a5e2 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -37,6 +37,62 @@ describe("tool.grep", () => { }) }) + test("hashline disabled keeps Line N format", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + hashline_edit: false, + }, + }, + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const grep = await GrepTool.init() + const result = await grep.execute( + { + pattern: "alpha", + path: tmp.path, + }, + ctx, + ) + expect(result.output).toContain("Line 1: alpha") + }, + }) + }) + + test("hashline enabled emits N#ID anchor format", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + hashline_edit: true, + }, + }, + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const grep = await GrepTool.init() + const result = await grep.execute( + { + pattern: "alpha", + path: tmp.path, + }, + ctx, + ) + expect(result.output).toMatch(/\b1#[ZPMQVRWSNKTXJBYH]{2}:alpha\b/) + }, + }) + }) + test("no matches returns correct output", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/tool/hashline.test.ts b/packages/opencode/test/tool/hashline.test.ts index cb2e71a2cd..7a2c358292 100644 --- a/packages/opencode/test/tool/hashline.test.ts +++ b/packages/opencode/test/tool/hashline.test.ts @@ -7,6 +7,15 @@ function swapID(ref: string) { return `${line}#${next}` } +function errorMessage(run: () => void) { + try { + run() + return "" + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +} + describe("tool.hashline", () => { test("hash computation is stable and 2-char alphabet encoded", () => { const a = hashlineID(1, " const x = 1") @@ -17,6 +26,15 @@ describe("tool.hashline", () => { expect(a).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/) }) + test("low-signal lines mix line index into hash id", () => { + const a = hashlineID(1, "") + const b = hashlineID(2, "") + const c = hashlineID(1, "{}") + const d = hashlineID(2, "{}") + expect(a).not.toBe(b) + expect(c).not.toBe(d) + }) + test("autocorrect strips copied hashline prefixes when enabled", () => { const old = Bun.env.OPENCODE_HL_AUTOCORRECT Bun.env.OPENCODE_HL_AUTOCORRECT = "1" @@ -39,6 +57,23 @@ describe("tool.hashline", () => { } }) + test("default autocorrect does not rewrite non-prefix content", () => { + const result = applyHashlineEdits({ + lines: ["a"], + trailing: false, + edits: [ + { + type: "set_line", + line: hashlineRef(1, "a"), + text: "+a", + }, + ], + autocorrect: true, + aggressiveAutocorrect: false, + }) + expect(result.lines).toEqual(["+a"]) + }) + test("parses strict LINE#ID references with tolerant extraction", () => { const ref = parseHashlineRef(">>> 12#ZP:const value = 1", "line") expect(ref.line).toBe(12) @@ -48,11 +83,11 @@ describe("tool.hashline", () => { expect(() => parseHashlineRef("12#ab", "line")).toThrow("LINE#ID") }) - test("aggregates mismatch errors with >>> context and retry refs", () => { + test("reports compact mismatch errors with retry anchors", () => { const lines = ["alpha", "beta", "gamma"] const wrong = swapID(hashlineRef(2, lines[1])) - expect(() => + const message = errorMessage(() => applyHashlineEdits({ lines, trailing: false, @@ -64,21 +99,12 @@ describe("tool.hashline", () => { }, ], }), - ).toThrow("changed since last read") + ) - expect(() => - applyHashlineEdits({ - lines, - trailing: false, - edits: [ - { - type: "set_line", - line: wrong, - text: "BETA", - }, - ], - }), - ).toThrow(">>> retry with") + expect(message).toContain("anchor mismatch") + expect(message).toContain("retry with") + expect(message).not.toContain(">>>") + expect(message.length).toBeLessThan(260) }) test("applies batched line edits bottom-up for stable results", () => { diff --git a/packages/opencode/test/tool/registry-hashline.test.ts b/packages/opencode/test/tool/registry-hashline.test.ts index bd620702f8..d27d1e8ddf 100644 --- a/packages/opencode/test/tool/registry-hashline.test.ts +++ b/packages/opencode/test/tool/registry-hashline.test.ts @@ -4,16 +4,46 @@ import { Instance } from "../../src/project/instance" import { ToolRegistry } from "../../src/tool/registry" describe("tool.registry hashline routing", () => { - test.each([ - { providerID: "openai", modelID: "gpt-5" }, - { providerID: "anthropic", modelID: "claude-3-7-sonnet" }, - ])("disables apply_patch and enables edit by default (%o)", async (model) => { - await using tmp = await tmpdir() + test("hashline mode keeps edit and apply_patch for GPT models", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + hashline_edit: true, + }, + }, + }) await Instance.provide({ directory: tmp.path, fn: async () => { - const tools = await ToolRegistry.tools(model) + const tools = await ToolRegistry.tools({ + providerID: "openai", + modelID: "gpt-5", + }) + const ids = tools.map((tool) => tool.id) + expect(ids).toContain("edit") + expect(ids).toContain("write") + expect(ids).toContain("apply_patch") + }, + }) + }) + + test("hashline mode keeps edit and removes apply_patch for non-GPT models", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + hashline_edit: true, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tools = await ToolRegistry.tools({ + providerID: "anthropic", + modelID: "claude-3-7-sonnet", + }) const ids = tools.map((tool) => tool.id) expect(ids).toContain("edit") expect(ids).toContain("write")