refactor(hashline-edit): adopt normalized single-shape edit input

Keep current field names but accept a pi-style flexible edit payload that is normalized to concrete operations at execution time.

Response now follows concise update/move status with diff metadata retained, removing full-file hashline echo to reduce model feedback loops.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-02-23 18:51:32 +09:00
parent ab768029fa
commit 86671ad25c
3 changed files with 207 additions and 75 deletions

View File

@@ -1,14 +1,14 @@
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 { countLineDiffs, generateUnifiedDiff } from "./diff-utils"
import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
import { generateHashlineDiff } from "./hashline-edit-diff"
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
import type { HashlineEdit } from "./types"
interface HashlineEditArgs {
filePath: string
edits: HashlineEdit[]
edits: RawHashlineEdit[]
delete?: boolean
rename?: string
}
@@ -44,6 +44,17 @@ function buildSuccessMeta(
) {
const unifiedDiff = generateUnifiedDiff(beforeContent, afterContent, effectivePath)
const { additions, deletions } = countLineDiffs(beforeContent, afterContent)
const beforeLines = beforeContent.split("\n")
const afterLines = afterContent.split("\n")
const maxLength = Math.max(beforeLines.length, afterLines.length)
let firstChangedLine: number | undefined
for (let index = 0; index < maxLength; index += 1) {
if ((beforeLines[index] ?? "") !== (afterLines[index] ?? "")) {
firstChangedLine = index + 1
break
}
}
return {
title: effectivePath,
@@ -54,6 +65,7 @@ function buildSuccessMeta(
diff: unifiedDiff,
noopEdits,
deduplicatedEdits,
firstChangedLine,
filediff: {
file: effectivePath,
path: effectivePath,
@@ -71,14 +83,17 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
try {
const metadataContext = context as ToolContextWithMetadata
const filePath = args.filePath
const { edits, delete: deleteMode, rename } = args
const { delete: deleteMode, rename } = args
if (!deleteMode && (!args.edits || !Array.isArray(args.edits) || args.edits.length === 0)) {
return "Error: edits parameter must be a non-empty array"
}
const edits = deleteMode ? [] : normalizeHashlineEdits(args.edits)
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"
}
@@ -100,6 +115,15 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
const applyResult = applyHashlineEditsWithReport(oldEnvelope.content, edits)
const canonicalNewContent = applyResult.content
if (canonicalNewContent === oldEnvelope.content && !rename) {
let diagnostic = `No changes made to ${filePath}. The edits produced identical content.`
if (applyResult.noopEdits > 0) {
diagnostic += ` No-op edits: ${applyResult.noopEdits}. Re-read the file and provide content that differs from current lines.`
}
return `Error: ${diagnostic}`
}
const writeContent = restoreFileText(canonicalNewContent, oldEnvelope)
await Bun.write(filePath, writeContent)
@@ -110,8 +134,6 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
}
const effectivePath = rename && rename !== filePath ? rename : filePath
const diff = generateHashlineDiff(oldEnvelope.content, canonicalNewContent, effectivePath)
const newHashlined = toHashlineContent(canonicalNewContent)
const meta = buildSuccessMeta(
effectivePath,
oldEnvelope.content,
@@ -129,13 +151,11 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
storeToolMetadata(context.sessionID, callID, meta)
}
return `Successfully applied ${edits.length} edit(s) to ${effectivePath}
No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits}
if (rename && rename !== filePath) {
return `Moved ${filePath} to ${rename}`
}
${diff}
Updated file (LINE#ID:content):
${newHashlined}`
return `Updated ${effectivePath}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.toLowerCase().includes("hash")) {

View File

@@ -0,0 +1,142 @@
import type { HashlineEdit } from "./types"
export interface RawHashlineEdit {
type?:
| "set_line"
| "replace_lines"
| "insert_after"
| "insert_before"
| "insert_between"
| "replace"
| "append"
| "prepend"
line?: string
start_line?: string
end_line?: string
after_line?: string
before_line?: string
text?: string | string[]
old_text?: string
new_text?: string | string[]
}
function firstDefined(...values: Array<string | undefined>): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.trim() !== "") return value
}
return undefined
}
function requireText(edit: RawHashlineEdit, index: number): string | string[] {
const text = edit.text ?? edit.new_text
if (text === undefined) {
throw new Error(`Edit ${index}: text is required for ${edit.type ?? "unknown"}`)
}
return text
}
function requireLine(anchor: string | undefined, index: number, op: string): string {
if (!anchor) {
throw new Error(`Edit ${index}: ${op} requires at least one anchor line reference`)
}
return anchor
}
export function normalizeHashlineEdits(rawEdits: RawHashlineEdit[]): HashlineEdit[] {
const normalized: HashlineEdit[] = []
for (let index = 0; index < rawEdits.length; index += 1) {
const edit = rawEdits[index] ?? {}
const type = edit.type
switch (type) {
case "set_line": {
const anchor = firstDefined(edit.line, edit.start_line, edit.end_line, edit.after_line, edit.before_line)
normalized.push({
type: "set_line",
line: requireLine(anchor, index, "set_line"),
text: requireText(edit, index),
})
break
}
case "replace_lines": {
const startAnchor = firstDefined(edit.start_line, edit.line, edit.after_line)
const endAnchor = firstDefined(edit.end_line, edit.line, edit.before_line)
if (!startAnchor && !endAnchor) {
throw new Error(`Edit ${index}: replace_lines requires start_line or end_line`)
}
if (startAnchor && endAnchor) {
normalized.push({
type: "replace_lines",
start_line: startAnchor,
end_line: endAnchor,
text: requireText(edit, index),
})
} else {
normalized.push({
type: "set_line",
line: requireLine(startAnchor ?? endAnchor, index, "replace_lines"),
text: requireText(edit, index),
})
}
break
}
case "insert_after": {
const anchor = firstDefined(edit.line, edit.after_line, edit.end_line, edit.start_line)
normalized.push({
type: "insert_after",
line: requireLine(anchor, index, "insert_after"),
text: requireText(edit, index),
})
break
}
case "insert_before": {
const anchor = firstDefined(edit.line, edit.before_line, edit.start_line, edit.end_line)
normalized.push({
type: "insert_before",
line: requireLine(anchor, index, "insert_before"),
text: requireText(edit, index),
})
break
}
case "insert_between": {
const afterLine = firstDefined(edit.after_line, edit.line, edit.start_line)
const beforeLine = firstDefined(edit.before_line, edit.end_line, edit.line)
normalized.push({
type: "insert_between",
after_line: requireLine(afterLine, index, "insert_between.after_line"),
before_line: requireLine(beforeLine, index, "insert_between.before_line"),
text: requireText(edit, index),
})
break
}
case "replace": {
const oldText = edit.old_text
const newText = edit.new_text ?? edit.text
if (!oldText) {
throw new Error(`Edit ${index}: replace requires old_text`)
}
if (newText === undefined) {
throw new Error(`Edit ${index}: replace requires new_text or text`)
}
normalized.push({ type: "replace", old_text: oldText, new_text: newText })
break
}
case "append": {
normalized.push({ type: "append", text: requireText(edit, index) })
break
}
case "prepend": {
normalized.push({ type: "prepend", text: requireText(edit, index) })
break
}
default: {
throw new Error(`Edit ${index}: unsupported type "${String(type)}"`)
}
}
}
return normalized
}

View File

@@ -1,11 +1,11 @@
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool"
import type { HashlineEdit } from "./types"
import { executeHashlineEditTool } from "./hashline-edit-executor"
import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description"
import type { RawHashlineEdit } from "./normalize-edits"
interface HashlineEditArgs {
filePath: string
edits: HashlineEdit[]
edits: RawHashlineEdit[]
delete?: boolean
rename?: string
}
@@ -19,64 +19,34 @@ export function createHashlineEditTool(): ToolDefinition {
rename: tool.schema.string().optional().describe("Rename output file path after edits"),
edits: tool.schema
.array(
tool.schema.union([
tool.schema.object({
type: tool.schema.literal("set_line"),
line: tool.schema.string().describe("Line reference in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("New content for the line (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("replace_lines"),
start_line: tool.schema.string().describe("Start line in LINE#ID format"),
end_line: tool.schema.string().describe("End line in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("New content to replace the range (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("insert_after"),
line: tool.schema.string().describe("Line reference in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Content to insert after the line (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("insert_before"),
line: tool.schema.string().describe("Line reference in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Content to insert before the line (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("insert_between"),
after_line: tool.schema.string().describe("After line in LINE#ID format"),
before_line: tool.schema.string().describe("Before line in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Content to insert between anchor lines (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("replace"),
old_text: tool.schema.string().describe("Text to find"),
new_text: tool.schema
.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"),
}),
])
tool.schema.object({
type: tool.schema
.union([
tool.schema.literal("set_line"),
tool.schema.literal("replace_lines"),
tool.schema.literal("insert_after"),
tool.schema.literal("insert_before"),
tool.schema.literal("insert_between"),
tool.schema.literal("replace"),
tool.schema.literal("append"),
tool.schema.literal("prepend"),
])
.describe("Edit operation type"),
line: tool.schema.string().optional().describe("Anchor line in LINE#ID format"),
start_line: tool.schema.string().optional().describe("Range start in LINE#ID format"),
end_line: tool.schema.string().optional().describe("Range end in LINE#ID format"),
after_line: tool.schema.string().optional().describe("Insert boundary (after) in LINE#ID format"),
before_line: tool.schema.string().optional().describe("Insert boundary (before) in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.optional()
.describe("Operation content"),
old_text: tool.schema.string().optional().describe("Legacy text replacement source"),
new_text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.optional()
.describe("Legacy text replacement target"),
})
)
.describe("Array of edit operations to apply (empty when delete=true)"),
},