feat(hashline-edit): add anchor insert modes and strict insert validation

This commit is contained in:
YeonGyu-Kim
2026-02-22 03:38:04 +09:00
parent f7c5c0be35
commit a39f183c31
13 changed files with 888 additions and 186 deletions
+59 -69
View File
@@ -1,13 +1,16 @@
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import type { HashlineEdit } from "./types"
import { applyHashlineEdits } from "./edit-operations"
import { applyHashlineEditsWithReport } from "./edit-operations"
import { computeLineHash } from "./hash-computation"
import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "./diff-utils"
import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description"
interface HashlineEditArgs {
filePath: string
edits: HashlineEdit[]
delete?: boolean
rename?: string
}
type ToolContextWithCallID = ToolContext & {
@@ -55,62 +58,11 @@ function generateDiff(oldContent: string, newContent: string, filePath: string):
export function createHashlineEditTool(): ToolDefinition {
return tool({
description: `Edit files using LINE#ID format for precise, safe modifications.
WORKFLOW:
1. Read the file and copy exact LINE#ID anchors.
2. Submit one edit call with all related operations for that file.
3. If more edits are needed after success, use the latest anchors from read/edit output.
4. Use anchors as "LINE#ID" only (never include trailing ":content").
VALIDATION:
- Payload shape: { "filePath": string, "edits": [...] }
- Each edit must be one of: set_line, replace_lines, insert_after, replace
- 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
- Example: "5#VK" means line 5 with hash id "VK"
- WRONG: "2#aa" (invalid characters) - will fail!
- CORRECT: "2#VK"
GETTING HASHES:
Use the read tool - it returns lines in "LINE#ID:content" format.
Successful edit output also includes updated file content in "LINE#ID:content" format.
FOUR OPERATION TYPES:
1. set_line: Replace a single line
{ "type": "set_line", "line": "5#VK", "text": "const y = 2" }
2. replace_lines: Replace a range of lines
{ "type": "replace_lines", "start_line": "5#VK", "end_line": "7#NP", "text": ["new", "content"] }
3. insert_after: Insert lines after a specific line
{ "type": "insert_after", "line": "5#VK", "text": "console.log('hi')" }
4. replace: Simple text replacement (no hash validation)
{ "type": "replace", "old_text": "foo", "new_text": "bar" }
HASH MISMATCH HANDLING:
If the hash doesn't match the current content, the edit fails with a hash mismatch error. This prevents editing stale content.
SEQUENTIAL EDITS (ANTI-FLAKE):
- Always copy anchors exactly from the latest read/edit output.
- Never infer or guess hashes.
- For related edits, prefer batching them in one call.
BOTTOM-UP APPLICATION:
Edits are applied from bottom to top (highest line numbers first) to preserve line number references.
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.`,
description: HASHLINE_EDIT_DESCRIPTION,
args: {
filePath: tool.schema.string().describe("Absolute path to the file to edit"),
delete: tool.schema.boolean().optional().describe("Delete file instead of editing"),
rename: tool.schema.string().optional().describe("Rename output file path after edits"),
edits: tool.schema
.array(
tool.schema.union([
@@ -136,6 +88,21 @@ CONTENT FORMAT:
.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"),
@@ -145,17 +112,24 @@ CONTENT FORMAT:
}),
])
)
.describe("Array of edit operations to apply"),
.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 } = args
const { edits, delete: deleteMode, rename } = args
if (!edits || !Array.isArray(edits) || edits.length === 0) {
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()
@@ -163,28 +137,43 @@ CONTENT FORMAT:
return `Error: File not found: ${filePath}`
}
if (deleteMode) {
await Bun.file(filePath).delete()
return `Successfully deleted ${filePath}`
}
const oldContent = await file.text()
const newContent = applyHashlineEdits(oldContent, edits)
const applyResult = applyHashlineEditsWithReport(oldContent, edits)
const newContent = applyResult.content
await Bun.write(filePath, newContent)
const diff = generateDiff(oldContent, newContent, filePath)
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, filePath)
const unifiedDiff = generateUnifiedDiff(oldContent, newContent, effectivePath)
const { additions, deletions } = countLineDiffs(oldContent, newContent)
const meta = {
title: filePath,
title: effectivePath,
metadata: {
filePath,
path: filePath,
file: filePath,
filePath: effectivePath,
path: effectivePath,
file: effectivePath,
diff: unifiedDiff,
noopEdits: applyResult.noopEdits,
deduplicatedEdits: applyResult.deduplicatedEdits,
filediff: {
file: filePath,
path: filePath,
filePath,
file: effectivePath,
path: effectivePath,
filePath: effectivePath,
before: oldContent,
after: newContent,
additions,
@@ -202,7 +191,8 @@ CONTENT FORMAT:
storeToolMetadata(context.sessionID, callID, meta)
}
return `Successfully applied ${edits.length} edit(s) to ${filePath}
return `Successfully applied ${edits.length} edit(s) to ${effectivePath}
No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits}
${diff}