feat(hashline-edit): add anchor insert modes and strict insert validation
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user