Download packages/agent/src/harness/tools/edit-diff.ts from SaylorTwift/pi: direct link, hf CLI and curl.
- Browser
- Download file 16.5 kB
-
https://huggingface.co/SaylorTwift/pi/resolve/main/packages/agent/src/harness/tools/edit-diff.ts
- Command line
-
hf download hf://SaylorTwift/pi/packages/agent/src/harness/tools/edit-diff.ts
-
curl -L -o edit-diff.ts https://huggingface.co/SaylorTwift/pi/resolve/main/packages/agent/src/harness/tools/edit-diff.ts
16.5 kB
| /** | |
| * Shared diff computation utilities for the edit and similar tools. | |
| */ | |
| import * as Diff from "diff"; | |
| export function detectLineEnding(content: string): "\r\n" | "\n" { | |
| const crlfIdx = content.indexOf("\r\n"); | |
| const lfIdx = content.indexOf("\n"); | |
| if (lfIdx === -1) return "\n"; | |
| if (crlfIdx === -1) return "\n"; | |
| return crlfIdx < lfIdx ? "\r\n" : "\n"; | |
| } | |
| export function normalizeToLF(text: string): string { | |
| return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); | |
| } | |
| export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string { | |
| return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text; | |
| } | |
| /** | |
| * Normalize text for fuzzy matching. Applies progressive transformations: | |
| * - Strip trailing whitespace from each line | |
| * - Normalize smart quotes to ASCII equivalents | |
| * - Normalize Unicode dashes/hyphens to ASCII hyphen | |
| * - Normalize special Unicode spaces to regular space | |
| */ | |
| export function normalizeForFuzzyMatch(text: string): string { | |
| return ( | |
| text | |
| .normalize("NFKC") | |
| // Strip trailing whitespace per line | |
| .split("\n") | |
| .map((line) => line.trimEnd()) | |
| .join("\n") | |
| // Smart single quotes → ' | |
| .replace(/[\u2018\u2019\u201A\u201B]/g, "'") | |
| // Smart double quotes → " | |
| .replace(/[\u201C\u201D\u201E\u201F]/g, '"') | |
| // Various dashes/hyphens → - | |
| // U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash, | |
| // U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus | |
| .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-") | |
| // Special spaces → regular space | |
| // U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP, | |
| // U+205F medium math space, U+3000 ideographic space | |
| .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ") | |
| ); | |
| } | |
| function splitLinesWithEndings(content: string): string[] { | |
| return content.match(/[^\n]*\n|[^\n]+/g) ?? []; | |
| } | |
| interface LineSpan { | |
| start: number; | |
| end: number; | |
| } | |
| interface MatchedEdit { | |
| editIndex: number; | |
| matchIndex: number; | |
| matchLength: number; | |
| newText: string; | |
| } | |
| type TextReplacement = Pick<MatchedEdit, "matchIndex" | "matchLength" | "newText">; | |
| function getLineSpans(content: string): LineSpan[] { | |
| let offset = 0; | |
| return splitLinesWithEndings(content).map((line) => { | |
| const span = { start: offset, end: offset + line.length }; | |
| offset = span.end; | |
| return span; | |
| }); | |
| } | |
| function getReplacementLineRange(lines: LineSpan[], replacement: TextReplacement) { | |
| const replacementStart = replacement.matchIndex; | |
| const replacementEnd = replacement.matchIndex + replacement.matchLength; | |
| let startLine = -1; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]; | |
| if (replacementStart >= line.start && replacementStart < line.end) { | |
| startLine = i; | |
| break; | |
| } | |
| } | |
| if (startLine === -1) { | |
| throw new Error("Replacement range is outside the base content."); | |
| } | |
| let endLine = startLine; | |
| while (endLine < lines.length && lines[endLine].end < replacementEnd) { | |
| endLine++; | |
| } | |
| if (endLine >= lines.length) { | |
| throw new Error("Replacement range is outside the base content."); | |
| } | |
| return { startLine, endLine: endLine + 1 }; | |
| } | |
| function applyReplacements(content: string, replacements: TextReplacement[], offset = 0): string { | |
| let result = content; | |
| for (let i = replacements.length - 1; i >= 0; i--) { | |
| const replacement = replacements[i]; | |
| const matchIndex = replacement.matchIndex - offset; | |
| result = | |
| result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength); | |
| } | |
| return result; | |
| } | |
| /** | |
| * Apply replacements matched against `baseContent` to `originalContent` while | |
| * preserving unchanged line blocks from the original. | |
| * | |
| * This is useful when `baseContent` is a normalized view of the original. Each | |
| * replacement is widened to the lines it actually touches, those touched lines | |
| * are rewritten from the normalized base, and all other lines are copied back | |
| * from `originalContent`. The actual replacement ranges drive preservation so | |
| * duplicate normalized lines cannot be aligned to the wrong occurrence. | |
| */ | |
| export function applyReplacementsPreservingUnchangedLines( | |
| originalContent: string, | |
| baseContent: string, | |
| replacements: TextReplacement[], | |
| ): string { | |
| const originalLines = splitLinesWithEndings(originalContent); | |
| const baseLines = getLineSpans(baseContent); | |
| if (originalLines.length !== baseLines.length) { | |
| throw new Error("Cannot preserve unchanged lines because the base content has a different line count."); | |
| } | |
| const groups: Array<{ startLine: number; endLine: number; replacements: TextReplacement[] }> = []; | |
| const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex); | |
| for (const replacement of sortedReplacements) { | |
| const range = getReplacementLineRange(baseLines, replacement); | |
| const current = groups[groups.length - 1]; | |
| if (current && range.startLine < current.endLine) { | |
| current.endLine = Math.max(current.endLine, range.endLine); | |
| current.replacements.push(replacement); | |
| continue; | |
| } | |
| groups.push({ ...range, replacements: [replacement] }); | |
| } | |
| let originalLineIndex = 0; | |
| let result = ""; | |
| for (const group of groups) { | |
| result += originalLines.slice(originalLineIndex, group.startLine).join(""); | |
| const groupStartOffset = baseLines[group.startLine].start; | |
| const groupEndOffset = baseLines[group.endLine - 1].end; | |
| result += applyReplacements( | |
| baseContent.slice(groupStartOffset, groupEndOffset), | |
| group.replacements, | |
| groupStartOffset, | |
| ); | |
| originalLineIndex = group.endLine; | |
| } | |
| result += originalLines.slice(originalLineIndex).join(""); | |
| return result; | |
| } | |
| export interface FuzzyMatchResult { | |
| /** Whether a match was found */ | |
| found: boolean; | |
| /** The index where the match starts (in the content that should be used for replacement) */ | |
| index: number; | |
| /** Length of the matched text */ | |
| matchLength: number; | |
| /** Whether fuzzy matching was used (false = exact match) */ | |
| usedFuzzyMatch: boolean; | |
| /** | |
| * The content to use for replacement operations. | |
| * When exact match: original content. When fuzzy match: normalized content. | |
| */ | |
| contentForReplacement: string; | |
| } | |
| export interface Edit { | |
| oldText: string; | |
| newText: string; | |
| } | |
| export interface AppliedEditsResult { | |
| baseContent: string; | |
| newContent: string; | |
| } | |
| /** | |
| * Find oldText in content, trying exact match first, then fuzzy match. | |
| * When fuzzy matching is used, the returned contentForReplacement is the | |
| * fuzzy-normalized version of the content (trailing whitespace stripped, | |
| * Unicode quotes/dashes normalized to ASCII). | |
| */ | |
| export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult { | |
| // Try exact match first | |
| const exactIndex = content.indexOf(oldText); | |
| if (exactIndex !== -1) { | |
| return { | |
| found: true, | |
| index: exactIndex, | |
| matchLength: oldText.length, | |
| usedFuzzyMatch: false, | |
| contentForReplacement: content, | |
| }; | |
| } | |
| // Try fuzzy match - work entirely in normalized space | |
| const fuzzyContent = normalizeForFuzzyMatch(content); | |
| const fuzzyOldText = normalizeForFuzzyMatch(oldText); | |
| const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText); | |
| if (fuzzyIndex === -1) { | |
| return { | |
| found: false, | |
| index: -1, | |
| matchLength: 0, | |
| usedFuzzyMatch: false, | |
| contentForReplacement: content, | |
| }; | |
| } | |
| // When fuzzy matching, return offsets in normalized space. Callers can use | |
| // the normalized content to compute replacements, then decide how much of | |
| // that normalized output should be written back. | |
| return { | |
| found: true, | |
| index: fuzzyIndex, | |
| matchLength: fuzzyOldText.length, | |
| usedFuzzyMatch: true, | |
| contentForReplacement: fuzzyContent, | |
| }; | |
| } | |
| /** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */ | |
| export function stripBom(content: string): { bom: string; text: string } { | |
| return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; | |
| } | |
| function countOccurrences(content: string, oldText: string): number { | |
| const fuzzyContent = normalizeForFuzzyMatch(content); | |
| const fuzzyOldText = normalizeForFuzzyMatch(oldText); | |
| return fuzzyContent.split(fuzzyOldText).length - 1; | |
| } | |
| function getNotFoundError(path: string, editIndex: number, totalEdits: number): Error { | |
| if (totalEdits === 1) { | |
| return new Error( | |
| `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`, | |
| ); | |
| } | |
| return new Error( | |
| `Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`, | |
| ); | |
| } | |
| function getDuplicateError(path: string, editIndex: number, totalEdits: number, occurrences: number): Error { | |
| if (totalEdits === 1) { | |
| return new Error( | |
| `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`, | |
| ); | |
| } | |
| return new Error( | |
| `Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`, | |
| ); | |
| } | |
| function getEmptyOldTextError(path: string, editIndex: number, totalEdits: number): Error { | |
| if (totalEdits === 1) { | |
| return new Error(`oldText must not be empty in ${path}.`); | |
| } | |
| return new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`); | |
| } | |
| function getNoChangeError(path: string, totalEdits: number): Error { | |
| if (totalEdits === 1) { | |
| return new Error( | |
| `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, | |
| ); | |
| } | |
| return new Error(`No changes made to ${path}. The replacements produced identical content.`); | |
| } | |
| /** | |
| * Apply one or more exact-text replacements to LF-normalized content. | |
| * | |
| * All edits are matched against the same original content. Replacements are | |
| * then applied in reverse order so offsets remain stable. If any edit needs | |
| * fuzzy matching, the operation runs in fuzzy-normalized content space and then | |
| * overlays those line-level changes onto the original content so unchanged line | |
| * blocks keep their original bytes. | |
| */ | |
| export function applyEditsToNormalizedContent( | |
| normalizedContent: string, | |
| edits: Edit[], | |
| path: string, | |
| ): AppliedEditsResult { | |
| const normalizedEdits = edits.map((edit) => ({ | |
| oldText: normalizeToLF(edit.oldText), | |
| newText: normalizeToLF(edit.newText), | |
| })); | |
| for (let i = 0; i < normalizedEdits.length; i++) { | |
| if (normalizedEdits[i].oldText.length === 0) { | |
| throw getEmptyOldTextError(path, i, normalizedEdits.length); | |
| } | |
| } | |
| const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText)); | |
| const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch); | |
| const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent; | |
| const matchedEdits: MatchedEdit[] = []; | |
| for (let i = 0; i < normalizedEdits.length; i++) { | |
| const edit = normalizedEdits[i]; | |
| const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText); | |
| if (!matchResult.found) { | |
| throw getNotFoundError(path, i, normalizedEdits.length); | |
| } | |
| const occurrences = countOccurrences(replacementBaseContent, edit.oldText); | |
| if (occurrences > 1) { | |
| throw getDuplicateError(path, i, normalizedEdits.length, occurrences); | |
| } | |
| matchedEdits.push({ | |
| editIndex: i, | |
| matchIndex: matchResult.index, | |
| matchLength: matchResult.matchLength, | |
| newText: edit.newText, | |
| }); | |
| } | |
| matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex); | |
| for (let i = 1; i < matchedEdits.length; i++) { | |
| const previous = matchedEdits[i - 1]; | |
| const current = matchedEdits[i]; | |
| if (previous.matchIndex + previous.matchLength > current.matchIndex) { | |
| throw new Error( | |
| `edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`, | |
| ); | |
| } | |
| } | |
| const baseContent = normalizedContent; | |
| const newContent = usedFuzzyMatch | |
| ? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits) | |
| : applyReplacements(replacementBaseContent, matchedEdits); | |
| if (baseContent === newContent) { | |
| throw getNoChangeError(path, normalizedEdits.length); | |
| } | |
| return { baseContent, newContent }; | |
| } | |
| /** Generate a standard unified patch. */ | |
| export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string { | |
| return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, { | |
| context: contextLines, | |
| headerOptions: Diff.FILE_HEADERS_ONLY, | |
| }); | |
| } | |
| /** | |
| * Generate a display-oriented diff string with line numbers and context. | |
| * Returns both the diff string and the first changed line number (in the new file). | |
| */ | |
| export function generateDiffString( | |
| oldContent: string, | |
| newContent: string, | |
| contextLines = 4, | |
| ): { diff: string; firstChangedLine: number | undefined } { | |
| const parts = Diff.diffLines(oldContent, newContent); | |
| const output: string[] = []; | |
| const oldLines = oldContent.split("\n"); | |
| const newLines = newContent.split("\n"); | |
| const maxLineNum = Math.max(oldLines.length, newLines.length); | |
| const lineNumWidth = String(maxLineNum).length; | |
| let oldLineNum = 1; | |
| let newLineNum = 1; | |
| let lastWasChange = false; | |
| let firstChangedLine: number | undefined; | |
| for (let i = 0; i < parts.length; i++) { | |
| const part = parts[i]; | |
| const raw = part.value.split("\n"); | |
| if (raw[raw.length - 1] === "") { | |
| raw.pop(); | |
| } | |
| if (part.added || part.removed) { | |
| // Capture the first changed line (in the new file) | |
| if (firstChangedLine === undefined) { | |
| firstChangedLine = newLineNum; | |
| } | |
| // Show the change | |
| for (const line of raw) { | |
| if (part.added) { | |
| const lineNum = String(newLineNum).padStart(lineNumWidth, " "); | |
| output.push(`+${lineNum} ${line}`); | |
| newLineNum++; | |
| } else { | |
| // removed | |
| const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); | |
| output.push(`-${lineNum} ${line}`); | |
| oldLineNum++; | |
| } | |
| } | |
| lastWasChange = true; | |
| } else { | |
| // Context lines - only show a few before/after changes | |
| const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); | |
| const hasLeadingChange = lastWasChange; | |
| const hasTrailingChange = nextPartIsChange; | |
| if (hasLeadingChange && hasTrailingChange) { | |
| if (raw.length <= contextLines * 2) { | |
| for (const line of raw) { | |
| const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); | |
| output.push(` ${lineNum} ${line}`); | |
| oldLineNum++; | |
| newLineNum++; | |
| } | |
| } else { | |
| const leadingLines = raw.slice(0, contextLines); | |
| const trailingLines = raw.slice(raw.length - contextLines); | |
| const skippedLines = raw.length - leadingLines.length - trailingLines.length; | |
| for (const line of leadingLines) { | |
| const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); | |
| output.push(` ${lineNum} ${line}`); | |
| oldLineNum++; | |
| newLineNum++; | |
| } | |
| output.push(` ${"".padStart(lineNumWidth, " ")} ...`); | |
| oldLineNum += skippedLines; | |
| newLineNum += skippedLines; | |
| for (const line of trailingLines) { | |
| const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); | |
| output.push(` ${lineNum} ${line}`); | |
| oldLineNum++; | |
| newLineNum++; | |
| } | |
| } | |
| } else if (hasLeadingChange) { | |
| const shownLines = raw.slice(0, contextLines); | |
| const skippedLines = raw.length - shownLines.length; | |
| for (const line of shownLines) { | |
| const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); | |
| output.push(` ${lineNum} ${line}`); | |
| oldLineNum++; | |
| newLineNum++; | |
| } | |
| if (skippedLines > 0) { | |
| output.push(` ${"".padStart(lineNumWidth, " ")} ...`); | |
| oldLineNum += skippedLines; | |
| newLineNum += skippedLines; | |
| } | |
| } else if (hasTrailingChange) { | |
| const skippedLines = Math.max(0, raw.length - contextLines); | |
| if (skippedLines > 0) { | |
| output.push(` ${"".padStart(lineNumWidth, " ")} ...`); | |
| oldLineNum += skippedLines; | |
| newLineNum += skippedLines; | |
| } | |
| for (const line of raw.slice(skippedLines)) { | |
| const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); | |
| output.push(` ${lineNum} ${line}`); | |
| oldLineNum++; | |
| newLineNum++; | |
| } | |
| } else { | |
| // Skip these context lines entirely | |
| oldLineNum += raw.length; | |
| newLineNum += raw.length; | |
| } | |
| lastWasChange = false; | |
| } | |
| } | |
| return { diff: output.join("\n"), firstChangedLine }; | |
| } | |