feat(coding-agent): introduced hashline format v2 with colon separators and tag-based API

- Changed hashline format separator from pipe (|) to colon (:) for improved readability across all tools and output formats.
- Refactored hashline edit API with operation-based structure: renamed delete->rm, rename->mv, set->target/new_content, and added explicit op field for operation types.
- Updated hashline hash encoding from 4-character base36 to 2-character hexadecimal for more compact representation.
- Replaced anchor terminology with tags throughout hashline documentation and API for clearer semantics.
This commit is contained in:
can1357
2026-02-19 20:00:23 +01:00
parent e7b01abf0c
commit 7ce3bb6906
17 changed files with 5616 additions and 837 deletions
+1 -1
View File
@@ -8,8 +8,8 @@ import * as Diff from "diff";
import { resolveToCwd } from "../tools/path-utils";
import { previewPatch } from "./applicator";
import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy";
import type { HashlineEdit } from "./hashline";
import { applyHashlineEdits } from "./hashline";
import type { HashlineEdit } from "./index";
import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
import type { DiffError, DiffResult, PatchInput } from "./types";
import { EditMatchError } from "./types";
+198 -329
View File
@@ -2,103 +2,27 @@
* Hashline edit mode — a line-addressable edit format using content hashes.
*
* Each line in a file is identified by its 1-indexed line number and a short
* base36 hash derived from the normalized line content (xxHash32, truncated to 4
* base36 chars).
* hexadecimal hash derived from the normalized line content (xxHash32, truncated to 2
* hex chars).
* The combined `LINE#ID` reference acts as both an address and a staleness check:
* if the file has changed since the caller last read it, hash mismatches are caught
* before any mutation occurs.
*
* Displayed format: `LINENUM#HASH|CONTENT`
* Reference format: `"LINENUM#HASH"` (e.g. `"5#a3f2"`)
* Displayed format: `LINENUM#HASH:CONTENT`
* Reference format: `"LINENUM#HASH"` (e.g. `"5#aa"`)
*/
import type { HashlineEdit } from "./index";
import type { HashMismatch } from "./types";
type ParsedRefs =
| { kind: "single"; ref: { line: number; hash: string } }
| { kind: "range"; start: { line: number; hash: string }; end: { line: number; hash: string } }
| { kind: "insertAfter"; after: { line: number; hash: string } }
| { kind: "insertBefore"; before: { line: number; hash: string } }
| { kind: "insertBetween"; after: { line: number; hash: string }; before: { line: number; hash: string } }
| { kind: "insertAtEof" };
function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dstLines: string[] } {
if ("set" in edit) {
return {
spec: { kind: "single", ref: parseLineRef(edit.set.ref) },
dstLines: edit.set.body,
};
}
if ("set_range" in edit) {
const r = edit.set_range as Record<string, unknown>;
const start = parseLineRef(r.beg as string);
if (!r.end) {
return {
spec: { kind: "single", ref: start },
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
};
}
const end = parseLineRef(r.end as string);
return {
spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end },
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
};
}
if ("insert" in edit) {
const r = edit.insert as Record<string, unknown>;
const dstLines = Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.text ?? r.content ?? ""));
const hasAfterField = "after" in r;
const hasBeforeField = "before" in r;
const afterRef = r.after;
const beforeRef = r.before;
if (hasAfterField && (typeof afterRef !== "string" || afterRef.trim().length === 0)) {
throw new Error('insert.after must be a non-empty "LINE#ID" string when provided');
}
if (hasBeforeField && (typeof beforeRef !== "string" || beforeRef.trim().length === 0)) {
throw new Error('insert.before must be a non-empty "LINE#ID" string when provided');
}
const hasAfter = hasAfterField && typeof afterRef === "string";
const hasBefore = hasBeforeField && typeof beforeRef === "string";
if (hasAfter && hasBefore) {
return {
spec: {
kind: "insertBetween",
after: parseLineRef(afterRef),
before: parseLineRef(beforeRef),
},
dstLines,
};
}
if (hasAfter) {
return {
spec: { kind: "insertAfter", after: parseLineRef(afterRef) },
dstLines,
};
}
if (hasBefore) {
return {
spec: { kind: "insertBefore", before: parseLineRef(beforeRef) },
dstLines,
};
}
return { spec: { kind: "insertAtEof" }, dstLines };
}
if ("replace" in edit) {
throw new Error("replace edits are applied separately; do not pass them to applyHashlineEdits");
}
throw new Error("Unknown hashline edit operation");
}
/** Split dst into lines; empty string means delete (no lines). */
function splitDstLines(dst: string): string[] {
return dst === "" ? [] : dst.split("\n");
}
/** Pattern matching hashline display format: `LINE#ID|CONTENT` */
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}\|/;
/** Pattern matching a unified-diff `+` prefix (but not `++`) */
const DIFF_PLUS_RE = /^\+(?!\+)/;
export type LineTag = { line: number; hash: string };
export type HashlineEdit =
| { op: "set"; tag: LineTag; content: string[] }
| { op: "replace"; first: LineTag; last: LineTag; content: string[] }
| { op: "append"; after?: LineTag; content: string[] }
| { op: "prepend"; before?: LineTag; content: string[] }
| { op: "insert"; after: LineTag; before: LineTag; content: string[] };
export type ReplaceTextEdit = { op: "replaceText"; old_text: string; new_text: string; all?: boolean };
export type EditSpec = HashlineEdit | ReplaceTextEdit;
/**
* Compare two strings ignoring all whitespace differences.
@@ -144,16 +68,6 @@ function restoreLeadingIndent(templateLine: string, line: string): string {
return templateIndent + line;
}
const CONFUSABLE_HYPHENS_RE = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
function normalizeConfusableHyphens(s: string): string {
return s.replace(CONFUSABLE_HYPHENS_RE, "-");
}
function normalizeConfusableHyphensInLines(lines: string[]): string[] {
return lines.map(l => normalizeConfusableHyphens(l));
}
function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]): string[] {
if (oldLines.length !== newLines.length) return newLines;
let changed = false;
@@ -261,50 +175,19 @@ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine:
return out;
}
/**
* Strip hashline display prefixes and diff `+` markers from replacement lines.
*
* Models frequently copy the `LINE#ID ` prefix from read output into their
* replacement content, or include unified-diff `+` prefixes. Both corrupt the
* output file. This strips them heuristically before application.
*/
function stripNewLinePrefixes(lines: string[]): string[] {
// Detect whether the *majority* of non-empty lines carry a prefix —
// if only one line out of many has a match it's likely real content.
let hashPrefixCount = 0;
let diffPlusCount = 0;
let nonEmpty = 0;
for (const l of lines) {
if (l.length === 0) continue;
nonEmpty++;
if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
}
if (nonEmpty === 0) return lines;
const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5;
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
if (!stripHash && !stripPlus) return lines;
return lines.map(l => {
if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
return l;
});
}
const HASH_LEN = 2;
const RADIX = 16;
const HASH_MOD = RADIX ** HASH_LEN;
const DICT = Array.from({ length: HASH_MOD }, (_, i) => i.toString(RADIX).padStart(HASH_LEN, "0"));
const DICT = Array.from({ length: 256 }, (_, i) => {
const h = i >>> 4;
const l = i & 0x0f;
return `${NIBBLE_STR[h]}${NIBBLE_STR[l]}`;
});
/**
* Compute a short base36 hash of a single line.
* Compute a short hexadecimal hash of a single line.
*
* Uses xxHash64 on a whitespace-normalized line, truncated to {@link HASH_LEN}
* base36 characters. The `idx` parameter is accepted for compatibility with older
* Uses xxHash32 on a whitespace-normalized line, truncated to {@link HASH_LEN}
* hex characters. The `idx` parameter is accepted for compatibility with older
* call sites, but is not currently mixed into the hash.
* The line input should not include a trailing newline.
*/
@@ -314,13 +197,20 @@ export function computeLineHash(idx: number, line: string): string {
}
line = line.replace(/\s+/g, "");
void idx; // Might use line, but for now, let's not.
return DICT[Bun.hash.xxHash32(line) % HASH_MOD];
return DICT[Bun.hash.xxHash32(line) & 0xff];
}
/**
* Formats a tag given the line number and content.
*/
export function formatLineTag(line: number, content: string): string {
return `${line}#${computeLineHash(line, content)}`;
}
/**
* Format file content with hashline prefixes for display.
*
* Each line becomes `LINENUM#HASH|CONTENT` where LINENUM is 1-indexed.
* Each line becomes `LINENUM#HASH:CONTENT` where LINENUM is 1-indexed.
*
* @param content - Raw file content string
* @param startLine - First line number (1-indexed, defaults to 1)
@@ -329,7 +219,7 @@ export function computeLineHash(idx: number, line: string): string {
* @example
* ```
* formatHashLines("function hi() {\n return;\n}")
* // "1#HH|function hi() {\n2#HH| return;\n3#HH|}"
* // "1#HH:function hi() {\n2#HH: return;\n3#HH:}"
* ```
*/
export function formatHashLines(content: string, startLine = 1): string {
@@ -337,8 +227,7 @@ export function formatHashLines(content: string, startLine = 1): string {
return lines
.map((line, i) => {
const num = startLine + i;
const hash = computeLineHash(num, line);
return `${num}#${hash}|${line}`;
return `${formatLineTag(num, line)}:${line}`;
})
.join("\n");
}
@@ -410,7 +299,7 @@ export async function* streamHashLinesFromUtf8(
};
const pushLine = (line: string): string[] => {
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
lineNum++;
const chunksToYield: string[] = [];
@@ -504,7 +393,7 @@ export async function* streamHashLinesFromLines(
const pushLine = (line: string): string[] => {
sawAnyLine = true;
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
lineNum++;
const chunksToYield: string[] = [];
@@ -560,18 +449,14 @@ export async function* streamHashLinesFromLines(
*
* @throws Error if the format is invalid (not `NUMBER#HEXHASH`)
*/
export function parseLineRef(ref: string): { line: number; hash: string } {
// Strip display-format suffix: "5#ab|some content" → "5#ab", or legacy "5#ab some content" → "5#ab"
// Models often copy the full display format from read output.
const cleaned = ref
.replace(/\|.*$/, "")
.replace(/ {2}.*$/, "")
.replace(/^>+\s*/, "")
.trim();
const normalized = cleaned.replace(/\s*#\s*/, "#");
const strictMatch = normalized.match(/^(\d+)#([0-9a-zA-Z]{1,16})$/);
const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+)#([0-9a-zA-Z]{${HASH_LEN}})`));
const match = strictMatch ?? prefixMatch;
export function parseTag(ref: string): { line: number; hash: string } {
// This regex captures:
// 1. optional leading ">+" and whitespace
// 2. line number (1+ digits)
// 3. "#" with optional surrounding spaces
// 4. hash (2 hex chars)
// 5. optional trailing display suffix (":..." or " ...")
const match = ref.match(/^\s*[>+-]*\s*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})/);
if (!match) {
throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`);
}
@@ -648,9 +533,9 @@ export class HashlineMismatchError extends Error {
const prefix = `${lineNum}#${hash}`;
if (mismatchSet.has(lineNum)) {
lines.push(`>>> ${prefix}|${content}`);
lines.push(`>>> ${prefix}:${content}`);
} else {
lines.push(` ${prefix}|${content}`);
lines.push(` ${prefix}:${content}`);
}
}
return lines.join("\n");
@@ -670,7 +555,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
}
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
if (actualHash !== ref.hash.toLowerCase()) {
if (actualHash !== ref.hash) {
throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
}
}
@@ -683,7 +568,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
* Apply an array of hashline edits to file content.
*
* Each edit operation identifies target lines directly (`set`, `set_range`,
* `insert`). Line references are resolved via {@link parseLineRef}
* `insert`). Line references are resolved via {@link parseTag}
* and hashes validated before any mutation.
*
* Edits are sorted bottom-up (highest effective line first) so earlier
@@ -711,36 +596,29 @@ export function applyHashlineEdits(
const autocorrect = Bun.env.PI_HL_AUTOCORRECT === "1";
// Parse src specs and dst lines up front
const parsed = edits.map(edit => {
const parsedEdit = parseHashlineEdit(edit);
return {
spec: parsedEdit.spec,
dstLines: stripNewLinePrefixes(parsedEdit.dstLines),
};
});
function collectExplicitlyTouchedLines(): Set<number> {
const touched = new Set<number>();
for (const { spec } of parsed) {
switch (spec.kind) {
case "single":
touched.add(spec.ref.line);
for (const edit of edits) {
switch (edit.op) {
case "set":
touched.add(edit.tag.line);
break;
case "range":
for (let ln = spec.start.line; ln <= spec.end.line; ln++) touched.add(ln);
case "replace":
for (let ln = edit.first.line; ln <= edit.last.line; ln++) touched.add(ln);
break;
case "insertAfter":
touched.add(spec.after.line);
case "append":
if (edit.after) {
touched.add(edit.after.line);
}
break;
case "insertBefore":
touched.add(spec.before.line);
case "prepend":
if (edit.before) {
touched.add(edit.before.line);
}
break;
case "insertBetween":
touched.add(spec.after.line);
touched.add(spec.before.line);
break;
case "insertAtEof":
case "insert":
touched.add(edit.after.line);
touched.add(edit.before.line);
break;
}
}
@@ -755,59 +633,53 @@ export function applyHashlineEdits(
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
}
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
if (actualHash === ref.hash.toLowerCase()) {
if (actualHash === ref.hash) {
return true;
}
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
return false;
}
for (const { spec, dstLines } of parsed) {
switch (spec.kind) {
case "single": {
if (!validateRef(spec.ref)) continue;
for (const edit of edits) {
switch (edit.op) {
case "set": {
if (!validateRef(edit.tag)) continue;
break;
}
case "insertAfter": {
if (dstLines.length === 0) {
case "append": {
if (edit.content.length === 0) {
throw new Error('Insert-after edit (src "N#HH..") requires non-empty dst');
}
if (!validateRef(spec.after)) continue;
if (edit.after && !validateRef(edit.after)) continue;
break;
}
case "insertBefore": {
if (dstLines.length === 0) {
case "prepend": {
if (edit.content.length === 0) {
throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst');
}
if (!validateRef(spec.before)) continue;
if (edit.before && !validateRef(edit.before)) continue;
break;
}
case "insertBetween": {
if (dstLines.length === 0) {
case "insert": {
if (edit.content.length === 0) {
throw new Error('Insert-between edit (src "A#HH.. B#HH..") requires non-empty dst');
}
if (spec.before.line !== spec.after.line + 1) {
if (edit.before.line !== edit.after.line + 1) {
throw new Error(
`insert requires adjacent anchors (after ${spec.after.line}, before ${spec.before.line})`,
`insert requires adjacent anchors (after ${edit.after.line}, before ${edit.before.line})`,
);
}
const afterValid = validateRef(spec.after);
const beforeValid = validateRef(spec.before);
const afterValid = validateRef(edit.after);
const beforeValid = validateRef(edit.before);
if (!afterValid || !beforeValid) continue;
break;
}
case "insertAtEof": {
if (dstLines.length === 0) {
throw new Error("Insert-at-EOF edit requires non-empty dst");
}
break;
}
case "range": {
if (spec.start.line > spec.end.line) {
throw new Error(`Range start line ${spec.start.line} must be <= end line ${spec.end.line}`);
case "replace": {
if (edit.first.line > edit.last.line) {
throw new Error(`Range start line ${edit.first.line} must be <= end line ${edit.last.line}`);
}
const startValid = validateRef(spec.start);
const endValid = validateRef(spec.end);
const startValid = validateRef(edit.first);
const endValid = validateRef(edit.last);
if (!startValid || !endValid) continue;
break;
}
@@ -819,30 +691,35 @@ export function applyHashlineEdits(
// Deduplicate identical edits targeting the same line(s)
const seenEditKeys = new Map<string, number>();
const dedupIndices = new Set<number>();
for (let i = 0; i < parsed.length; i++) {
const p = parsed[i];
for (let i = 0; i < edits.length; i++) {
const edit = edits[i];
let lineKey: string;
switch (p.spec.kind) {
case "single":
lineKey = `s:${p.spec.ref.line}`;
switch (edit.op) {
case "set":
lineKey = `s:${edit.tag.line}`;
break;
case "range":
lineKey = `r:${p.spec.start.line}:${p.spec.end.line}`;
case "replace":
lineKey = `r:${edit.first.line}:${edit.last.line}`;
break;
case "insertAfter":
lineKey = `i:${p.spec.after.line}`;
break;
case "insertBefore":
lineKey = `ib:${p.spec.before.line}`;
break;
case "insertBetween":
lineKey = `ix:${p.spec.after.line}:${p.spec.before.line}`;
break;
case "insertAtEof":
case "append":
if (edit.after) {
lineKey = `i:${edit.after.line}`;
break;
}
lineKey = "ieof";
break;
case "prepend":
if (edit.before) {
lineKey = `ib:${edit.before.line}`;
break;
}
lineKey = "ibef";
break;
case "insert":
lineKey = `ix:${edit.after.line}:${edit.before.line}`;
break;
}
const dstKey = `${lineKey}|${p.dstLines.join("\n")}`;
const dstKey = `${lineKey}:${edit.content.join("\n")}`;
if (seenEditKeys.has(dstKey)) {
dedupIndices.add(i);
} else {
@@ -850,51 +727,47 @@ export function applyHashlineEdits(
}
}
if (dedupIndices.size > 0) {
for (let i = parsed.length - 1; i >= 0; i--) {
if (dedupIndices.has(i)) parsed.splice(i, 1);
for (let i = edits.length - 1; i >= 0; i--) {
if (dedupIndices.has(i)) edits.splice(i, 1);
}
}
// Compute sort key (descending) — bottom-up application
const annotated = parsed.map((p, idx) => {
const annotated = edits.map((edit, idx) => {
let sortLine: number;
let precedence: number;
switch (p.spec.kind) {
case "single":
sortLine = p.spec.ref.line;
switch (edit.op) {
case "set":
sortLine = edit.tag.line;
precedence = 0;
break;
case "range":
sortLine = p.spec.end.line;
case "replace":
sortLine = edit.last.line;
precedence = 0;
break;
case "insertAfter":
sortLine = p.spec.after.line;
case "append":
sortLine = edit.after ? edit.after.line : fileLines.length + 1;
precedence = 1;
break;
case "insertBefore":
sortLine = p.spec.before.line;
case "prepend":
sortLine = edit.before ? edit.before.line : 0;
precedence = 2;
break;
case "insertBetween":
sortLine = p.spec.before.line;
case "insert":
sortLine = edit.before.line;
precedence = 3;
break;
case "insertAtEof":
sortLine = fileLines.length + 1;
precedence = 4;
break;
}
return { ...p, idx, sortLine, precedence };
return { edit, idx, sortLine, precedence };
});
annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
// Apply edits bottom-up
for (const { spec, dstLines, idx } of annotated) {
switch (spec.kind) {
case "single": {
const merged = autocorrect ? maybeExpandSingleLineMerge(spec.ref.line, dstLines) : null;
for (const { edit, idx } of annotated) {
switch (edit.op) {
case "set": {
const merged = autocorrect ? maybeExpandSingleLineMerge(edit.tag.line, edit.content) : null;
if (merged) {
const origLines = originalFileLines.slice(
merged.startLine - 1,
@@ -902,16 +775,11 @@ export function applyHashlineEdits(
);
let nextLines = merged.newLines;
nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
if (
origLines.join("\n") === nextLines.join("\n") &&
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
) {
nextLines = normalizeConfusableHyphensInLines(nextLines);
}
if (origLines.join("\n") === nextLines.join("\n")) {
if (origLines.every((line, i) => line === nextLines[i])) {
noopEdits.push({
editIndex: idx,
loc: `${spec.ref.line}#${spec.ref.hash}`,
loc: `${edit.tag.line}#${edit.tag.hash}`,
currentContent: origLines.join("\n"),
});
break;
@@ -922,112 +790,113 @@ export function applyHashlineEdits(
}
const count = 1;
const origLines = originalFileLines.slice(spec.ref.line - 1, spec.ref.line);
const origLines = originalFileLines.slice(edit.tag.line - 1, edit.tag.line);
let stripped = autocorrect
? stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines)
: dstLines;
? stripRangeBoundaryEcho(originalFileLines, edit.tag.line, edit.tag.line, edit.content)
: edit.content;
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
if (
autocorrect &&
origLines.join("\n") === newLines.join("\n") &&
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
) {
newLines = normalizeConfusableHyphensInLines(newLines);
}
if (origLines.join("\n") === newLines.join("\n")) {
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
if (origLines.every((line, i) => line === newLines[i])) {
noopEdits.push({
editIndex: idx,
loc: `${spec.ref.line}#${spec.ref.hash}`,
loc: `${edit.tag.line}#${edit.tag.hash}`,
currentContent: origLines.join("\n"),
});
break;
}
fileLines.splice(spec.ref.line - 1, count, ...newLines);
trackFirstChanged(spec.ref.line);
fileLines.splice(edit.tag.line - 1, count, ...newLines);
trackFirstChanged(edit.tag.line);
break;
}
case "range": {
const count = spec.end.line - spec.start.line + 1;
const origLines = originalFileLines.slice(spec.start.line - 1, spec.start.line - 1 + count);
case "replace": {
const count = edit.last.line - edit.first.line + 1;
const origLines = originalFileLines.slice(edit.first.line - 1, edit.first.line - 1 + count);
let stripped = autocorrect
? stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines)
: dstLines;
? stripRangeBoundaryEcho(originalFileLines, edit.first.line, edit.last.line, edit.content)
: edit.content;
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
if (
autocorrect &&
origLines.join("\n") === newLines.join("\n") &&
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
) {
newLines = normalizeConfusableHyphensInLines(newLines);
}
if (origLines.join("\n") === newLines.join("\n")) {
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
if (autocorrect && origLines.every((line, i) => line === newLines[i])) {
noopEdits.push({
editIndex: idx,
loc: `${spec.start.line}#${spec.start.hash}`,
loc: `${edit.first.line}#${edit.first.hash}`,
currentContent: origLines.join("\n"),
});
break;
}
fileLines.splice(spec.start.line - 1, count, ...newLines);
trackFirstChanged(spec.start.line);
fileLines.splice(edit.first.line - 1, count, ...newLines);
trackFirstChanged(edit.first.line);
break;
}
case "insertAfter": {
const anchorLine = originalFileLines[spec.after.line - 1];
const inserted = autocorrect ? stripInsertAnchorEchoAfter(anchorLine, dstLines) : dstLines;
case "append": {
const inserted = edit.after
? autocorrect
? stripInsertAnchorEchoAfter(originalFileLines[edit.after.line - 1], edit.content)
: edit.content
: edit.content;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
loc: `${spec.after.line}#${spec.after.hash}`,
currentContent: originalFileLines[spec.after.line - 1],
loc: edit.after ? `${edit.after.line}#${edit.after.hash}` : "EOF",
currentContent: edit.after ? originalFileLines[edit.after.line - 1] : "",
});
break;
}
fileLines.splice(spec.after.line, 0, ...inserted);
trackFirstChanged(spec.after.line + 1);
if (edit.after) {
fileLines.splice(edit.after.line, 0, ...inserted);
trackFirstChanged(edit.after.line + 1);
} else {
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...inserted);
trackFirstChanged(1);
} else {
fileLines.splice(fileLines.length, 0, ...inserted);
trackFirstChanged(fileLines.length - inserted.length + 1);
}
}
break;
}
case "insertBefore": {
const anchorLine = originalFileLines[spec.before.line - 1];
const inserted = autocorrect ? stripInsertAnchorEchoBefore(anchorLine, dstLines) : dstLines;
case "prepend": {
const inserted = edit.before
? autocorrect
? stripInsertAnchorEchoBefore(originalFileLines[edit.before.line - 1], edit.content)
: edit.content
: edit.content;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
loc: `${spec.before.line}#${spec.before.hash}`,
currentContent: originalFileLines[spec.before.line - 1],
loc: edit.before ? `${edit.before.line}#${edit.before.hash}` : "BOF",
currentContent: edit.before ? originalFileLines[edit.before.line - 1] : "",
});
break;
}
fileLines.splice(spec.before.line - 1, 0, ...inserted);
trackFirstChanged(spec.before.line);
if (edit.before) {
fileLines.splice(edit.before.line - 1, 0, ...inserted);
trackFirstChanged(edit.before.line);
} else {
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...inserted);
} else {
fileLines.splice(0, 0, ...inserted);
}
trackFirstChanged(1);
}
break;
}
case "insertBetween": {
const afterLine = originalFileLines[spec.after.line - 1];
const beforeLine = originalFileLines[spec.before.line - 1];
const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, dstLines) : dstLines;
case "insert": {
const afterLine = originalFileLines[edit.after.line - 1];
const beforeLine = originalFileLines[edit.before.line - 1];
const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, edit.content) : edit.content;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
loc: `${spec.after.line}#${spec.after.hash}..${spec.before.line}#${spec.before.hash}`,
loc: `${edit.after.line}#${edit.after.hash}..${edit.before.line}#${edit.before.hash}`,
currentContent: `${afterLine}\n${beforeLine}`,
});
break;
}
fileLines.splice(spec.before.line - 1, 0, ...inserted);
trackFirstChanged(spec.before.line);
break;
}
case "insertAtEof": {
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...dstLines);
trackFirstChanged(1);
break;
}
fileLines.splice(fileLines.length, 0, ...dstLines);
trackFirstChanged(fileLines.length - dstLines.length + 1);
fileLines.splice(edit.before.line - 1, 0, ...inserted);
trackFirstChanged(edit.before.line);
break;
}
}
@@ -1047,12 +916,12 @@ export function applyHashlineEdits(
function maybeExpandSingleLineMerge(
line: number,
dst: string[],
content: string[],
): { startLine: number; deleteCount: number; newLines: string[] } | null {
if (dst.length !== 1) return null;
if (content.length !== 1) return null;
if (line < 1 || line > fileLines.length) return null;
const newLine = dst[0];
const newLine = content[0];
const newCanon = stripAllWhitespace(newLine);
const newCanonForMergeOps = stripMergeOperatorChars(newCanon);
if (newCanon.length === 0) return null;
+271 -160
View File
@@ -34,7 +34,14 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"
import { applyPatch } from "./applicator";
import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
import { findMatch } from "./fuzzy";
import { applyHashlineEdits, computeLineHash, parseLineRef } from "./hashline";
import {
applyHashlineEdits,
computeLineHash,
type HashlineEdit,
type LineTag,
parseTag,
type ReplaceTextEdit,
} from "./hashline";
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
import { buildNormativeUpdateInput } from "./normative";
import { type EditToolDetails, getLspBatchRequest } from "./shared";
@@ -66,7 +73,7 @@ export {
computeLineHash,
formatHashLines,
HashlineMismatchError,
parseLineRef,
parseTag,
streamHashLinesFromLines,
streamHashLinesFromUtf8,
validateLineRef,
@@ -128,26 +135,68 @@ const patchEditSchema = Type.Object({
export type ReplaceParams = Static<typeof replaceEditSchema>;
export type PatchParams = Static<typeof patchEditSchema>;
type HashlineEditUnion = Static<typeof hashlineEditItemSchemaWithReplace>;
type HashlineReplaceEdit = { replace: { old_text: string; new_text: string; all?: boolean } };
export type HashlineEdit =
| { set: { ref: string; body: string[] } }
| { set_range: { beg: string; end: string; body: string[] } }
| { insert: { before?: string; after?: string; body: string[] } }
| HashlineReplaceEdit;
/** Pattern matching hashline display format: `LINE#ID:CONTENT` */
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}:/;
const hashlineContentFormat = (kind: string) =>
/** Pattern matching a unified-diff `+` prefix (but not `++`) */
const DIFF_PLUS_RE = /^[+-](?![+-])/;
/**
* Strip hashline display prefixes and diff `+` markers from replacement lines.
*
* Models frequently copy the `LINE#ID ` prefix from read output into their
* replacement content, or include unified-diff `+` prefixes. Both corrupt the
* output file. This strips them heuristically before application.
*/
function stripNewLinePrefixes(lines: string[]): string[] {
// Detect whether the *majority* of non-empty lines carry a prefix —
// if only one line out of many has a match it's likely real content.
let hashPrefixCount = 0;
let diffPlusCount = 0;
let nonEmpty = 0;
for (const l of lines) {
if (l.length === 0) continue;
nonEmpty++;
if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
}
if (nonEmpty === 0) return lines;
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5;
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
if (!stripHash && !stripPlus) return lines;
return lines.map(l => {
if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
return l;
});
}
const hashlineReplaceContentFormat = (kind: string) =>
Type.Union([
Type.Null(),
Type.Array(Type.String(), { description: `${kind} lines` }),
Type.String({ description: `${kind} line` }),
]);
const hashlineInsertContentFormat = (kind: string) =>
Type.Union([
Type.Array(Type.String(), { description: `${kind} lines`, minItems: 1 }),
Type.String({ description: `${kind} line`, minLength: 1 }),
]);
const hashlineTagFormat = (what: string) =>
Type.String({
description: `Tag identifying the ${what} in "LINE#ID" format`,
});
function hashlineParseContent(edit: string | string[] | null): string[] {
if (edit === null) return [];
if (Array.isArray(edit)) return edit;
const lines = edit.split("\n");
const lines = stripNewLinePrefixes(edit.split("\n"));
if (lines.length === 0) return [];
if (lines[lines.length - 1].trim() === "") return lines.slice(0, -1);
return lines;
@@ -159,113 +208,89 @@ function hashlineParseContentString(edit: string | string[] | null): string {
return edit;
}
const hashlineTargetEditSchema = Type.Object({
target: Type.String({ description: 'Line reference "LINE#ID"' }),
new_content: hashlineContentFormat("Replacement"),
});
const hashlineTargetEditSchema = Type.Object(
{
op: Type.Literal("set"),
tag: hashlineTagFormat("line being replaced"),
content: hashlineReplaceContentFormat("Replacement"),
},
{ additionalProperties: false },
);
function hashlineParseTargetEdit(edit: HashlineEditUnion): HashlineEdit | null {
if ("target" in edit) {
return {
set: {
ref: edit.target,
body: hashlineParseContent(edit.new_content),
},
};
}
return null;
}
const hashlineAppendEditSchema = Type.Object(
{
op: Type.Literal("append"),
after: Type.Optional(hashlineTagFormat("line after which to append")),
content: hashlineInsertContentFormat("Appended"),
},
{ additionalProperties: false },
);
const hashlineRangeEditSchema = Type.Object({
first: Type.String({ description: 'Start line ref "LINE#ID"' }),
last: Type.String({ description: 'End line ref "LINE#ID"' }),
new_content: hashlineContentFormat("Replacement"),
});
const hashlinePrependEditSchema = Type.Object(
{
op: Type.Literal("prepend"),
before: Type.Optional(hashlineTagFormat("line before which to prepend")),
content: hashlineInsertContentFormat("Prepended"),
},
{ additionalProperties: false },
);
function hashlineParseRangeEdit(edit: HashlineEditUnion): HashlineEdit | null {
if ("first" in edit && "last" in edit) {
return {
set_range: {
beg: edit.first,
end: edit.last,
body: hashlineParseContent(edit.new_content),
},
};
}
return null;
}
const hashlineRangeEditSchema = Type.Object(
{
op: Type.Literal("replace"),
first: hashlineTagFormat("first line"),
last: hashlineTagFormat("last line"),
content: hashlineReplaceContentFormat("Replacement"),
},
{ additionalProperties: false },
);
const hashlineInsertEditSchema = Type.Object({
before: Type.Optional(Type.String({ description: 'Insert before this line "LINE#ID"' })),
after: Type.Optional(Type.String({ description: 'Insert after this line "LINE#ID"' })),
inserted_lines: hashlineContentFormat("Inserted"),
});
const hashlineInsertEditSchema = Type.Object(
{
op: Type.Literal("insert"),
before: Type.Optional(hashlineTagFormat("line before which to insert")),
after: Type.Optional(hashlineTagFormat("line after which to insert")),
content: hashlineInsertContentFormat("Inserted"),
},
{ additionalProperties: false },
);
function hashlineParseInsertEdit(edit: HashlineEditUnion): HashlineEdit | null {
if ("inserted_lines" in edit) {
const { before = "", after = "", inserted_lines } = edit;
return {
insert: {
before: before.length > 0 ? before : undefined,
after: after.length > 0 ? after : undefined,
body: hashlineParseContent(inserted_lines),
},
};
}
return null;
}
const hashlineReplaceTextEditSchema = Type.Object(
{
op: Type.Literal("replaceText"),
old_text: Type.String({ description: "Text to find", minLength: 1 }),
new_text: hashlineReplaceContentFormat("Replacement"),
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences" })),
},
{ additionalProperties: false },
);
const hashlineReplaceTextEditSchema = Type.Object({
old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
new_text: hashlineContentFormat("Replacement"),
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
});
const HL_REPLACE_ENABLED = Bun.env.PI_HL_REPLACETXT === "1";
function hashlineParseReplaceTextEdit(edit: HashlineEditUnion): HashlineReplaceEdit | null {
if ("old_text" in edit && "new_text" in edit) {
const { old_text, new_text, all } = edit;
return {
replace: {
old_text: old_text,
new_text: hashlineParseContentString(new_text),
all: all,
},
};
}
return null;
}
const hashlineEditItemSchema = Type.Union([
const hashlineEditSpecSchema = Type.Union([
hashlineTargetEditSchema,
hashlineRangeEditSchema,
hashlineAppendEditSchema,
hashlinePrependEditSchema,
hashlineInsertEditSchema,
]);
const hashlineEditItemSchemaWithReplace = Type.Union([
hashlineTargetEditSchema,
hashlineRangeEditSchema,
hashlineInsertEditSchema,
hashlineReplaceTextEditSchema,
...(HL_REPLACE_ENABLED ? [hashlineReplaceTextEditSchema] : []),
]);
const hashlineEditSchema = Type.Object({
path: Type.String({ description: "File path (relative or absolute)" }),
edits: Type.Array(hashlineEditItemSchema, { description: "Array of edit operations" }),
delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })),
rename: Type.Optional(Type.String({ description: "New path for move" })),
});
const hashlineEditSchemaWithReplace = Type.Object({
path: Type.String({ description: "File path (relative or absolute)" }),
edits: Type.Array(hashlineEditItemSchemaWithReplace, { description: "Array of edit operations" }),
delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })),
rename: Type.Optional(Type.String({ description: "New path for move" })),
});
const hashlineEditSchema = Type.Object(
{
path: Type.String({ description: "File path (relative or absolute)" }),
edits: Type.Array(hashlineEditSpecSchema, {
description: "Changes to apply to the file at `path`",
minItems: 0,
}),
delete: Type.Optional(Type.Boolean({ description: "Delete the file when true" })),
rename: Type.Optional(Type.String({ description: "New path if moving" })),
},
{ additionalProperties: false },
);
export type HashlineToolEdit = Static<typeof hashlineEditItemSchemaWithReplace>;
export type HashlineParams = Static<typeof hashlineEditSchemaWithReplace>;
function isHashlineReplaceTextEnabled(): boolean {
return Bun.env.PI_HL_REPLACETXT === "1";
}
export type HashlineToolEdit = Static<typeof hashlineEditSpecSchema>;
export type HashlineParams = Static<typeof hashlineEditSchema>;
// ═══════════════════════════════════════════════════════════════════════════
// LSP FileSystem for patch mode
@@ -355,11 +380,7 @@ function mergeDiagnosticsWithWarnings(
// Tool Class
// ═══════════════════════════════════════════════════════════════════════════
type TInput =
| typeof replaceEditSchema
| typeof patchEditSchema
| typeof hashlineEditSchema
| typeof hashlineEditSchemaWithReplace;
type TInput = typeof replaceEditSchema | typeof patchEditSchema | typeof hashlineEditSchema;
export type EditMode = "replace" | "patch" | "hashline";
@@ -465,7 +486,7 @@ export class EditTool implements AgentTool<TInput> {
case "patch":
return renderPromptTemplate(patchDescription);
case "hashline":
return renderPromptTemplate(hashlineDescription, { allowReplaceText: isHashlineReplaceTextEnabled() });
return renderPromptTemplate(hashlineDescription, { allowReplaceText: HL_REPLACE_ENABLED });
default:
return renderPromptTemplate(replaceDescription);
}
@@ -479,7 +500,7 @@ export class EditTool implements AgentTool<TInput> {
case "patch":
return patchEditSchema;
case "hashline":
return isHashlineReplaceTextEnabled() ? hashlineEditSchemaWithReplace : hashlineEditSchema;
return hashlineEditSchema;
default:
return replaceEditSchema;
}
@@ -499,9 +520,8 @@ export class EditTool implements AgentTool<TInput> {
// ─────────────────────────────────────────────────────────────────
if (this.mode === "hashline") {
const { path, edits, delete: deleteFile, rename } = params as HashlineParams;
const op: Operation = deleteFile === true ? "delete" : "update";
enforcePlanModeWrite(this.session, path, { op, rename });
enforcePlanModeWrite(this.session, path, { op: deleteFile ? "delete" : "update", rename });
if (path.endsWith(".ipynb") && edits?.length > 0) {
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
@@ -511,7 +531,7 @@ export class EditTool implements AgentTool<TInput> {
const resolvedRename = rename ? resolvePlanPath(this.session, rename) : undefined;
const file = Bun.file(absolutePath);
if (deleteFile === true) {
if (deleteFile) {
if (await file.exists()) {
await file.unlink();
}
@@ -527,30 +547,115 @@ export class EditTool implements AgentTool<TInput> {
}
if (!(await file.exists())) {
throw new Error(`File not found: ${path}`);
}
const allowReplaceText = isHashlineReplaceTextEnabled();
const anchorEdits: HashlineEdit[] = [];
const replaceEdits: HashlineReplaceEdit[] = [];
for (let i = 0; i < edits.length; i++) {
const edit = edits[i] as HashlineEditUnion;
const anchorEdit =
hashlineParseTargetEdit(edit) ?? hashlineParseRangeEdit(edit) ?? hashlineParseInsertEdit(edit);
if (!anchorEdit) {
if (allowReplaceText) {
const replaceEdit = hashlineParseReplaceTextEdit(edit);
if (replaceEdit) {
replaceEdits.push(replaceEdit);
continue;
const content: string[] = [];
for (const edit of edits) {
switch (edit.op) {
case "append": {
if (edit.after) {
throw new Error(`File not found: ${path}`);
}
content.push(...hashlineParseContent(edit.content));
break;
}
case "prepend": {
if (edit.before) {
throw new Error(`File not found: ${path}`);
}
content.unshift(...hashlineParseContent(edit.content));
break;
}
default: {
throw new Error(`File not found: ${path}`);
}
}
throw new Error(
`edits[${i}] must match exactly one variant: {target,new_content} | {first,last,new_content} | {before/after,inserted_lines}${allowReplaceText ? " | {old_text,new_text}" : ""}. Got keys: [${Object.keys(edit).join(", ")}].`,
);
}
anchorEdits.push(anchorEdit);
await file.write(content.join("\n"));
return {
content: [{ type: "text", text: `Created ${path}` }],
details: {
diff: "",
op: "create",
meta: outputMeta().get(),
},
};
}
const anchorEdits: HashlineEdit[] = [];
const replaceEdits: ReplaceTextEdit[] = [];
for (const edit of edits) {
switch (edit.op) {
case "set": {
const { tag, content } = edit;
anchorEdits.push({ op: "set", tag: parseTag(tag), content: hashlineParseContent(content) });
break;
}
case "replace": {
const { first, last, content } = edit;
anchorEdits.push({
op: "replace",
first: parseTag(first),
last: parseTag(last),
content: hashlineParseContent(content),
});
break;
}
case "append": {
const { after, content } = edit;
anchorEdits.push({
op: "append",
...(after ? { after: parseTag(after) } : {}),
content: hashlineParseContent(content),
});
break;
}
case "prepend": {
const { before, content } = edit;
anchorEdits.push({
op: "prepend",
...(before ? { before: parseTag(before) } : {}),
content: hashlineParseContent(content),
});
break;
}
case "insert": {
const { before, after, content } = edit;
if (before && !after) {
anchorEdits.push({
op: "prepend",
before: parseTag(before),
content: hashlineParseContent(content),
});
} else if (after && !before) {
anchorEdits.push({
op: "append",
after: parseTag(after),
content: hashlineParseContent(content),
});
} else if (before && after) {
anchorEdits.push({
op: "insert",
before: parseTag(before),
after: parseTag(after),
content: hashlineParseContent(content),
});
} else {
throw new Error(`Insert must have both before and after tags.`);
}
break;
}
case "replaceText": {
const { old_text, new_text, all } = edit;
replaceEdits.push({
op: "replaceText",
old_text: old_text,
new_text: hashlineParseContentString(new_text),
all: all ?? false,
});
break;
}
default:
throw new Error(`Invalid edit operation: ${JSON.stringify(edit)}`);
}
}
const rawContent = await file.text();
@@ -565,12 +670,12 @@ export class EditTool implements AgentTool<TInput> {
// Apply content-replace edits (substr-style fuzzy replace)
for (const r of replaceEdits) {
if (r.replace.old_text.length === 0) {
throw new Error("replace.old_text must not be empty.");
if (r.old_text.length === 0) {
throw new Error("old_text must not be empty.");
}
const rep = replaceText(normalizedContent, r.replace.old_text, r.replace.new_text, {
const rep = replaceText(normalizedContent, r.old_text, r.new_text, {
fuzzy: this.#allowFuzzy,
all: r.replace.all ?? false,
all: r.all ?? false,
threshold: this.#fuzzyThreshold,
});
normalizedContent = rep.content;
@@ -598,21 +703,35 @@ export class EditTool implements AgentTool<TInput> {
// Edits were not literally identical but heuristics normalized them back
const lines = result.content.split("\n");
const targetLines: string[] = [];
const refs: LineTag[] = [];
for (const edit of anchorEdits) {
const refs: string[] = [];
if ("set" in edit) refs.push(edit.set.ref);
else if ("set_range" in edit) refs.push(edit.set_range.beg, edit.set_range.end);
else if ("insert" in edit) {
if (edit.insert.after) refs.push(edit.insert.after);
if (edit.insert.before) refs.push(edit.insert.before);
refs.length = 0;
switch (edit.op) {
case "set":
refs.push(edit.tag);
break;
case "replace":
refs.push(edit.first, edit.last);
break;
case "append":
if (edit.after) refs.push(edit.after);
break;
case "prepend":
if (edit.before) refs.push(edit.before);
break;
case "insert":
refs.push(edit.after, edit.before);
break;
default:
break;
}
for (const ref of refs) {
try {
const parsed = parseLineRef(ref);
if (parsed.line >= 1 && parsed.line <= lines.length) {
const lineContent = lines[parsed.line - 1];
const hash = computeLineHash(parsed.line, lineContent);
targetLines.push(`${parsed.line}#${hash}|${lineContent}`);
if (ref.line >= 1 && ref.line <= lines.length) {
const lineContent = lines[ref.line - 1];
const hash = computeLineHash(ref.line, lineContent);
targetLines.push(`${ref.line}#${hash}:${lineContent}`);
}
} catch {
/* skip malformed refs */
@@ -667,7 +786,7 @@ export class EditTool implements AgentTool<TInput> {
diff: diffResult.diff,
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
diagnostics,
op,
op: "update",
rename,
meta,
},
@@ -714,17 +833,10 @@ export class EditTool implements AgentTool<TInput> {
// Generate diff for display
let diffResult = { diff: "", firstChangedLine: undefined as number | undefined };
let normative: PatchInput | undefined;
if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew);
normative = buildNormativeUpdateInput({
path,
rename: effRename,
oldContent: result.change.oldContent,
newContent: result.change.newContent,
});
}
let resultText: string;
@@ -762,7 +874,6 @@ export class EditTool implements AgentTool<TInput> {
rename: effRename,
meta,
},
$normative: normative,
};
}