feat(hashline-edit): add autocorrect, BOM/CRLF normalization, and file creation support
Implements key features from oh-my-pi to improve agent editing success rates: - Autocorrect v1: single-line merge expansion, wrapped line restoration, paired indent restoration (autocorrect-replacement-lines.ts) - BOM/CRLF normalization: canonicalize on read, restore on write (file-text-canonicalization.ts) - Pre-validate all hashes before mutation (edit-ordering.ts) - File creation via append/prepend operations (new types + executor logic) - Modular refactoring: split edit-operations.ts into focused modules (primitives, ordering, deduplication, diff, executor) - Enhanced tool description with operation choice guide and recovery hints All 50 tests pass. TypeScript clean. Build successful.
This commit is contained in:
parent
e84fce3121
commit
5d1d87cc10
106
src/tools/hashline-edit/autocorrect-replacement-lines.ts
Normal file
106
src/tools/hashline-edit/autocorrect-replacement-lines.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
function normalizeTokens(text: string): string {
|
||||||
|
return text.replace(/\s+/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function leadingWhitespace(text: string): string {
|
||||||
|
const match = text.match(/^\s*/)
|
||||||
|
return match ? match[0] : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreOldWrappedLines(originalLines: string[], replacementLines: string[]): string[] {
|
||||||
|
if (replacementLines.length <= 1) return replacementLines
|
||||||
|
if (originalLines.length !== replacementLines.length) return replacementLines
|
||||||
|
const original = normalizeTokens(originalLines.join("\n"))
|
||||||
|
const replacement = normalizeTokens(replacementLines.join("\n"))
|
||||||
|
if (original !== replacement) return replacementLines
|
||||||
|
return originalLines
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maybeExpandSingleLineMerge(
|
||||||
|
originalLines: string[],
|
||||||
|
replacementLines: string[]
|
||||||
|
): string[] {
|
||||||
|
if (replacementLines.length !== 1 || originalLines.length <= 1) {
|
||||||
|
return replacementLines
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = replacementLines[0]
|
||||||
|
const parts = originalLines.map((line) => line.trim()).filter((line) => line.length > 0)
|
||||||
|
if (parts.length !== originalLines.length) return replacementLines
|
||||||
|
|
||||||
|
const indices: number[] = []
|
||||||
|
let offset = 0
|
||||||
|
let orderedMatch = true
|
||||||
|
for (const part of parts) {
|
||||||
|
const idx = merged.indexOf(part, offset)
|
||||||
|
if (idx === -1) {
|
||||||
|
orderedMatch = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
indices.push(idx)
|
||||||
|
offset = idx + part.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const expanded: string[] = []
|
||||||
|
if (orderedMatch) {
|
||||||
|
for (let i = 0; i < indices.length; i += 1) {
|
||||||
|
const start = indices[i]
|
||||||
|
const end = i + 1 < indices.length ? indices[i + 1] : merged.length
|
||||||
|
const candidate = merged.slice(start, end).trim()
|
||||||
|
if (candidate.length === 0) {
|
||||||
|
orderedMatch = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
expanded.push(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderedMatch && expanded.length === originalLines.length) {
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
const semicolonSplit = merged
|
||||||
|
.split(/;\s+/)
|
||||||
|
.map((line, idx, arr) => {
|
||||||
|
if (idx < arr.length - 1 && !line.endsWith(";")) {
|
||||||
|
return `${line};`
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
})
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
|
||||||
|
if (semicolonSplit.length === originalLines.length) {
|
||||||
|
return semicolonSplit
|
||||||
|
}
|
||||||
|
|
||||||
|
return replacementLines
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreIndentForPairedReplacement(
|
||||||
|
originalLines: string[],
|
||||||
|
replacementLines: string[]
|
||||||
|
): string[] {
|
||||||
|
if (originalLines.length !== replacementLines.length) {
|
||||||
|
return replacementLines
|
||||||
|
}
|
||||||
|
|
||||||
|
return replacementLines.map((line, idx) => {
|
||||||
|
if (line.length === 0) return line
|
||||||
|
if (leadingWhitespace(line).length > 0) return line
|
||||||
|
const indent = leadingWhitespace(originalLines[idx])
|
||||||
|
if (indent.length === 0) return line
|
||||||
|
return `${indent}${line}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autocorrectReplacementLines(
|
||||||
|
originalLines: string[],
|
||||||
|
replacementLines: string[]
|
||||||
|
): string[] {
|
||||||
|
let next = replacementLines
|
||||||
|
next = maybeExpandSingleLineMerge(originalLines, next)
|
||||||
|
next = restoreOldWrappedLines(originalLines, next)
|
||||||
|
next = restoreIndentForPairedReplacement(originalLines, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
47
src/tools/hashline-edit/edit-deduplication.ts
Normal file
47
src/tools/hashline-edit/edit-deduplication.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import type { HashlineEdit } from "./types"
|
||||||
|
import { toNewLines } from "./edit-text-normalization"
|
||||||
|
|
||||||
|
function normalizeEditPayload(payload: string | string[]): string {
|
||||||
|
return toNewLines(payload).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDedupeKey(edit: HashlineEdit): string {
|
||||||
|
switch (edit.type) {
|
||||||
|
case "set_line":
|
||||||
|
return `set_line|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||||
|
case "replace_lines":
|
||||||
|
return `replace_lines|${edit.start_line}|${edit.end_line}|${normalizeEditPayload(edit.text)}`
|
||||||
|
case "insert_after":
|
||||||
|
return `insert_after|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||||
|
case "insert_before":
|
||||||
|
return `insert_before|${edit.line}|${normalizeEditPayload(edit.text)}`
|
||||||
|
case "insert_between":
|
||||||
|
return `insert_between|${edit.after_line}|${edit.before_line}|${normalizeEditPayload(edit.text)}`
|
||||||
|
case "replace":
|
||||||
|
return `replace|${edit.old_text}|${normalizeEditPayload(edit.new_text)}`
|
||||||
|
case "append":
|
||||||
|
return `append|${normalizeEditPayload(edit.text)}`
|
||||||
|
case "prepend":
|
||||||
|
return `prepend|${normalizeEditPayload(edit.text)}`
|
||||||
|
default:
|
||||||
|
return JSON.stringify(edit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const deduped: HashlineEdit[] = []
|
||||||
|
let deduplicatedEdits = 0
|
||||||
|
|
||||||
|
for (const edit of edits) {
|
||||||
|
const key = buildDedupeKey(edit)
|
||||||
|
if (seen.has(key)) {
|
||||||
|
deduplicatedEdits += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(key)
|
||||||
|
deduped.push(edit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { edits: deduped, deduplicatedEdits }
|
||||||
|
}
|
||||||
154
src/tools/hashline-edit/edit-operation-primitives.ts
Normal file
154
src/tools/hashline-edit/edit-operation-primitives.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { autocorrectReplacementLines } from "./autocorrect-replacement-lines"
|
||||||
|
import {
|
||||||
|
restoreLeadingIndent,
|
||||||
|
stripInsertAnchorEcho,
|
||||||
|
stripInsertBeforeEcho,
|
||||||
|
stripInsertBoundaryEcho,
|
||||||
|
stripRangeBoundaryEcho,
|
||||||
|
toNewLines,
|
||||||
|
} from "./edit-text-normalization"
|
||||||
|
import { parseLineRef, validateLineRef } from "./validation"
|
||||||
|
|
||||||
|
interface EditApplyOptions {
|
||||||
|
skipValidation?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldValidate(options?: EditApplyOptions): boolean {
|
||||||
|
return options?.skipValidation !== true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySetLine(
|
||||||
|
lines: string[],
|
||||||
|
anchor: string,
|
||||||
|
newText: string | string[],
|
||||||
|
options?: EditApplyOptions
|
||||||
|
): string[] {
|
||||||
|
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||||
|
const { line } = parseLineRef(anchor)
|
||||||
|
const result = [...lines]
|
||||||
|
const originalLine = lines[line - 1] ?? ""
|
||||||
|
const corrected = autocorrectReplacementLines([originalLine], toNewLines(newText))
|
||||||
|
const replacement = corrected.map((entry, idx) => {
|
||||||
|
if (idx !== 0) return entry
|
||||||
|
return restoreLeadingIndent(originalLine, entry)
|
||||||
|
})
|
||||||
|
result.splice(line - 1, 1, ...replacement)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyReplaceLines(
|
||||||
|
lines: string[],
|
||||||
|
startAnchor: string,
|
||||||
|
endAnchor: string,
|
||||||
|
newText: string | string[],
|
||||||
|
options?: EditApplyOptions
|
||||||
|
): string[] {
|
||||||
|
if (shouldValidate(options)) {
|
||||||
|
validateLineRef(lines, startAnchor)
|
||||||
|
validateLineRef(lines, endAnchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { line: startLine } = parseLineRef(startAnchor)
|
||||||
|
const { line: endLine } = parseLineRef(endAnchor)
|
||||||
|
|
||||||
|
if (startLine > endLine) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...lines]
|
||||||
|
const originalRange = lines.slice(startLine - 1, endLine)
|
||||||
|
const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText))
|
||||||
|
const corrected = autocorrectReplacementLines(originalRange, stripped)
|
||||||
|
const restored = corrected.map((entry, idx) => {
|
||||||
|
if (idx !== 0) return entry
|
||||||
|
return restoreLeadingIndent(lines[startLine - 1], entry)
|
||||||
|
})
|
||||||
|
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyInsertAfter(
|
||||||
|
lines: string[],
|
||||||
|
anchor: string,
|
||||||
|
text: string | string[],
|
||||||
|
options?: EditApplyOptions
|
||||||
|
): string[] {
|
||||||
|
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||||
|
const { line } = parseLineRef(anchor)
|
||||||
|
const result = [...lines]
|
||||||
|
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
|
||||||
|
if (newLines.length === 0) {
|
||||||
|
throw new Error(`insert_after requires non-empty text for ${anchor}`)
|
||||||
|
}
|
||||||
|
result.splice(line, 0, ...newLines)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyInsertBefore(
|
||||||
|
lines: string[],
|
||||||
|
anchor: string,
|
||||||
|
text: string | string[],
|
||||||
|
options?: EditApplyOptions
|
||||||
|
): string[] {
|
||||||
|
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||||
|
const { line } = parseLineRef(anchor)
|
||||||
|
const result = [...lines]
|
||||||
|
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
|
||||||
|
if (newLines.length === 0) {
|
||||||
|
throw new Error(`insert_before requires non-empty text for ${anchor}`)
|
||||||
|
}
|
||||||
|
result.splice(line - 1, 0, ...newLines)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyInsertBetween(
|
||||||
|
lines: string[],
|
||||||
|
afterAnchor: string,
|
||||||
|
beforeAnchor: string,
|
||||||
|
text: string | string[],
|
||||||
|
options?: EditApplyOptions
|
||||||
|
): string[] {
|
||||||
|
if (shouldValidate(options)) {
|
||||||
|
validateLineRef(lines, afterAnchor)
|
||||||
|
validateLineRef(lines, beforeAnchor)
|
||||||
|
}
|
||||||
|
const { line: afterLine } = parseLineRef(afterAnchor)
|
||||||
|
const { line: beforeLine } = parseLineRef(beforeAnchor)
|
||||||
|
if (beforeLine <= afterLine) {
|
||||||
|
throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...lines]
|
||||||
|
const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text))
|
||||||
|
if (newLines.length === 0) {
|
||||||
|
throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`)
|
||||||
|
}
|
||||||
|
result.splice(beforeLine - 1, 0, ...newLines)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAppend(lines: string[], text: string | string[]): string[] {
|
||||||
|
const normalized = toNewLines(text)
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
throw new Error("append requires non-empty text")
|
||||||
|
}
|
||||||
|
return [...lines, ...normalized]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPrepend(lines: string[], text: string | string[]): string[] {
|
||||||
|
const normalized = toNewLines(text)
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
throw new Error("prepend requires non-empty text")
|
||||||
|
}
|
||||||
|
return [...normalized, ...lines]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
|
||||||
|
if (!content.includes(oldText)) {
|
||||||
|
throw new Error(`Text not found: "${oldText}"`)
|
||||||
|
}
|
||||||
|
const replacement = Array.isArray(newText) ? newText.join("\n") : newText
|
||||||
|
return content.replaceAll(oldText, replacement)
|
||||||
|
}
|
||||||
@ -248,4 +248,34 @@ describe("hashline edit operations", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(result).toEqual(["if (x) {", " return 3", " return 4", "}"])
|
expect(result).toEqual(["if (x) {", " return 3", " return 4", "}"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("applies append and prepend operations", () => {
|
||||||
|
//#given
|
||||||
|
const content = "line 1\nline 2"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyHashlineEdits(content, [
|
||||||
|
{ type: "append", text: ["line 3"] },
|
||||||
|
{ type: "prepend", text: ["line 0"] },
|
||||||
|
])
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual("line 0\nline 1\nline 2\nline 3")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("autocorrects single-line merged replacement into original line count", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["const a = 1;", "const b = 2;"]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = applyReplaceLines(
|
||||||
|
lines,
|
||||||
|
anchorFor(lines, 1),
|
||||||
|
anchorFor(lines, 2),
|
||||||
|
"const a = 10; const b = 20;"
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual(["const a = 10;", "const b = 20;"])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import { parseLineRef, validateLineRef, validateLineRefs } from "./validation"
|
import { dedupeEdits } from "./edit-deduplication"
|
||||||
|
import { collectLineRefs, getEditLineNumber } from "./edit-ordering"
|
||||||
import type { HashlineEdit } from "./types"
|
import type { HashlineEdit } from "./types"
|
||||||
import {
|
import {
|
||||||
restoreLeadingIndent,
|
applyAppend,
|
||||||
stripInsertAnchorEcho,
|
applyInsertAfter,
|
||||||
stripInsertBeforeEcho,
|
applyInsertBefore,
|
||||||
stripInsertBoundaryEcho,
|
applyInsertBetween,
|
||||||
stripRangeBoundaryEcho,
|
applyPrepend,
|
||||||
toNewLines,
|
applyReplace,
|
||||||
} from "./edit-text-normalization"
|
applyReplaceLines,
|
||||||
|
applySetLine,
|
||||||
|
} from "./edit-operation-primitives"
|
||||||
|
import { validateLineRefs } from "./validation"
|
||||||
|
|
||||||
export interface HashlineApplyReport {
|
export interface HashlineApplyReport {
|
||||||
content: string
|
content: string
|
||||||
@ -15,158 +19,6 @@ export interface HashlineApplyReport {
|
|||||||
deduplicatedEdits: number
|
deduplicatedEdits: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applySetLine(lines: string[], anchor: string, newText: string | string[]): string[] {
|
|
||||||
validateLineRef(lines, anchor)
|
|
||||||
const { line } = parseLineRef(anchor)
|
|
||||||
const result = [...lines]
|
|
||||||
const replacement = toNewLines(newText).map((entry, idx) => {
|
|
||||||
if (idx !== 0) return entry
|
|
||||||
return restoreLeadingIndent(lines[line - 1], entry)
|
|
||||||
})
|
|
||||||
result.splice(line - 1, 1, ...replacement)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyReplaceLines(
|
|
||||||
lines: string[],
|
|
||||||
startAnchor: string,
|
|
||||||
endAnchor: string,
|
|
||||||
newText: string | string[]
|
|
||||||
): string[] {
|
|
||||||
validateLineRef(lines, startAnchor)
|
|
||||||
validateLineRef(lines, endAnchor)
|
|
||||||
|
|
||||||
const { line: startLine } = parseLineRef(startAnchor)
|
|
||||||
const { line: endLine } = parseLineRef(endAnchor)
|
|
||||||
|
|
||||||
if (startLine > endLine) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = [...lines]
|
|
||||||
const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText))
|
|
||||||
const restored = stripped.map((entry, idx) => {
|
|
||||||
if (idx !== 0) return entry
|
|
||||||
return restoreLeadingIndent(lines[startLine - 1], entry)
|
|
||||||
})
|
|
||||||
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyInsertAfter(lines: string[], anchor: string, text: string | string[]): string[] {
|
|
||||||
validateLineRef(lines, anchor)
|
|
||||||
const { line } = parseLineRef(anchor)
|
|
||||||
const result = [...lines]
|
|
||||||
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
|
|
||||||
if (newLines.length === 0) {
|
|
||||||
throw new Error(`insert_after requires non-empty text for ${anchor}`)
|
|
||||||
}
|
|
||||||
result.splice(line, 0, ...newLines)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyInsertBefore(lines: string[], anchor: string, text: string | string[]): string[] {
|
|
||||||
validateLineRef(lines, anchor)
|
|
||||||
const { line } = parseLineRef(anchor)
|
|
||||||
const result = [...lines]
|
|
||||||
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
|
|
||||||
if (newLines.length === 0) {
|
|
||||||
throw new Error(`insert_before requires non-empty text for ${anchor}`)
|
|
||||||
}
|
|
||||||
result.splice(line - 1, 0, ...newLines)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyInsertBetween(
|
|
||||||
lines: string[],
|
|
||||||
afterAnchor: string,
|
|
||||||
beforeAnchor: string,
|
|
||||||
text: string | string[]
|
|
||||||
): string[] {
|
|
||||||
validateLineRef(lines, afterAnchor)
|
|
||||||
validateLineRef(lines, beforeAnchor)
|
|
||||||
const { line: afterLine } = parseLineRef(afterAnchor)
|
|
||||||
const { line: beforeLine } = parseLineRef(beforeAnchor)
|
|
||||||
if (beforeLine <= afterLine) {
|
|
||||||
throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = [...lines]
|
|
||||||
const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text))
|
|
||||||
if (newLines.length === 0) {
|
|
||||||
throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`)
|
|
||||||
}
|
|
||||||
result.splice(beforeLine - 1, 0, ...newLines)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
|
|
||||||
if (!content.includes(oldText)) {
|
|
||||||
throw new Error(`Text not found: "${oldText}"`)
|
|
||||||
}
|
|
||||||
const replacement = Array.isArray(newText) ? newText.join("\n") : newText
|
|
||||||
return content.replaceAll(oldText, replacement)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEditLineNumber(edit: HashlineEdit): number {
|
|
||||||
switch (edit.type) {
|
|
||||||
case "set_line":
|
|
||||||
return parseLineRef(edit.line).line
|
|
||||||
case "replace_lines":
|
|
||||||
return parseLineRef(edit.end_line).line
|
|
||||||
case "insert_after":
|
|
||||||
return parseLineRef(edit.line).line
|
|
||||||
case "insert_before":
|
|
||||||
return parseLineRef(edit.line).line
|
|
||||||
case "insert_between":
|
|
||||||
return parseLineRef(edit.before_line).line
|
|
||||||
case "replace":
|
|
||||||
return Number.NEGATIVE_INFINITY
|
|
||||||
default:
|
|
||||||
return Number.POSITIVE_INFINITY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEditPayload(payload: string | string[]): string {
|
|
||||||
return toNewLines(payload).join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const deduped: HashlineEdit[] = []
|
|
||||||
let deduplicatedEdits = 0
|
|
||||||
|
|
||||||
for (const edit of edits) {
|
|
||||||
const key = (() => {
|
|
||||||
switch (edit.type) {
|
|
||||||
case "set_line":
|
|
||||||
return `set_line|${edit.line}|${normalizeEditPayload(edit.text)}`
|
|
||||||
case "replace_lines":
|
|
||||||
return `replace_lines|${edit.start_line}|${edit.end_line}|${normalizeEditPayload(edit.text)}`
|
|
||||||
case "insert_after":
|
|
||||||
return `insert_after|${edit.line}|${normalizeEditPayload(edit.text)}`
|
|
||||||
case "insert_before":
|
|
||||||
return `insert_before|${edit.line}|${normalizeEditPayload(edit.text)}`
|
|
||||||
case "insert_between":
|
|
||||||
return `insert_between|${edit.after_line}|${edit.before_line}|${normalizeEditPayload(edit.text)}`
|
|
||||||
case "replace":
|
|
||||||
return `replace|${edit.old_text}|${normalizeEditPayload(edit.new_text)}`
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (seen.has(key)) {
|
|
||||||
deduplicatedEdits += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen.add(key)
|
|
||||||
deduped.push(edit)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { edits: deduped, deduplicatedEdits }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyHashlineEditsWithReport(content: string, edits: HashlineEdit[]): HashlineApplyReport {
|
export function applyHashlineEditsWithReport(content: string, edits: HashlineEdit[]): HashlineApplyReport {
|
||||||
if (edits.length === 0) {
|
if (edits.length === 0) {
|
||||||
return {
|
return {
|
||||||
@ -182,40 +34,23 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
|||||||
let noopEdits = 0
|
let noopEdits = 0
|
||||||
|
|
||||||
let result = content
|
let result = content
|
||||||
let lines = result.split("\n")
|
let lines = result.length === 0 ? [] : result.split("\n")
|
||||||
|
|
||||||
const refs = sortedEdits.flatMap((edit) => {
|
const refs = collectLineRefs(sortedEdits)
|
||||||
switch (edit.type) {
|
|
||||||
case "set_line":
|
|
||||||
return [edit.line]
|
|
||||||
case "replace_lines":
|
|
||||||
return [edit.start_line, edit.end_line]
|
|
||||||
case "insert_after":
|
|
||||||
return [edit.line]
|
|
||||||
case "insert_before":
|
|
||||||
return [edit.line]
|
|
||||||
case "insert_between":
|
|
||||||
return [edit.after_line, edit.before_line]
|
|
||||||
case "replace":
|
|
||||||
return []
|
|
||||||
default:
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
validateLineRefs(lines, refs)
|
validateLineRefs(lines, refs)
|
||||||
|
|
||||||
for (const edit of sortedEdits) {
|
for (const edit of sortedEdits) {
|
||||||
switch (edit.type) {
|
switch (edit.type) {
|
||||||
case "set_line": {
|
case "set_line": {
|
||||||
lines = applySetLine(lines, edit.line, edit.text)
|
lines = applySetLine(lines, edit.line, edit.text, { skipValidation: true })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "replace_lines": {
|
case "replace_lines": {
|
||||||
lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text)
|
lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text, { skipValidation: true })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "insert_after": {
|
case "insert_after": {
|
||||||
const next = applyInsertAfter(lines, edit.line, edit.text)
|
const next = applyInsertAfter(lines, edit.line, edit.text, { skipValidation: true })
|
||||||
if (next.join("\n") === lines.join("\n")) {
|
if (next.join("\n") === lines.join("\n")) {
|
||||||
noopEdits += 1
|
noopEdits += 1
|
||||||
break
|
break
|
||||||
@ -224,7 +59,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "insert_before": {
|
case "insert_before": {
|
||||||
const next = applyInsertBefore(lines, edit.line, edit.text)
|
const next = applyInsertBefore(lines, edit.line, edit.text, { skipValidation: true })
|
||||||
if (next.join("\n") === lines.join("\n")) {
|
if (next.join("\n") === lines.join("\n")) {
|
||||||
noopEdits += 1
|
noopEdits += 1
|
||||||
break
|
break
|
||||||
@ -233,7 +68,25 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "insert_between": {
|
case "insert_between": {
|
||||||
const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text)
|
const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text, { skipValidation: true })
|
||||||
|
if (next.join("\n") === lines.join("\n")) {
|
||||||
|
noopEdits += 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = next
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "append": {
|
||||||
|
const next = applyAppend(lines, edit.text)
|
||||||
|
if (next.join("\n") === lines.join("\n")) {
|
||||||
|
noopEdits += 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = next
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "prepend": {
|
||||||
|
const next = applyPrepend(lines, edit.text)
|
||||||
if (next.join("\n") === lines.join("\n")) {
|
if (next.join("\n") === lines.join("\n")) {
|
||||||
noopEdits += 1
|
noopEdits += 1
|
||||||
break
|
break
|
||||||
@ -243,11 +96,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
|||||||
}
|
}
|
||||||
case "replace": {
|
case "replace": {
|
||||||
result = lines.join("\n")
|
result = lines.join("\n")
|
||||||
if (!result.includes(edit.old_text)) {
|
const replaced = applyReplace(result, edit.old_text, edit.new_text)
|
||||||
throw new Error(`Text not found: "${edit.old_text}"`)
|
|
||||||
}
|
|
||||||
const replacement = Array.isArray(edit.new_text) ? edit.new_text.join("\n") : edit.new_text
|
|
||||||
const replaced = result.replaceAll(edit.old_text, replacement)
|
|
||||||
if (replaced === result) {
|
if (replaced === result) {
|
||||||
noopEdits += 1
|
noopEdits += 1
|
||||||
break
|
break
|
||||||
@ -269,3 +118,12 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
|||||||
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
|
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
|
||||||
return applyHashlineEditsWithReport(content, edits).content
|
return applyHashlineEditsWithReport(content, edits).content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
applySetLine,
|
||||||
|
applyReplaceLines,
|
||||||
|
applyInsertAfter,
|
||||||
|
applyInsertBefore,
|
||||||
|
applyInsertBetween,
|
||||||
|
applyReplace,
|
||||||
|
} from "./edit-operation-primitives"
|
||||||
|
|||||||
48
src/tools/hashline-edit/edit-ordering.ts
Normal file
48
src/tools/hashline-edit/edit-ordering.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { parseLineRef } from "./validation"
|
||||||
|
import type { HashlineEdit } from "./types"
|
||||||
|
|
||||||
|
export function getEditLineNumber(edit: HashlineEdit): number {
|
||||||
|
switch (edit.type) {
|
||||||
|
case "set_line":
|
||||||
|
return parseLineRef(edit.line).line
|
||||||
|
case "replace_lines":
|
||||||
|
return parseLineRef(edit.end_line).line
|
||||||
|
case "insert_after":
|
||||||
|
return parseLineRef(edit.line).line
|
||||||
|
case "insert_before":
|
||||||
|
return parseLineRef(edit.line).line
|
||||||
|
case "insert_between":
|
||||||
|
return parseLineRef(edit.before_line).line
|
||||||
|
case "append":
|
||||||
|
return Number.NEGATIVE_INFINITY
|
||||||
|
case "prepend":
|
||||||
|
return Number.NEGATIVE_INFINITY
|
||||||
|
case "replace":
|
||||||
|
return Number.NEGATIVE_INFINITY
|
||||||
|
default:
|
||||||
|
return Number.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectLineRefs(edits: HashlineEdit[]): string[] {
|
||||||
|
return edits.flatMap((edit) => {
|
||||||
|
switch (edit.type) {
|
||||||
|
case "set_line":
|
||||||
|
return [edit.line]
|
||||||
|
case "replace_lines":
|
||||||
|
return [edit.start_line, edit.end_line]
|
||||||
|
case "insert_after":
|
||||||
|
return [edit.line]
|
||||||
|
case "insert_before":
|
||||||
|
return [edit.line]
|
||||||
|
case "insert_between":
|
||||||
|
return [edit.after_line, edit.before_line]
|
||||||
|
case "append":
|
||||||
|
case "prepend":
|
||||||
|
case "replace":
|
||||||
|
return []
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
40
src/tools/hashline-edit/file-text-canonicalization.ts
Normal file
40
src/tools/hashline-edit/file-text-canonicalization.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
export interface FileTextEnvelope {
|
||||||
|
content: string
|
||||||
|
hadBom: boolean
|
||||||
|
lineEnding: "\n" | "\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLineEnding(content: string): "\n" | "\r\n" {
|
||||||
|
return content.includes("\r\n") ? "\r\n" : "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripBom(content: string): { content: string; hadBom: boolean } {
|
||||||
|
if (!content.startsWith("\uFEFF")) {
|
||||||
|
return { content, hadBom: false }
|
||||||
|
}
|
||||||
|
return { content: content.slice(1), hadBom: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToLf(content: string): string {
|
||||||
|
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreLineEndings(content: string, lineEnding: "\n" | "\r\n"): string {
|
||||||
|
if (lineEnding === "\n") return content
|
||||||
|
return content.replace(/\n/g, "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizeFileText(content: string): FileTextEnvelope {
|
||||||
|
const stripped = stripBom(content)
|
||||||
|
return {
|
||||||
|
content: normalizeToLf(stripped.content),
|
||||||
|
hadBom: stripped.hadBom,
|
||||||
|
lineEnding: detectLineEnding(stripped.content),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreFileText(content: string, envelope: FileTextEnvelope): string {
|
||||||
|
const withLineEnding = restoreLineEndings(content, envelope.lineEnding)
|
||||||
|
if (!envelope.hadBom) return withLineEnding
|
||||||
|
return `\uFEFF${withLineEnding}`
|
||||||
|
}
|
||||||
31
src/tools/hashline-edit/hashline-edit-diff.ts
Normal file
31
src/tools/hashline-edit/hashline-edit-diff.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { computeLineHash } from "./hash-computation"
|
||||||
|
|
||||||
|
export function generateHashlineDiff(oldContent: string, newContent: string, filePath: string): string {
|
||||||
|
const oldLines = oldContent.split("\n")
|
||||||
|
const newLines = newContent.split("\n")
|
||||||
|
|
||||||
|
let diff = `--- ${filePath}\n+++ ${filePath}\n`
|
||||||
|
const maxLines = Math.max(oldLines.length, newLines.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLines; i += 1) {
|
||||||
|
const oldLine = oldLines[i] ?? ""
|
||||||
|
const newLine = newLines[i] ?? ""
|
||||||
|
const lineNum = i + 1
|
||||||
|
const hash = computeLineHash(lineNum, newLine)
|
||||||
|
|
||||||
|
if (i >= oldLines.length) {
|
||||||
|
diff += `+ ${lineNum}#${hash}:${newLine}\n`
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (i >= newLines.length) {
|
||||||
|
diff += `- ${lineNum}# :${oldLine}\n`
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (oldLine !== newLine) {
|
||||||
|
diff += `- ${lineNum}# :${oldLine}\n`
|
||||||
|
diff += `+ ${lineNum}#${hash}:${newLine}\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diff
|
||||||
|
}
|
||||||
146
src/tools/hashline-edit/hashline-edit-executor.ts
Normal file
146
src/tools/hashline-edit/hashline-edit-executor.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
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 { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
|
||||||
|
import { generateHashlineDiff } from "./hashline-edit-diff"
|
||||||
|
import type { HashlineEdit } from "./types"
|
||||||
|
|
||||||
|
interface HashlineEditArgs {
|
||||||
|
filePath: string
|
||||||
|
edits: HashlineEdit[]
|
||||||
|
delete?: boolean
|
||||||
|
rename?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolContextWithCallID = ToolContext & {
|
||||||
|
callID?: string
|
||||||
|
callId?: string
|
||||||
|
call_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolContextWithMetadata = ToolContextWithCallID & {
|
||||||
|
metadata?: (value: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
|
||||||
|
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID
|
||||||
|
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId
|
||||||
|
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCreateFromMissingFile(edits: HashlineEdit[]): boolean {
|
||||||
|
if (edits.length === 0) return false
|
||||||
|
return edits.every((edit) => edit.type === "append" || edit.type === "prepend")
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSuccessMeta(
|
||||||
|
effectivePath: string,
|
||||||
|
beforeContent: string,
|
||||||
|
afterContent: string,
|
||||||
|
noopEdits: number,
|
||||||
|
deduplicatedEdits: number
|
||||||
|
) {
|
||||||
|
const unifiedDiff = generateUnifiedDiff(beforeContent, afterContent, effectivePath)
|
||||||
|
const { additions, deletions } = countLineDiffs(beforeContent, afterContent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: effectivePath,
|
||||||
|
metadata: {
|
||||||
|
filePath: effectivePath,
|
||||||
|
path: effectivePath,
|
||||||
|
file: effectivePath,
|
||||||
|
diff: unifiedDiff,
|
||||||
|
noopEdits,
|
||||||
|
deduplicatedEdits,
|
||||||
|
filediff: {
|
||||||
|
file: effectivePath,
|
||||||
|
path: effectivePath,
|
||||||
|
filePath: effectivePath,
|
||||||
|
before: beforeContent,
|
||||||
|
after: afterContent,
|
||||||
|
additions,
|
||||||
|
deletions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeHashlineEditTool(args: HashlineEditArgs, context: ToolContext): Promise<string> {
|
||||||
|
try {
|
||||||
|
const metadataContext = context as ToolContextWithMetadata
|
||||||
|
const filePath = args.filePath
|
||||||
|
const { edits, delete: deleteMode, rename } = args
|
||||||
|
|
||||||
|
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()
|
||||||
|
if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) {
|
||||||
|
return `Error: File not found: ${filePath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteMode) {
|
||||||
|
if (!exists) return `Error: File not found: ${filePath}`
|
||||||
|
await Bun.file(filePath).delete()
|
||||||
|
return `Successfully deleted ${filePath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawOldContent = exists ? Buffer.from(await file.arrayBuffer()).toString("utf8") : ""
|
||||||
|
const oldEnvelope = canonicalizeFileText(rawOldContent)
|
||||||
|
|
||||||
|
const applyResult = applyHashlineEditsWithReport(oldEnvelope.content, edits)
|
||||||
|
const canonicalNewContent = applyResult.content
|
||||||
|
const writeContent = restoreFileText(canonicalNewContent, oldEnvelope)
|
||||||
|
|
||||||
|
await Bun.write(filePath, writeContent)
|
||||||
|
|
||||||
|
if (rename && rename !== filePath) {
|
||||||
|
await Bun.write(rename, writeContent)
|
||||||
|
await Bun.file(filePath).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectivePath = rename && rename !== filePath ? rename : filePath
|
||||||
|
const diff = generateHashlineDiff(oldEnvelope.content, canonicalNewContent, effectivePath)
|
||||||
|
const newHashlined = toHashlineContent(canonicalNewContent)
|
||||||
|
const meta = buildSuccessMeta(
|
||||||
|
effectivePath,
|
||||||
|
oldEnvelope.content,
|
||||||
|
canonicalNewContent,
|
||||||
|
applyResult.noopEdits,
|
||||||
|
applyResult.deduplicatedEdits
|
||||||
|
)
|
||||||
|
|
||||||
|
if (typeof metadataContext.metadata === "function") {
|
||||||
|
metadataContext.metadata(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
const callID = resolveToolCallID(metadataContext)
|
||||||
|
if (callID) {
|
||||||
|
storeToolMetadata(context.sessionID, callID, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Successfully applied ${edits.length} edit(s) to ${effectivePath}
|
||||||
|
No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits}
|
||||||
|
|
||||||
|
${diff}
|
||||||
|
|
||||||
|
Updated file (LINE#ID:content):
|
||||||
|
${newHashlined}`
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
if (message.toLowerCase().includes("hash")) {
|
||||||
|
return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.`
|
||||||
|
}
|
||||||
|
return `Error: ${message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,7 +7,17 @@ export {
|
|||||||
} from "./hash-computation"
|
} from "./hash-computation"
|
||||||
export { parseLineRef, validateLineRef } from "./validation"
|
export { parseLineRef, validateLineRef } from "./validation"
|
||||||
export type { LineRef } from "./validation"
|
export type { LineRef } from "./validation"
|
||||||
export type { SetLine, ReplaceLines, InsertAfter, InsertBefore, InsertBetween, Replace, HashlineEdit } from "./types"
|
export type {
|
||||||
|
SetLine,
|
||||||
|
ReplaceLines,
|
||||||
|
InsertAfter,
|
||||||
|
InsertBefore,
|
||||||
|
InsertBetween,
|
||||||
|
Replace,
|
||||||
|
Append,
|
||||||
|
Prepend,
|
||||||
|
HashlineEdit,
|
||||||
|
} from "./types"
|
||||||
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants"
|
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants"
|
||||||
export {
|
export {
|
||||||
applyHashlineEdits,
|
applyHashlineEdits,
|
||||||
|
|||||||
@ -7,14 +7,14 @@ WORKFLOW:
|
|||||||
4. Use anchors as "LINE#ID" only (never include trailing ":content").
|
4. Use anchors as "LINE#ID" only (never include trailing ":content").
|
||||||
|
|
||||||
VALIDATION:
|
VALIDATION:
|
||||||
- Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
|
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
|
||||||
- Each edit must be one of: set_line, replace_lines, insert_after, insert_before, insert_between, replace
|
Each edit must be one of: set_line, replace_lines, insert_after, insert_before, insert_between, replace, append, prepend
|
||||||
- text/new_text must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
|
text/new_text must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
|
||||||
|
|
||||||
LINE#ID FORMAT (CRITICAL - READ CAREFULLY):
|
LINE#ID FORMAT (CRITICAL - READ CAREFULLY):
|
||||||
Each line reference must be in "LINE#ID" format where:
|
Each line reference must be in "LINE#ID" format where:
|
||||||
- LINE: 1-based line number
|
LINE: 1-based line number
|
||||||
- ID: Two CID letters from the set ZPMQVRWSNKTXJBYH
|
ID: Two CID letters from the set ZPMQVRWSNKTXJBYH
|
||||||
|
|
||||||
OPERATION TYPES:
|
OPERATION TYPES:
|
||||||
1. set_line
|
1. set_line
|
||||||
@ -25,10 +25,34 @@ OPERATION TYPES:
|
|||||||
6. replace
|
6. replace
|
||||||
|
|
||||||
FILE MODES:
|
FILE MODES:
|
||||||
- delete=true deletes file and requires edits=[] with no rename
|
delete=true deletes file and requires edits=[] with no rename
|
||||||
- rename moves final content to a new path and removes old path
|
rename moves final content to a new path and removes old path
|
||||||
|
|
||||||
CONTENT FORMAT:
|
CONTENT FORMAT:
|
||||||
- text/new_text can be a string (single line) or string[] (multi-line, preferred).
|
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.
|
If you pass a multi-line string, it is split by real newline characters.
|
||||||
- Literal "\\n" is preserved as text.`
|
Literal "\\n" is preserved as text.
|
||||||
|
|
||||||
|
FILE CREATION:
|
||||||
|
append: adds content at EOF. If file does not exist, creates it.
|
||||||
|
prepend: adds content at BOF. If file does not exist, creates it.
|
||||||
|
CRITICAL: append/prepend are the ONLY operations that work without an existing file.
|
||||||
|
|
||||||
|
OPERATION CHOICE:
|
||||||
|
One line wrong \u2192 set_line
|
||||||
|
Block rewrite \u2192 replace_lines
|
||||||
|
New content between known anchors \u2192 insert_between (safest \u2014 dual-anchor pinning)
|
||||||
|
New content at boundary \u2192 insert_after or insert_before
|
||||||
|
New file or EOF/BOF addition \u2192 append or prepend
|
||||||
|
No LINE#ID available \u2192 replace (last resort)
|
||||||
|
|
||||||
|
AUTOCORRECT (built-in \u2014 you do NOT need to handle these):
|
||||||
|
Merged lines are auto-expanded back to original line count.
|
||||||
|
Indentation is auto-restored from original lines.
|
||||||
|
BOM and CRLF line endings are preserved automatically.
|
||||||
|
Hashline prefixes and diff markers in text are auto-stripped.
|
||||||
|
|
||||||
|
RECOVERY (when >>> mismatch error appears):
|
||||||
|
Copy the updated LINE#ID tags shown in the error output directly.
|
||||||
|
Re-read only if the needed tags are missing from the error snippet.
|
||||||
|
ALWAYS batch all edits for one file in a single call.`
|
||||||
|
|||||||
@ -216,4 +216,50 @@ describe("createHashlineEditTool", () => {
|
|||||||
expect(fs.existsSync(filePath)).toBe(false)
|
expect(fs.existsSync(filePath)).toBe(false)
|
||||||
expect(result).toContain("Successfully deleted")
|
expect(result).toContain("Successfully deleted")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("creates missing file with append and prepend", async () => {
|
||||||
|
//#given
|
||||||
|
const filePath = path.join(tempDir, "created.txt")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await tool.execute(
|
||||||
|
{
|
||||||
|
filePath,
|
||||||
|
edits: [
|
||||||
|
{ type: "append", text: ["line2"] },
|
||||||
|
{ type: "prepend", text: ["line1"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
createMockContext(),
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true)
|
||||||
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nline2")
|
||||||
|
expect(result).toContain("Successfully applied 2 edit(s)")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves BOM and CRLF through hashline_edit", async () => {
|
||||||
|
//#given
|
||||||
|
const filePath = path.join(tempDir, "crlf-bom.txt")
|
||||||
|
const bomCrLf = "\uFEFFline1\r\nline2\r\n"
|
||||||
|
fs.writeFileSync(filePath, bomCrLf)
|
||||||
|
const line2Hash = computeLineHash(2, "line2")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await tool.execute(
|
||||||
|
{
|
||||||
|
filePath,
|
||||||
|
edits: [{ type: "set_line", line: `2#${line2Hash}`, text: "line2-updated" }],
|
||||||
|
},
|
||||||
|
createMockContext(),
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const bytes = fs.readFileSync(filePath)
|
||||||
|
expect(bytes[0]).toBe(0xef)
|
||||||
|
expect(bytes[1]).toBe(0xbb)
|
||||||
|
expect(bytes[2]).toBe(0xbf)
|
||||||
|
expect(bytes.toString("utf-8")).toBe("\uFEFFline1\r\nline2-updated\r\n")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
|
||||||
import type { HashlineEdit } from "./types"
|
import type { HashlineEdit } from "./types"
|
||||||
import { applyHashlineEditsWithReport } from "./edit-operations"
|
import { executeHashlineEditTool } from "./hashline-edit-executor"
|
||||||
import { computeLineHash } from "./hash-computation"
|
|
||||||
import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "./diff-utils"
|
|
||||||
import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description"
|
import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description"
|
||||||
|
|
||||||
interface HashlineEditArgs {
|
interface HashlineEditArgs {
|
||||||
@ -13,49 +10,6 @@ interface HashlineEditArgs {
|
|||||||
rename?: string
|
rename?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolContextWithCallID = ToolContext & {
|
|
||||||
callID?: string
|
|
||||||
callId?: string
|
|
||||||
call_id?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolContextWithMetadata = ToolContextWithCallID & {
|
|
||||||
metadata?: (value: unknown) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
|
|
||||||
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID
|
|
||||||
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId
|
|
||||||
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateDiff(oldContent: string, newContent: string, filePath: string): string {
|
|
||||||
const oldLines = oldContent.split("\n")
|
|
||||||
const newLines = newContent.split("\n")
|
|
||||||
|
|
||||||
let diff = `--- ${filePath}\n+++ ${filePath}\n`
|
|
||||||
|
|
||||||
const maxLines = Math.max(oldLines.length, newLines.length)
|
|
||||||
for (let i = 0; i < maxLines; i++) {
|
|
||||||
const oldLine = oldLines[i] ?? ""
|
|
||||||
const newLine = newLines[i] ?? ""
|
|
||||||
const lineNum = i + 1
|
|
||||||
const hash = computeLineHash(lineNum, newLine)
|
|
||||||
|
|
||||||
if (i >= oldLines.length) {
|
|
||||||
diff += `+ ${lineNum}#${hash}:${newLine}\n`
|
|
||||||
} else if (i >= newLines.length) {
|
|
||||||
diff += `- ${lineNum}# :${oldLine}\n`
|
|
||||||
} else if (oldLine !== newLine) {
|
|
||||||
diff += `- ${lineNum}# :${oldLine}\n`
|
|
||||||
diff += `+ ${lineNum}#${hash}:${newLine}\n`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return diff
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createHashlineEditTool(): ToolDefinition {
|
export function createHashlineEditTool(): ToolDefinition {
|
||||||
return tool({
|
return tool({
|
||||||
description: HASHLINE_EDIT_DESCRIPTION,
|
description: HASHLINE_EDIT_DESCRIPTION,
|
||||||
@ -110,101 +64,22 @@ export function createHashlineEditTool(): ToolDefinition {
|
|||||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
||||||
.describe("Replacement text (string or string[] for multiline)"),
|
.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)"),
|
||||||
},
|
},
|
||||||
execute: async (args: HashlineEditArgs, context: ToolContext) => {
|
execute: async (args: HashlineEditArgs, context: ToolContext) => executeHashlineEditTool(args, context),
|
||||||
try {
|
|
||||||
const metadataContext = context as ToolContextWithMetadata
|
|
||||||
const filePath = args.filePath
|
|
||||||
const { edits, delete: deleteMode, rename } = args
|
|
||||||
|
|
||||||
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()
|
|
||||||
if (!exists) {
|
|
||||||
return `Error: File not found: ${filePath}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteMode) {
|
|
||||||
await Bun.file(filePath).delete()
|
|
||||||
return `Successfully deleted ${filePath}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldContent = await file.text()
|
|
||||||
const applyResult = applyHashlineEditsWithReport(oldContent, edits)
|
|
||||||
const newContent = applyResult.content
|
|
||||||
|
|
||||||
await Bun.write(filePath, newContent)
|
|
||||||
|
|
||||||
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, effectivePath)
|
|
||||||
const { additions, deletions } = countLineDiffs(oldContent, newContent)
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: effectivePath,
|
|
||||||
metadata: {
|
|
||||||
filePath: effectivePath,
|
|
||||||
path: effectivePath,
|
|
||||||
file: effectivePath,
|
|
||||||
diff: unifiedDiff,
|
|
||||||
noopEdits: applyResult.noopEdits,
|
|
||||||
deduplicatedEdits: applyResult.deduplicatedEdits,
|
|
||||||
filediff: {
|
|
||||||
file: effectivePath,
|
|
||||||
path: effectivePath,
|
|
||||||
filePath: effectivePath,
|
|
||||||
before: oldContent,
|
|
||||||
after: newContent,
|
|
||||||
additions,
|
|
||||||
deletions,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof metadataContext.metadata === "function") {
|
|
||||||
metadataContext.metadata(meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
const callID = resolveToolCallID(metadataContext)
|
|
||||||
if (callID) {
|
|
||||||
storeToolMetadata(context.sessionID, callID, meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Successfully applied ${edits.length} edit(s) to ${effectivePath}
|
|
||||||
No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits}
|
|
||||||
|
|
||||||
${diff}
|
|
||||||
|
|
||||||
Updated file (LINE#ID:content):
|
|
||||||
${newHashlined}`
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
|
||||||
if (message.toLowerCase().includes("hash")) {
|
|
||||||
return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.`
|
|
||||||
}
|
|
||||||
return `Error: ${message}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,4 +36,22 @@ export interface Replace {
|
|||||||
new_text: string | string[]
|
new_text: string | string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | InsertBefore | InsertBetween | Replace
|
export interface Append {
|
||||||
|
type: "append"
|
||||||
|
text: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Prepend {
|
||||||
|
type: "prepend"
|
||||||
|
text: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HashlineEdit =
|
||||||
|
| SetLine
|
||||||
|
| ReplaceLines
|
||||||
|
| InsertAfter
|
||||||
|
| InsertBefore
|
||||||
|
| InsertBetween
|
||||||
|
| Replace
|
||||||
|
| Append
|
||||||
|
| Prepend
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user