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:
@@ -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")) {
|
||||
|
||||
142
src/tools/hashline-edit/normalize-edits.ts
Normal file
142
src/tools/hashline-edit/normalize-edits.ts
Normal 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
|
||||
}
|
||||
@@ -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)"),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user