feat(hashline-edit): add autocorrect, BOM/CRLF normalization, and file creation support

Implements key features from oh-my-pi to improve agent editing success rates:

- Autocorrect v1: single-line merge expansion, wrapped line restoration,
  paired indent restoration (autocorrect-replacement-lines.ts)
- BOM/CRLF normalization: canonicalize on read, restore on write
  (file-text-canonicalization.ts)
- Pre-validate all hashes before mutation (edit-ordering.ts)
- File creation via append/prepend operations (new types + executor logic)
- Modular refactoring: split edit-operations.ts into focused modules
  (primitives, ordering, deduplication, diff, executor)
- Enhanced tool description with operation choice guide and recovery hints

All 50 tests pass. TypeScript clean. Build successful.
This commit is contained in:
YeonGyu-Kim
2026-02-22 14:13:59 +09:00
parent e84fce3121
commit 5d1d87cc10
14 changed files with 774 additions and 341 deletions

View File

@@ -0,0 +1,106 @@
function normalizeTokens(text: string): string {
return text.replace(/\s+/g, "")
}
function leadingWhitespace(text: string): string {
const match = text.match(/^\s*/)
return match ? match[0] : ""
}
export function restoreOldWrappedLines(originalLines: string[], replacementLines: string[]): string[] {
if (replacementLines.length <= 1) return replacementLines
if (originalLines.length !== replacementLines.length) return replacementLines
const original = normalizeTokens(originalLines.join("\n"))
const replacement = normalizeTokens(replacementLines.join("\n"))
if (original !== replacement) return replacementLines
return originalLines
}
export function maybeExpandSingleLineMerge(
originalLines: string[],
replacementLines: string[]
): string[] {
if (replacementLines.length !== 1 || originalLines.length <= 1) {
return replacementLines
}
const merged = replacementLines[0]
const parts = originalLines.map((line) => line.trim()).filter((line) => line.length > 0)
if (parts.length !== originalLines.length) return replacementLines
const indices: number[] = []
let offset = 0
let orderedMatch = true
for (const part of parts) {
const idx = merged.indexOf(part, offset)
if (idx === -1) {
orderedMatch = false
break
}
indices.push(idx)
offset = idx + part.length
}
const expanded: string[] = []
if (orderedMatch) {
for (let i = 0; i < indices.length; i += 1) {
const start = indices[i]
const end = i + 1 < indices.length ? indices[i + 1] : merged.length
const candidate = merged.slice(start, end).trim()
if (candidate.length === 0) {
orderedMatch = false
break
}
expanded.push(candidate)
}
}
if (orderedMatch && expanded.length === originalLines.length) {
return expanded
}
const semicolonSplit = merged
.split(/;\s+/)
.map((line, idx, arr) => {
if (idx < arr.length - 1 && !line.endsWith(";")) {
return `${line};`
}
return line
})
.map((line) => line.trim())
.filter((line) => line.length > 0)
if (semicolonSplit.length === originalLines.length) {
return semicolonSplit
}
return replacementLines
}
export function restoreIndentForPairedReplacement(
originalLines: string[],
replacementLines: string[]
): string[] {
if (originalLines.length !== replacementLines.length) {
return replacementLines
}
return replacementLines.map((line, idx) => {
if (line.length === 0) return line
if (leadingWhitespace(line).length > 0) return line
const indent = leadingWhitespace(originalLines[idx])
if (indent.length === 0) return line
return `${indent}${line}`
})
}
export function autocorrectReplacementLines(
originalLines: string[],
replacementLines: string[]
): string[] {
let next = replacementLines
next = maybeExpandSingleLineMerge(originalLines, next)
next = restoreOldWrappedLines(originalLines, next)
next = restoreIndentForPairedReplacement(originalLines, next)
return next
}

View File

@@ -0,0 +1,47 @@
import type { HashlineEdit } from "./types"
import { toNewLines } from "./edit-text-normalization"
function normalizeEditPayload(payload: string | string[]): string {
return toNewLines(payload).join("\n")
}
function buildDedupeKey(edit: HashlineEdit): string {
switch (edit.type) {
case "set_line":
return `set_line|${edit.line}|${normalizeEditPayload(edit.text)}`
case "replace_lines":
return `replace_lines|${edit.start_line}|${edit.end_line}|${normalizeEditPayload(edit.text)}`
case "insert_after":
return `insert_after|${edit.line}|${normalizeEditPayload(edit.text)}`
case "insert_before":
return `insert_before|${edit.line}|${normalizeEditPayload(edit.text)}`
case "insert_between":
return `insert_between|${edit.after_line}|${edit.before_line}|${normalizeEditPayload(edit.text)}`
case "replace":
return `replace|${edit.old_text}|${normalizeEditPayload(edit.new_text)}`
case "append":
return `append|${normalizeEditPayload(edit.text)}`
case "prepend":
return `prepend|${normalizeEditPayload(edit.text)}`
default:
return JSON.stringify(edit)
}
}
export function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } {
const seen = new Set<string>()
const deduped: HashlineEdit[] = []
let deduplicatedEdits = 0
for (const edit of edits) {
const key = buildDedupeKey(edit)
if (seen.has(key)) {
deduplicatedEdits += 1
continue
}
seen.add(key)
deduped.push(edit)
}
return { edits: deduped, deduplicatedEdits }
}

View File

@@ -0,0 +1,154 @@
import { autocorrectReplacementLines } from "./autocorrect-replacement-lines"
import {
restoreLeadingIndent,
stripInsertAnchorEcho,
stripInsertBeforeEcho,
stripInsertBoundaryEcho,
stripRangeBoundaryEcho,
toNewLines,
} from "./edit-text-normalization"
import { parseLineRef, validateLineRef } from "./validation"
interface EditApplyOptions {
skipValidation?: boolean
}
function shouldValidate(options?: EditApplyOptions): boolean {
return options?.skipValidation !== true
}
export function applySetLine(
lines: string[],
anchor: string,
newText: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const originalLine = lines[line - 1] ?? ""
const corrected = autocorrectReplacementLines([originalLine], toNewLines(newText))
const replacement = corrected.map((entry, idx) => {
if (idx !== 0) return entry
return restoreLeadingIndent(originalLine, entry)
})
result.splice(line - 1, 1, ...replacement)
return result
}
export function applyReplaceLines(
lines: string[],
startAnchor: string,
endAnchor: string,
newText: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) {
validateLineRef(lines, startAnchor)
validateLineRef(lines, endAnchor)
}
const { line: startLine } = parseLineRef(startAnchor)
const { line: endLine } = parseLineRef(endAnchor)
if (startLine > endLine) {
throw new Error(
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
)
}
const result = [...lines]
const originalRange = lines.slice(startLine - 1, endLine)
const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText))
const corrected = autocorrectReplacementLines(originalRange, stripped)
const restored = corrected.map((entry, idx) => {
if (idx !== 0) return entry
return restoreLeadingIndent(lines[startLine - 1], entry)
})
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
return result
}
export function applyInsertAfter(
lines: string[],
anchor: string,
text: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`insert_after requires non-empty text for ${anchor}`)
}
result.splice(line, 0, ...newLines)
return result
}
export function applyInsertBefore(
lines: string[],
anchor: string,
text: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`insert_before requires non-empty text for ${anchor}`)
}
result.splice(line - 1, 0, ...newLines)
return result
}
export function applyInsertBetween(
lines: string[],
afterAnchor: string,
beforeAnchor: string,
text: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) {
validateLineRef(lines, afterAnchor)
validateLineRef(lines, beforeAnchor)
}
const { line: afterLine } = parseLineRef(afterAnchor)
const { line: beforeLine } = parseLineRef(beforeAnchor)
if (beforeLine <= afterLine) {
throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`)
}
const result = [...lines]
const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`)
}
result.splice(beforeLine - 1, 0, ...newLines)
return result
}
export function applyAppend(lines: string[], text: string | string[]): string[] {
const normalized = toNewLines(text)
if (normalized.length === 0) {
throw new Error("append requires non-empty text")
}
return [...lines, ...normalized]
}
export function applyPrepend(lines: string[], text: string | string[]): string[] {
const normalized = toNewLines(text)
if (normalized.length === 0) {
throw new Error("prepend requires non-empty text")
}
return [...normalized, ...lines]
}
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
if (!content.includes(oldText)) {
throw new Error(`Text not found: "${oldText}"`)
}
const replacement = Array.isArray(newText) ? newText.join("\n") : newText
return content.replaceAll(oldText, replacement)
}

View File

@@ -246,6 +246,36 @@ describe("hashline edit operations", () => {
const result = applyReplaceLines(lines, anchorFor(lines, 2), anchorFor(lines, 3), ["return 3", "return 4"])
//#then
expect(result).toEqual(["if (x) {", " return 3", "return 4", "}"])
expect(result).toEqual(["if (x) {", " return 3", " return 4", "}"])
})
it("applies append and prepend operations", () => {
//#given
const content = "line 1\nline 2"
//#when
const result = applyHashlineEdits(content, [
{ type: "append", text: ["line 3"] },
{ type: "prepend", text: ["line 0"] },
])
//#then
expect(result).toEqual("line 0\nline 1\nline 2\nline 3")
})
it("autocorrects single-line merged replacement into original line count", () => {
//#given
const lines = ["const a = 1;", "const b = 2;"]
//#when
const result = applyReplaceLines(
lines,
anchorFor(lines, 1),
anchorFor(lines, 2),
"const a = 10; const b = 20;"
)
//#then
expect(result).toEqual(["const a = 10;", "const b = 20;"])
})
})

View File

@@ -1,13 +1,17 @@
import { parseLineRef, validateLineRef, validateLineRefs } from "./validation"
import { dedupeEdits } from "./edit-deduplication"
import { collectLineRefs, getEditLineNumber } from "./edit-ordering"
import type { HashlineEdit } from "./types"
import {
restoreLeadingIndent,
stripInsertAnchorEcho,
stripInsertBeforeEcho,
stripInsertBoundaryEcho,
stripRangeBoundaryEcho,
toNewLines,
} from "./edit-text-normalization"
applyAppend,
applyInsertAfter,
applyInsertBefore,
applyInsertBetween,
applyPrepend,
applyReplace,
applyReplaceLines,
applySetLine,
} from "./edit-operation-primitives"
import { validateLineRefs } from "./validation"
export interface HashlineApplyReport {
content: string
@@ -15,158 +19,6 @@ export interface HashlineApplyReport {
deduplicatedEdits: number
}
export function applySetLine(lines: string[], anchor: string, newText: string | string[]): string[] {
validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const replacement = toNewLines(newText).map((entry, idx) => {
if (idx !== 0) return entry
return restoreLeadingIndent(lines[line - 1], entry)
})
result.splice(line - 1, 1, ...replacement)
return result
}
export function applyReplaceLines(
lines: string[],
startAnchor: string,
endAnchor: string,
newText: string | string[]
): string[] {
validateLineRef(lines, startAnchor)
validateLineRef(lines, endAnchor)
const { line: startLine } = parseLineRef(startAnchor)
const { line: endLine } = parseLineRef(endAnchor)
if (startLine > endLine) {
throw new Error(
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
)
}
const result = [...lines]
const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText))
const restored = stripped.map((entry, idx) => {
if (idx !== 0) return entry
return restoreLeadingIndent(lines[startLine - 1], entry)
})
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
return result
}
export function applyInsertAfter(lines: string[], anchor: string, text: string | string[]): string[] {
validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`insert_after requires non-empty text for ${anchor}`)
}
result.splice(line, 0, ...newLines)
return result
}
export function applyInsertBefore(lines: string[], anchor: string, text: string | string[]): string[] {
validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`insert_before requires non-empty text for ${anchor}`)
}
result.splice(line - 1, 0, ...newLines)
return result
}
export function applyInsertBetween(
lines: string[],
afterAnchor: string,
beforeAnchor: string,
text: string | string[]
): string[] {
validateLineRef(lines, afterAnchor)
validateLineRef(lines, beforeAnchor)
const { line: afterLine } = parseLineRef(afterAnchor)
const { line: beforeLine } = parseLineRef(beforeAnchor)
if (beforeLine <= afterLine) {
throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`)
}
const result = [...lines]
const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`)
}
result.splice(beforeLine - 1, 0, ...newLines)
return result
}
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
if (!content.includes(oldText)) {
throw new Error(`Text not found: "${oldText}"`)
}
const replacement = Array.isArray(newText) ? newText.join("\n") : newText
return content.replaceAll(oldText, replacement)
}
function getEditLineNumber(edit: HashlineEdit): number {
switch (edit.type) {
case "set_line":
return parseLineRef(edit.line).line
case "replace_lines":
return parseLineRef(edit.end_line).line
case "insert_after":
return parseLineRef(edit.line).line
case "insert_before":
return parseLineRef(edit.line).line
case "insert_between":
return parseLineRef(edit.before_line).line
case "replace":
return Number.NEGATIVE_INFINITY
default:
return Number.POSITIVE_INFINITY
}
}
function normalizeEditPayload(payload: string | string[]): string {
return toNewLines(payload).join("\n")
}
function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } {
const seen = new Set<string>()
const deduped: HashlineEdit[] = []
let deduplicatedEdits = 0
for (const edit of edits) {
const key = (() => {
switch (edit.type) {
case "set_line":
return `set_line|${edit.line}|${normalizeEditPayload(edit.text)}`
case "replace_lines":
return `replace_lines|${edit.start_line}|${edit.end_line}|${normalizeEditPayload(edit.text)}`
case "insert_after":
return `insert_after|${edit.line}|${normalizeEditPayload(edit.text)}`
case "insert_before":
return `insert_before|${edit.line}|${normalizeEditPayload(edit.text)}`
case "insert_between":
return `insert_between|${edit.after_line}|${edit.before_line}|${normalizeEditPayload(edit.text)}`
case "replace":
return `replace|${edit.old_text}|${normalizeEditPayload(edit.new_text)}`
}
})()
if (seen.has(key)) {
deduplicatedEdits += 1
continue
}
seen.add(key)
deduped.push(edit)
}
return { edits: deduped, deduplicatedEdits }
}
export function applyHashlineEditsWithReport(content: string, edits: HashlineEdit[]): HashlineApplyReport {
if (edits.length === 0) {
return {
@@ -182,40 +34,23 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
let noopEdits = 0
let result = content
let lines = result.split("\n")
let lines = result.length === 0 ? [] : result.split("\n")
const refs = sortedEdits.flatMap((edit) => {
switch (edit.type) {
case "set_line":
return [edit.line]
case "replace_lines":
return [edit.start_line, edit.end_line]
case "insert_after":
return [edit.line]
case "insert_before":
return [edit.line]
case "insert_between":
return [edit.after_line, edit.before_line]
case "replace":
return []
default:
return []
}
})
const refs = collectLineRefs(sortedEdits)
validateLineRefs(lines, refs)
for (const edit of sortedEdits) {
switch (edit.type) {
case "set_line": {
lines = applySetLine(lines, edit.line, edit.text)
lines = applySetLine(lines, edit.line, edit.text, { skipValidation: true })
break
}
case "replace_lines": {
lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text)
lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text, { skipValidation: true })
break
}
case "insert_after": {
const next = applyInsertAfter(lines, edit.line, edit.text)
const next = applyInsertAfter(lines, edit.line, edit.text, { skipValidation: true })
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
@@ -224,7 +59,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
break
}
case "insert_before": {
const next = applyInsertBefore(lines, edit.line, edit.text)
const next = applyInsertBefore(lines, edit.line, edit.text, { skipValidation: true })
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
@@ -233,7 +68,25 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
break
}
case "insert_between": {
const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text)
const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text, { skipValidation: true })
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
}
lines = next
break
}
case "append": {
const next = applyAppend(lines, edit.text)
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
}
lines = next
break
}
case "prepend": {
const next = applyPrepend(lines, edit.text)
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
@@ -243,11 +96,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
}
case "replace": {
result = lines.join("\n")
if (!result.includes(edit.old_text)) {
throw new Error(`Text not found: "${edit.old_text}"`)
}
const replacement = Array.isArray(edit.new_text) ? edit.new_text.join("\n") : edit.new_text
const replaced = result.replaceAll(edit.old_text, replacement)
const replaced = applyReplace(result, edit.old_text, edit.new_text)
if (replaced === result) {
noopEdits += 1
break
@@ -269,3 +118,12 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
return applyHashlineEditsWithReport(content, edits).content
}
export {
applySetLine,
applyReplaceLines,
applyInsertAfter,
applyInsertBefore,
applyInsertBetween,
applyReplace,
} from "./edit-operation-primitives"

View File

@@ -0,0 +1,48 @@
import { parseLineRef } from "./validation"
import type { HashlineEdit } from "./types"
export function getEditLineNumber(edit: HashlineEdit): number {
switch (edit.type) {
case "set_line":
return parseLineRef(edit.line).line
case "replace_lines":
return parseLineRef(edit.end_line).line
case "insert_after":
return parseLineRef(edit.line).line
case "insert_before":
return parseLineRef(edit.line).line
case "insert_between":
return parseLineRef(edit.before_line).line
case "append":
return Number.NEGATIVE_INFINITY
case "prepend":
return Number.NEGATIVE_INFINITY
case "replace":
return Number.NEGATIVE_INFINITY
default:
return Number.POSITIVE_INFINITY
}
}
export function collectLineRefs(edits: HashlineEdit[]): string[] {
return edits.flatMap((edit) => {
switch (edit.type) {
case "set_line":
return [edit.line]
case "replace_lines":
return [edit.start_line, edit.end_line]
case "insert_after":
return [edit.line]
case "insert_before":
return [edit.line]
case "insert_between":
return [edit.after_line, edit.before_line]
case "append":
case "prepend":
case "replace":
return []
default:
return []
}
})
}

View File

@@ -0,0 +1,40 @@
export interface FileTextEnvelope {
content: string
hadBom: boolean
lineEnding: "\n" | "\r\n"
}
function detectLineEnding(content: string): "\n" | "\r\n" {
return content.includes("\r\n") ? "\r\n" : "\n"
}
function stripBom(content: string): { content: string; hadBom: boolean } {
if (!content.startsWith("\uFEFF")) {
return { content, hadBom: false }
}
return { content: content.slice(1), hadBom: true }
}
function normalizeToLf(content: string): string {
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
}
function restoreLineEndings(content: string, lineEnding: "\n" | "\r\n"): string {
if (lineEnding === "\n") return content
return content.replace(/\n/g, "\r\n")
}
export function canonicalizeFileText(content: string): FileTextEnvelope {
const stripped = stripBom(content)
return {
content: normalizeToLf(stripped.content),
hadBom: stripped.hadBom,
lineEnding: detectLineEnding(stripped.content),
}
}
export function restoreFileText(content: string, envelope: FileTextEnvelope): string {
const withLineEnding = restoreLineEndings(content, envelope.lineEnding)
if (!envelope.hadBom) return withLineEnding
return `\uFEFF${withLineEnding}`
}

View File

@@ -0,0 +1,31 @@
import { computeLineHash } from "./hash-computation"
export function generateHashlineDiff(oldContent: string, newContent: string, filePath: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
let diff = `--- ${filePath}\n+++ ${filePath}\n`
const maxLines = Math.max(oldLines.length, newLines.length)
for (let i = 0; i < maxLines; i += 1) {
const oldLine = oldLines[i] ?? ""
const newLine = newLines[i] ?? ""
const lineNum = i + 1
const hash = computeLineHash(lineNum, newLine)
if (i >= oldLines.length) {
diff += `+ ${lineNum}#${hash}:${newLine}\n`
continue
}
if (i >= newLines.length) {
diff += `- ${lineNum}# :${oldLine}\n`
continue
}
if (oldLine !== newLine) {
diff += `- ${lineNum}# :${oldLine}\n`
diff += `+ ${lineNum}#${hash}:${newLine}\n`
}
}
return diff
}

View File

@@ -0,0 +1,146 @@
import type { ToolContext } from "@opencode-ai/plugin/tool"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { applyHashlineEditsWithReport } from "./edit-operations"
import { countLineDiffs, generateUnifiedDiff, toHashlineContent } from "./diff-utils"
import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
import { generateHashlineDiff } from "./hashline-edit-diff"
import type { HashlineEdit } from "./types"
interface HashlineEditArgs {
filePath: string
edits: HashlineEdit[]
delete?: boolean
rename?: string
}
type ToolContextWithCallID = ToolContext & {
callID?: string
callId?: string
call_id?: string
}
type ToolContextWithMetadata = ToolContextWithCallID & {
metadata?: (value: unknown) => void
}
function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id
return undefined
}
function canCreateFromMissingFile(edits: HashlineEdit[]): boolean {
if (edits.length === 0) return false
return edits.every((edit) => edit.type === "append" || edit.type === "prepend")
}
function buildSuccessMeta(
effectivePath: string,
beforeContent: string,
afterContent: string,
noopEdits: number,
deduplicatedEdits: number
) {
const unifiedDiff = generateUnifiedDiff(beforeContent, afterContent, effectivePath)
const { additions, deletions } = countLineDiffs(beforeContent, afterContent)
return {
title: effectivePath,
metadata: {
filePath: effectivePath,
path: effectivePath,
file: effectivePath,
diff: unifiedDiff,
noopEdits,
deduplicatedEdits,
filediff: {
file: effectivePath,
path: effectivePath,
filePath: effectivePath,
before: beforeContent,
after: afterContent,
additions,
deletions,
},
},
}
}
export async function executeHashlineEditTool(args: HashlineEditArgs, context: ToolContext): Promise<string> {
try {
const metadataContext = context as ToolContextWithMetadata
const filePath = args.filePath
const { edits, delete: deleteMode, rename } = args
if (deleteMode && rename) {
return "Error: delete and rename cannot be used together"
}
if (!deleteMode && (!edits || !Array.isArray(edits) || edits.length === 0)) {
return "Error: edits parameter must be a non-empty array"
}
if (deleteMode && edits.length > 0) {
return "Error: delete mode requires edits to be an empty array"
}
const file = Bun.file(filePath)
const exists = await file.exists()
if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) {
return `Error: File not found: ${filePath}`
}
if (deleteMode) {
if (!exists) return `Error: File not found: ${filePath}`
await Bun.file(filePath).delete()
return `Successfully deleted ${filePath}`
}
const rawOldContent = exists ? Buffer.from(await file.arrayBuffer()).toString("utf8") : ""
const oldEnvelope = canonicalizeFileText(rawOldContent)
const applyResult = applyHashlineEditsWithReport(oldEnvelope.content, edits)
const canonicalNewContent = applyResult.content
const writeContent = restoreFileText(canonicalNewContent, oldEnvelope)
await Bun.write(filePath, writeContent)
if (rename && rename !== filePath) {
await Bun.write(rename, writeContent)
await Bun.file(filePath).delete()
}
const effectivePath = rename && rename !== filePath ? rename : filePath
const diff = generateHashlineDiff(oldEnvelope.content, canonicalNewContent, effectivePath)
const newHashlined = toHashlineContent(canonicalNewContent)
const meta = buildSuccessMeta(
effectivePath,
oldEnvelope.content,
canonicalNewContent,
applyResult.noopEdits,
applyResult.deduplicatedEdits
)
if (typeof metadataContext.metadata === "function") {
metadataContext.metadata(meta)
}
const callID = resolveToolCallID(metadataContext)
if (callID) {
storeToolMetadata(context.sessionID, callID, meta)
}
return `Successfully applied ${edits.length} edit(s) to ${effectivePath}
No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits}
${diff}
Updated file (LINE#ID:content):
${newHashlined}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.toLowerCase().includes("hash")) {
return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.`
}
return `Error: ${message}`
}
}

View File

@@ -7,7 +7,17 @@ export {
} from "./hash-computation"
export { parseLineRef, validateLineRef } from "./validation"
export type { LineRef } from "./validation"
export type { SetLine, ReplaceLines, InsertAfter, InsertBefore, InsertBetween, Replace, HashlineEdit } from "./types"
export type {
SetLine,
ReplaceLines,
InsertAfter,
InsertBefore,
InsertBetween,
Replace,
Append,
Prepend,
HashlineEdit,
} from "./types"
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants"
export {
applyHashlineEdits,

View File

@@ -7,14 +7,14 @@ WORKFLOW:
4. Use anchors as "LINE#ID" only (never include trailing ":content").
VALIDATION:
- Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
- Each edit must be one of: set_line, replace_lines, insert_after, insert_before, insert_between, replace
- text/new_text must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
Each edit must be one of: set_line, replace_lines, insert_after, insert_before, insert_between, replace, append, prepend
text/new_text must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
LINE#ID FORMAT (CRITICAL - READ CAREFULLY):
Each line reference must be in "LINE#ID" format where:
- LINE: 1-based line number
- ID: Two CID letters from the set ZPMQVRWSNKTXJBYH
LINE: 1-based line number
ID: Two CID letters from the set ZPMQVRWSNKTXJBYH
OPERATION TYPES:
1. set_line
@@ -25,10 +25,34 @@ OPERATION TYPES:
6. replace
FILE MODES:
- delete=true deletes file and requires edits=[] with no rename
- rename moves final content to a new path and removes old path
delete=true deletes file and requires edits=[] with no rename
rename moves final content to a new path and removes old path
CONTENT FORMAT:
- text/new_text can be a string (single line) or string[] (multi-line, preferred).
- If you pass a multi-line string, it is split by real newline characters.
- Literal "\\n" is preserved as text.`
text/new_text can be a string (single line) or string[] (multi-line, preferred).
If you pass a multi-line string, it is split by real newline characters.
Literal "\\n" is preserved as text.
FILE CREATION:
append: adds content at EOF. If file does not exist, creates it.
prepend: adds content at BOF. If file does not exist, creates it.
CRITICAL: append/prepend are the ONLY operations that work without an existing file.
OPERATION CHOICE:
One line wrong \u2192 set_line
Block rewrite \u2192 replace_lines
New content between known anchors \u2192 insert_between (safest \u2014 dual-anchor pinning)
New content at boundary \u2192 insert_after or insert_before
New file or EOF/BOF addition \u2192 append or prepend
No LINE#ID available \u2192 replace (last resort)
AUTOCORRECT (built-in \u2014 you do NOT need to handle these):
Merged lines are auto-expanded back to original line count.
Indentation is auto-restored from original lines.
BOM and CRLF line endings are preserved automatically.
Hashline prefixes and diff markers in text are auto-stripped.
RECOVERY (when >>> mismatch error appears):
Copy the updated LINE#ID tags shown in the error output directly.
Re-read only if the needed tags are missing from the error snippet.
ALWAYS batch all edits for one file in a single call.`

View File

@@ -216,4 +216,50 @@ describe("createHashlineEditTool", () => {
expect(fs.existsSync(filePath)).toBe(false)
expect(result).toContain("Successfully deleted")
})
it("creates missing file with append and prepend", async () => {
//#given
const filePath = path.join(tempDir, "created.txt")
//#when
const result = await tool.execute(
{
filePath,
edits: [
{ type: "append", text: ["line2"] },
{ type: "prepend", text: ["line1"] },
],
},
createMockContext(),
)
//#then
expect(fs.existsSync(filePath)).toBe(true)
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nline2")
expect(result).toContain("Successfully applied 2 edit(s)")
})
it("preserves BOM and CRLF through hashline_edit", async () => {
//#given
const filePath = path.join(tempDir, "crlf-bom.txt")
const bomCrLf = "\uFEFFline1\r\nline2\r\n"
fs.writeFileSync(filePath, bomCrLf)
const line2Hash = computeLineHash(2, "line2")
//#when
await tool.execute(
{
filePath,
edits: [{ type: "set_line", line: `2#${line2Hash}`, text: "line2-updated" }],
},
createMockContext(),
)
//#then
const bytes = fs.readFileSync(filePath)
expect(bytes[0]).toBe(0xef)
expect(bytes[1]).toBe(0xbb)
expect(bytes[2]).toBe(0xbf)
expect(bytes.toString("utf-8")).toBe("\uFEFFline1\r\nline2-updated\r\n")
})
})

View File

@@ -1,9 +1,6 @@
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import type { HashlineEdit } from "./types"
import { applyHashlineEditsWithReport } from "./edit-operations"
import { computeLineHash } from "./hash-computation"
import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "./diff-utils"
import { executeHashlineEditTool } from "./hashline-edit-executor"
import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description"
interface HashlineEditArgs {
@@ -13,49 +10,6 @@ interface HashlineEditArgs {
rename?: string
}
type ToolContextWithCallID = ToolContext & {
callID?: string
callId?: string
call_id?: string
}
type ToolContextWithMetadata = ToolContextWithCallID & {
metadata?: (value: unknown) => void
}
function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id
return undefined
}
function generateDiff(oldContent: string, newContent: string, filePath: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
let diff = `--- ${filePath}\n+++ ${filePath}\n`
const maxLines = Math.max(oldLines.length, newLines.length)
for (let i = 0; i < maxLines; i++) {
const oldLine = oldLines[i] ?? ""
const newLine = newLines[i] ?? ""
const lineNum = i + 1
const hash = computeLineHash(lineNum, newLine)
if (i >= oldLines.length) {
diff += `+ ${lineNum}#${hash}:${newLine}\n`
} else if (i >= newLines.length) {
diff += `- ${lineNum}# :${oldLine}\n`
} else if (oldLine !== newLine) {
diff += `- ${lineNum}# :${oldLine}\n`
diff += `+ ${lineNum}#${hash}:${newLine}\n`
}
}
return diff
}
export function createHashlineEditTool(): ToolDefinition {
return tool({
description: HASHLINE_EDIT_DESCRIPTION,
@@ -110,101 +64,22 @@ export function createHashlineEditTool(): ToolDefinition {
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Replacement text (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("append"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Content to append at EOF; also creates missing file"),
}),
tool.schema.object({
type: tool.schema.literal("prepend"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Content to prepend at BOF; also creates missing file"),
}),
])
)
.describe("Array of edit operations to apply (empty when delete=true)"),
},
execute: async (args: HashlineEditArgs, context: ToolContext) => {
try {
const metadataContext = context as ToolContextWithMetadata
const filePath = args.filePath
const { edits, delete: deleteMode, rename } = args
if (deleteMode && rename) {
return "Error: delete and rename cannot be used together"
}
if (!deleteMode && (!edits || !Array.isArray(edits) || edits.length === 0)) {
return "Error: edits parameter must be a non-empty array"
}
if (deleteMode && edits.length > 0) {
return "Error: delete mode requires edits to be an empty array"
}
const file = Bun.file(filePath)
const exists = await file.exists()
if (!exists) {
return `Error: File not found: ${filePath}`
}
if (deleteMode) {
await Bun.file(filePath).delete()
return `Successfully deleted ${filePath}`
}
const oldContent = await file.text()
const applyResult = applyHashlineEditsWithReport(oldContent, edits)
const newContent = applyResult.content
await Bun.write(filePath, newContent)
if (rename && rename !== filePath) {
await Bun.write(rename, newContent)
await Bun.file(filePath).delete()
}
const effectivePath = rename && rename !== filePath ? rename : filePath
const diff = generateDiff(oldContent, newContent, effectivePath)
const newHashlined = toHashlineContent(newContent)
const unifiedDiff = generateUnifiedDiff(oldContent, newContent, effectivePath)
const { additions, deletions } = countLineDiffs(oldContent, newContent)
const meta = {
title: effectivePath,
metadata: {
filePath: effectivePath,
path: effectivePath,
file: effectivePath,
diff: unifiedDiff,
noopEdits: applyResult.noopEdits,
deduplicatedEdits: applyResult.deduplicatedEdits,
filediff: {
file: effectivePath,
path: effectivePath,
filePath: effectivePath,
before: oldContent,
after: newContent,
additions,
deletions,
},
},
}
if (typeof metadataContext.metadata === "function") {
metadataContext.metadata(meta)
}
const callID = resolveToolCallID(metadataContext)
if (callID) {
storeToolMetadata(context.sessionID, callID, meta)
}
return `Successfully applied ${edits.length} edit(s) to ${effectivePath}
No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits}
${diff}
Updated file (LINE#ID:content):
${newHashlined}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.toLowerCase().includes("hash")) {
return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.`
}
return `Error: ${message}`
}
},
execute: async (args: HashlineEditArgs, context: ToolContext) => executeHashlineEditTool(args, context),
})
}

View File

@@ -36,4 +36,22 @@ export interface Replace {
new_text: string | string[]
}
export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | InsertBefore | InsertBetween | Replace
export interface Append {
type: "append"
text: string | string[]
}
export interface Prepend {
type: "prepend"
text: string | string[]
}
export type HashlineEdit =
| SetLine
| ReplaceLines
| InsertAfter
| InsertBefore
| InsertBetween
| Replace
| Append
| Prepend