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:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user