mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 14:55:19 +00:00
stabilize hashline routing and anchors
This commit is contained in:
@@ -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",
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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:<content>`, 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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:<content>` 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
|
||||
|
||||
@@ -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<typeof HashlineEdit>
|
||||
|
||||
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<string>()
|
||||
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<Extract<HashlineEdit, { type: "replace" }>> = []
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user