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:
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user