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:
parent
ab768029fa
commit
86671ad25c
@ -1,14 +1,14 @@
|
|||||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||||
import { applyHashlineEditsWithReport } from "./edit-operations"
|
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 { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
|
||||||
import { generateHashlineDiff } from "./hashline-edit-diff"
|
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
|
||||||
import type { HashlineEdit } from "./types"
|
import type { HashlineEdit } from "./types"
|
||||||
|
|
||||||
interface HashlineEditArgs {
|
interface HashlineEditArgs {
|
||||||
filePath: string
|
filePath: string
|
||||||
edits: HashlineEdit[]
|
edits: RawHashlineEdit[]
|
||||||
delete?: boolean
|
delete?: boolean
|
||||||
rename?: string
|
rename?: string
|
||||||
}
|
}
|
||||||
@ -44,6 +44,17 @@ function buildSuccessMeta(
|
|||||||
) {
|
) {
|
||||||
const unifiedDiff = generateUnifiedDiff(beforeContent, afterContent, effectivePath)
|
const unifiedDiff = generateUnifiedDiff(beforeContent, afterContent, effectivePath)
|
||||||
const { additions, deletions } = countLineDiffs(beforeContent, afterContent)
|
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 {
|
return {
|
||||||
title: effectivePath,
|
title: effectivePath,
|
||||||
@ -54,6 +65,7 @@ function buildSuccessMeta(
|
|||||||
diff: unifiedDiff,
|
diff: unifiedDiff,
|
||||||
noopEdits,
|
noopEdits,
|
||||||
deduplicatedEdits,
|
deduplicatedEdits,
|
||||||
|
firstChangedLine,
|
||||||
filediff: {
|
filediff: {
|
||||||
file: effectivePath,
|
file: effectivePath,
|
||||||
path: effectivePath,
|
path: effectivePath,
|
||||||
@ -71,14 +83,17 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
|||||||
try {
|
try {
|
||||||
const metadataContext = context as ToolContextWithMetadata
|
const metadataContext = context as ToolContextWithMetadata
|
||||||
const filePath = args.filePath
|
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) {
|
if (deleteMode && rename) {
|
||||||
return "Error: delete and rename cannot be used together"
|
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) {
|
if (deleteMode && edits.length > 0) {
|
||||||
return "Error: delete mode requires edits to be an empty array"
|
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 applyResult = applyHashlineEditsWithReport(oldEnvelope.content, edits)
|
||||||
const canonicalNewContent = applyResult.content
|
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)
|
const writeContent = restoreFileText(canonicalNewContent, oldEnvelope)
|
||||||
|
|
||||||
await Bun.write(filePath, writeContent)
|
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 effectivePath = rename && rename !== filePath ? rename : filePath
|
||||||
const diff = generateHashlineDiff(oldEnvelope.content, canonicalNewContent, effectivePath)
|
|
||||||
const newHashlined = toHashlineContent(canonicalNewContent)
|
|
||||||
const meta = buildSuccessMeta(
|
const meta = buildSuccessMeta(
|
||||||
effectivePath,
|
effectivePath,
|
||||||
oldEnvelope.content,
|
oldEnvelope.content,
|
||||||
@ -129,13 +151,11 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
|||||||
storeToolMetadata(context.sessionID, callID, meta)
|
storeToolMetadata(context.sessionID, callID, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
return `Successfully applied ${edits.length} edit(s) to ${effectivePath}
|
if (rename && rename !== filePath) {
|
||||||
No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits}
|
return `Moved ${filePath} to ${rename}`
|
||||||
|
}
|
||||||
|
|
||||||
${diff}
|
return `Updated ${effectivePath}`
|
||||||
|
|
||||||
Updated file (LINE#ID:content):
|
|
||||||
${newHashlined}`
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
if (message.toLowerCase().includes("hash")) {
|
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 { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||||
import type { HashlineEdit } from "./types"
|
|
||||||
import { executeHashlineEditTool } from "./hashline-edit-executor"
|
import { executeHashlineEditTool } from "./hashline-edit-executor"
|
||||||
import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description"
|
import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description"
|
||||||
|
import type { RawHashlineEdit } from "./normalize-edits"
|
||||||
|
|
||||||
interface HashlineEditArgs {
|
interface HashlineEditArgs {
|
||||||
filePath: string
|
filePath: string
|
||||||
edits: HashlineEdit[]
|
edits: RawHashlineEdit[]
|
||||||
delete?: boolean
|
delete?: boolean
|
||||||
rename?: string
|
rename?: string
|
||||||
}
|
}
|
||||||
@ -19,64 +19,34 @@ export function createHashlineEditTool(): ToolDefinition {
|
|||||||
rename: tool.schema.string().optional().describe("Rename output file path after edits"),
|
rename: tool.schema.string().optional().describe("Rename output file path after edits"),
|
||||||
edits: tool.schema
|
edits: tool.schema
|
||||||
.array(
|
.array(
|
||||||
tool.schema.union([
|
tool.schema.object({
|
||||||
tool.schema.object({
|
type: tool.schema
|
||||||
type: tool.schema.literal("set_line"),
|
.union([
|
||||||
line: tool.schema.string().describe("Line reference in LINE#ID format"),
|
tool.schema.literal("set_line"),
|
||||||
text: tool.schema
|
tool.schema.literal("replace_lines"),
|
||||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
tool.schema.literal("insert_after"),
|
||||||
.describe("New content for the line (string or string[] for multiline)"),
|
tool.schema.literal("insert_before"),
|
||||||
}),
|
tool.schema.literal("insert_between"),
|
||||||
tool.schema.object({
|
tool.schema.literal("replace"),
|
||||||
type: tool.schema.literal("replace_lines"),
|
tool.schema.literal("append"),
|
||||||
start_line: tool.schema.string().describe("Start line in LINE#ID format"),
|
tool.schema.literal("prepend"),
|
||||||
end_line: tool.schema.string().describe("End line in LINE#ID format"),
|
])
|
||||||
text: tool.schema
|
.describe("Edit operation type"),
|
||||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
line: tool.schema.string().optional().describe("Anchor line in LINE#ID format"),
|
||||||
.describe("New content to replace the range (string or string[] for multiline)"),
|
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"),
|
||||||
tool.schema.object({
|
after_line: tool.schema.string().optional().describe("Insert boundary (after) in LINE#ID format"),
|
||||||
type: tool.schema.literal("insert_after"),
|
before_line: tool.schema.string().optional().describe("Insert boundary (before) in LINE#ID format"),
|
||||||
line: tool.schema.string().describe("Line reference in LINE#ID format"),
|
text: tool.schema
|
||||||
text: tool.schema
|
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
||||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
.optional()
|
||||||
.describe("Content to insert after the line (string or string[] for multiline)"),
|
.describe("Operation content"),
|
||||||
}),
|
old_text: tool.schema.string().optional().describe("Legacy text replacement source"),
|
||||||
tool.schema.object({
|
new_text: tool.schema
|
||||||
type: tool.schema.literal("insert_before"),
|
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
||||||
line: tool.schema.string().describe("Line reference in LINE#ID format"),
|
.optional()
|
||||||
text: tool.schema
|
.describe("Legacy text replacement target"),
|
||||||
.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"),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
)
|
)
|
||||||
.describe("Array of edit operations to apply (empty when delete=true)"),
|
.describe("Array of edit operations to apply (empty when delete=true)"),
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user