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.
155 lines
4.8 KiB
TypeScript
155 lines
4.8 KiB
TypeScript
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)
|
|
}
|