feat: introduced hashline API redesign with structured operations and LINE#ID format
- Redesigned hashline edit API with new operation names (set, set_range, insert) and structured body parameter accepting string arrays for multiline edits. - Changed hashline reference format from LINE:HASH to LINE#ID throughout tools and documentation for improved clarity. - Enhanced insert operation to support optional before/after anchors enabling flexible insertion positioning and boundary echo stripping. - Made hashline autocorrect heuristics conditional on PI_HL_AUTOCORRECT environment variable for controlled behavior. - Added benchmark reports for claude-haiku-4-5 and GPT-5.2-Codex models demonstrating hashline edit variant performance.
This commit is contained in:
@@ -4,12 +4,12 @@
|
||||
* 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).
|
||||
* The combined `LINE:HASH` reference acts as both an address and a staleness check:
|
||||
* 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#a3f2"`)
|
||||
*/
|
||||
|
||||
import type { HashlineEdit } from "./index";
|
||||
@@ -18,45 +18,84 @@ 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: "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; dst: string } {
|
||||
if ("set_line" in edit) {
|
||||
function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dstLines: string[] } {
|
||||
if ("set" in edit) {
|
||||
return {
|
||||
spec: { kind: "single", ref: parseLineRef(edit.set_line.anchor) },
|
||||
dst: edit.set_line.new_text,
|
||||
spec: { kind: "single", ref: parseLineRef(edit.set.ref) },
|
||||
dstLines: edit.set.body,
|
||||
};
|
||||
}
|
||||
if ("replace_lines" in edit) {
|
||||
const r = edit.replace_lines as Record<string, string>;
|
||||
const start = parseLineRef(r.start_anchor);
|
||||
if (!r.end_anchor) {
|
||||
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 },
|
||||
dst: r.new_text ?? "",
|
||||
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
|
||||
};
|
||||
}
|
||||
const end = parseLineRef(r.end_anchor);
|
||||
const end = parseLineRef(r.end as string);
|
||||
return {
|
||||
spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end },
|
||||
dst: r.new_text ?? "",
|
||||
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");
|
||||
}
|
||||
return {
|
||||
spec: { kind: "insertAfter", after: parseLineRef(edit.insert_after.anchor) },
|
||||
dst: edit.insert_after.text ?? (edit.insert_after as Record<string, string>).content ?? "",
|
||||
};
|
||||
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:HASH|CONTENT` */
|
||||
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+:[0-9a-zA-Z]{1,16}\|/;
|
||||
/** 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 = /^\+(?!\+)/;
|
||||
@@ -179,6 +218,25 @@ function stripInsertAnchorEchoAfter(anchorLine: string, dstLines: string[]): str
|
||||
return dstLines;
|
||||
}
|
||||
|
||||
function stripInsertAnchorEchoBefore(anchorLine: string, dstLines: string[]): string[] {
|
||||
if (dstLines.length <= 1) return dstLines;
|
||||
if (equalsIgnoringWhitespace(dstLines[dstLines.length - 1], anchorLine)) {
|
||||
return dstLines.slice(0, -1);
|
||||
}
|
||||
return dstLines;
|
||||
}
|
||||
|
||||
function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, dstLines: string[]): string[] {
|
||||
let out = dstLines;
|
||||
if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) {
|
||||
out = out.slice(1);
|
||||
}
|
||||
if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) {
|
||||
out = out.slice(0, -1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] {
|
||||
// Only strip when the model replaced with multiple lines and grew the edit.
|
||||
// This avoids turning a single-line replacement into a deletion.
|
||||
@@ -206,7 +264,7 @@ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine:
|
||||
/**
|
||||
* Strip hashline display prefixes and diff `+` markers from replacement lines.
|
||||
*
|
||||
* Models frequently copy the `LINE:HASH ` prefix from read output into their
|
||||
* 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.
|
||||
*/
|
||||
@@ -262,7 +320,7 @@ export function computeLineHash(idx: number, line: string): string {
|
||||
/**
|
||||
* 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)
|
||||
@@ -271,7 +329,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 {
|
||||
@@ -280,7 +338,7 @@ export function formatHashLines(content: string, startLine = 1): string {
|
||||
.map((line, i) => {
|
||||
const num = startLine + i;
|
||||
const hash = computeLineHash(num, line);
|
||||
return `${num}:${hash}|${line}`;
|
||||
return `${num}#${hash}|${line}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
@@ -352,7 +410,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[] = [];
|
||||
@@ -446,7 +504,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[] = [];
|
||||
@@ -498,24 +556,24 @@ export async function* streamHashLinesFromLines(
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a line reference string like `"5:abcd"` into structured form.
|
||||
* Parse a line reference string like `"5#abcd"` into structured form.
|
||||
*
|
||||
* @throws Error if the format is invalid (not `NUMBER:HEXHASH`)
|
||||
* @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"
|
||||
// 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 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;
|
||||
if (!match) {
|
||||
throw new Error(`Invalid line reference "${ref}". Expected format "LINE:HASH" (e.g. "5:aa").`);
|
||||
throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`);
|
||||
}
|
||||
const line = Number.parseInt(match[1], 10);
|
||||
if (line < 1) {
|
||||
@@ -535,7 +593,7 @@ const MISMATCH_CONTEXT = 2;
|
||||
* Error thrown when one or more hashline references have stale hashes.
|
||||
*
|
||||
* Displays grep-style output with `>>>` markers on mismatched lines,
|
||||
* showing the correct `LINE:HASH` so the caller can fix all refs at once.
|
||||
* showing the correct `LINE#ID` so the caller can fix all refs at once.
|
||||
*/
|
||||
export class HashlineMismatchError extends Error {
|
||||
readonly remaps: ReadonlyMap<string, string>;
|
||||
@@ -548,7 +606,7 @@ export class HashlineMismatchError extends Error {
|
||||
const remaps = new Map<string, string>();
|
||||
for (const m of mismatches) {
|
||||
const actual = computeLineHash(m.line, fileLines[m.line - 1]);
|
||||
remaps.set(`${m.line}:${m.expected}`, `${m.line}:${actual}`);
|
||||
remaps.set(`${m.line}#${m.expected}`, `${m.line}#${actual}`);
|
||||
}
|
||||
this.remaps = remaps;
|
||||
}
|
||||
@@ -573,7 +631,7 @@ export class HashlineMismatchError extends Error {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(
|
||||
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE:HASH references shown below (>>> marks changed lines).`,
|
||||
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE#ID references shown below (>>> marks changed lines).`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
@@ -587,7 +645,7 @@ export class HashlineMismatchError extends Error {
|
||||
|
||||
const content = fileLines[lineNum - 1];
|
||||
const hash = computeLineHash(lineNum, content);
|
||||
const prefix = `${lineNum}:${hash}`;
|
||||
const prefix = `${lineNum}#${hash}`;
|
||||
|
||||
if (mismatchSet.has(lineNum)) {
|
||||
lines.push(`>>> ${prefix}|${content}`);
|
||||
@@ -624,8 +682,8 @@ 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_line`, `replace_lines`,
|
||||
* `insert_after`). Line references are resolved via {@link parseLineRef}
|
||||
* Each edit operation identifies target lines directly (`set`, `set_range`,
|
||||
* `insert`). Line references are resolved via {@link parseLineRef}
|
||||
* and hashes validated before any mutation.
|
||||
*
|
||||
* Edits are sorted bottom-up (highest effective line first) so earlier
|
||||
@@ -651,12 +709,14 @@ export function applyHashlineEdits(
|
||||
let firstChangedLine: number | undefined;
|
||||
const noopEdits: Array<{ editIndex: number; loc: string; currentContent: string }> = [];
|
||||
|
||||
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(splitDstLines(parsedEdit.dst)),
|
||||
dstLines: stripNewLinePrefixes(parsedEdit.dstLines),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -673,6 +733,15 @@ export function applyHashlineEdits(
|
||||
case "insertAfter":
|
||||
touched.add(spec.after.line);
|
||||
break;
|
||||
case "insertBefore":
|
||||
touched.add(spec.before.line);
|
||||
break;
|
||||
case "insertBetween":
|
||||
touched.add(spec.after.line);
|
||||
touched.add(spec.before.line);
|
||||
break;
|
||||
case "insertAtEof":
|
||||
break;
|
||||
}
|
||||
}
|
||||
return touched;
|
||||
@@ -700,11 +769,38 @@ export function applyHashlineEdits(
|
||||
}
|
||||
case "insertAfter": {
|
||||
if (dstLines.length === 0) {
|
||||
throw new Error('Insert-after edit (src "N:HH..") requires non-empty dst');
|
||||
throw new Error('Insert-after edit (src "N#HH..") requires non-empty dst');
|
||||
}
|
||||
if (!validateRef(spec.after)) continue;
|
||||
break;
|
||||
}
|
||||
case "insertBefore": {
|
||||
if (dstLines.length === 0) {
|
||||
throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst');
|
||||
}
|
||||
if (!validateRef(spec.before)) continue;
|
||||
break;
|
||||
}
|
||||
case "insertBetween": {
|
||||
if (dstLines.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) {
|
||||
throw new Error(
|
||||
`insert requires adjacent anchors (after ${spec.after.line}, before ${spec.before.line})`,
|
||||
);
|
||||
}
|
||||
const afterValid = validateRef(spec.after);
|
||||
const beforeValid = validateRef(spec.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}`);
|
||||
@@ -736,6 +832,15 @@ export function applyHashlineEdits(
|
||||
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":
|
||||
lineKey = "ieof";
|
||||
break;
|
||||
}
|
||||
const dstKey = `${lineKey}|${p.dstLines.join("\n")}`;
|
||||
if (seenEditKeys.has(dstKey)) {
|
||||
@@ -767,6 +872,18 @@ export function applyHashlineEdits(
|
||||
sortLine = p.spec.after.line;
|
||||
precedence = 1;
|
||||
break;
|
||||
case "insertBefore":
|
||||
sortLine = p.spec.before.line;
|
||||
precedence = 2;
|
||||
break;
|
||||
case "insertBetween":
|
||||
sortLine = p.spec.before.line;
|
||||
precedence = 3;
|
||||
break;
|
||||
case "insertAtEof":
|
||||
sortLine = fileLines.length + 1;
|
||||
precedence = 4;
|
||||
break;
|
||||
}
|
||||
return { ...p, idx, sortLine, precedence };
|
||||
});
|
||||
@@ -777,7 +894,7 @@ export function applyHashlineEdits(
|
||||
for (const { spec, dstLines, idx } of annotated) {
|
||||
switch (spec.kind) {
|
||||
case "single": {
|
||||
const merged = maybeExpandSingleLineMerge(spec.ref.line, dstLines);
|
||||
const merged = autocorrect ? maybeExpandSingleLineMerge(spec.ref.line, dstLines) : null;
|
||||
if (merged) {
|
||||
const origLines = originalFileLines.slice(
|
||||
merged.startLine - 1,
|
||||
@@ -794,7 +911,7 @@ export function applyHashlineEdits(
|
||||
if (origLines.join("\n") === nextLines.join("\n")) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.ref.line}:${spec.ref.hash}`,
|
||||
loc: `${spec.ref.line}#${spec.ref.hash}`,
|
||||
currentContent: origLines.join("\n"),
|
||||
});
|
||||
break;
|
||||
@@ -806,16 +923,22 @@ export function applyHashlineEdits(
|
||||
|
||||
const count = 1;
|
||||
const origLines = originalFileLines.slice(spec.ref.line - 1, spec.ref.line);
|
||||
let stripped = stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines);
|
||||
stripped = restoreOldWrappedLines(origLines, stripped);
|
||||
let newLines = restoreIndentForPairedReplacement(origLines, stripped);
|
||||
if (origLines.join("\n") === newLines.join("\n") && origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))) {
|
||||
let stripped = autocorrect
|
||||
? stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines)
|
||||
: dstLines;
|
||||
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")) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.ref.line}:${spec.ref.hash}`,
|
||||
loc: `${spec.ref.line}#${spec.ref.hash}`,
|
||||
currentContent: origLines.join("\n"),
|
||||
});
|
||||
break;
|
||||
@@ -827,16 +950,22 @@ export function applyHashlineEdits(
|
||||
case "range": {
|
||||
const count = spec.end.line - spec.start.line + 1;
|
||||
const origLines = originalFileLines.slice(spec.start.line - 1, spec.start.line - 1 + count);
|
||||
let stripped = stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines);
|
||||
stripped = restoreOldWrappedLines(origLines, stripped);
|
||||
let newLines = restoreIndentForPairedReplacement(origLines, stripped);
|
||||
if (origLines.join("\n") === newLines.join("\n") && origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))) {
|
||||
let stripped = autocorrect
|
||||
? stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines)
|
||||
: dstLines;
|
||||
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")) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.start.line}:${spec.start.hash}`,
|
||||
loc: `${spec.start.line}#${spec.start.hash}`,
|
||||
currentContent: origLines.join("\n"),
|
||||
});
|
||||
break;
|
||||
@@ -847,11 +976,11 @@ export function applyHashlineEdits(
|
||||
}
|
||||
case "insertAfter": {
|
||||
const anchorLine = originalFileLines[spec.after.line - 1];
|
||||
const inserted = stripInsertAnchorEchoAfter(anchorLine, dstLines);
|
||||
const inserted = autocorrect ? stripInsertAnchorEchoAfter(anchorLine, dstLines) : dstLines;
|
||||
if (inserted.length === 0) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.after.line}:${spec.after.hash}`,
|
||||
loc: `${spec.after.line}#${spec.after.hash}`,
|
||||
currentContent: originalFileLines[spec.after.line - 1],
|
||||
});
|
||||
break;
|
||||
@@ -860,23 +989,53 @@ export function applyHashlineEdits(
|
||||
trackFirstChanged(spec.after.line + 1);
|
||||
break;
|
||||
}
|
||||
case "insertBefore": {
|
||||
const anchorLine = originalFileLines[spec.before.line - 1];
|
||||
const inserted = autocorrect ? stripInsertAnchorEchoBefore(anchorLine, dstLines) : dstLines;
|
||||
if (inserted.length === 0) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.before.line}#${spec.before.hash}`,
|
||||
currentContent: originalFileLines[spec.before.line - 1],
|
||||
});
|
||||
break;
|
||||
}
|
||||
fileLines.splice(spec.before.line - 1, 0, ...inserted);
|
||||
trackFirstChanged(spec.before.line);
|
||||
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;
|
||||
if (inserted.length === 0) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.after.line}#${spec.after.hash}..${spec.before.line}#${spec.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);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
let diffLineCount = Math.abs(fileLines.length - originalFileLines.length);
|
||||
for (let i = 0; i < Math.min(fileLines.length, originalFileLines.length); i++) {
|
||||
if (fileLines[i] !== originalFileLines[i]) diffLineCount++;
|
||||
}
|
||||
if (diffLineCount > edits.length * 4) {
|
||||
warnings.push(
|
||||
`Edit changed ${diffLineCount} lines across ${edits.length} operations — verify no unintended reformatting.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
content: fileLines.join("\n"),
|
||||
firstChangedLine,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
...(noopEdits.length > 0 ? { noopEdits } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -129,35 +129,38 @@ const patchEditSchema = Type.Object({
|
||||
export type ReplaceParams = Static<typeof replaceEditSchema>;
|
||||
export type PatchParams = Static<typeof patchEditSchema>;
|
||||
|
||||
const hashlineSingleSchema = Type.Object(
|
||||
const hashlineSetSchema = Type.Object(
|
||||
{
|
||||
set_line: Type.Object({
|
||||
anchor: Type.String({ description: 'Line reference "LINE:HASH"' }),
|
||||
new_text: Type.String({ description: 'Replacement content (\\n-separated) — "" for delete' }),
|
||||
set: Type.Object({
|
||||
ref: Type.String({ description: 'Line reference "LINE#ID"' }),
|
||||
body: Type.Array(Type.String(), { description: "Replacement lines (empty array to delete)" }),
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
);
|
||||
|
||||
const hashlineRangeSchema = Type.Object(
|
||||
const hashlineSetRangeSchema = Type.Object(
|
||||
{
|
||||
replace_lines: Type.Object({
|
||||
start_anchor: Type.String({ description: 'Start line ref "LINE:HASH"' }),
|
||||
end_anchor: Type.String({ description: 'End line ref "LINE:HASH"' }),
|
||||
new_text: Type.String({ description: 'Replacement content (\\n-separated) — "" for delete' }),
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
);
|
||||
const hashlineInsertAfterSchema = Type.Object(
|
||||
{
|
||||
insert_after: Type.Object({
|
||||
anchor: Type.String({ description: 'Insert after this line "LINE:HASH"' }),
|
||||
text: Type.String({ description: "Content to insert (\\n-separated); must be non-empty" }),
|
||||
set_range: Type.Object({
|
||||
beg: Type.String({ description: 'Start line ref "LINE#ID"' }),
|
||||
end: Type.String({ description: 'End line ref "LINE#ID"' }),
|
||||
body: Type.Array(Type.String(), { description: "Replacement lines (empty array to delete)" }),
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
);
|
||||
const hashlineInsertSchema = Type.Union([
|
||||
Type.Object(
|
||||
{
|
||||
insert: Type.Object({
|
||||
before: Type.Optional(Type.String({ minLength: 1, description: 'Insert before this line "LINE#ID"' })),
|
||||
after: Type.Optional(Type.String({ minLength: 1, description: 'Insert after this line "LINE#ID"' })),
|
||||
body: Type.Array(Type.String(), { description: "Lines to insert; must be non-empty" }),
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
]);
|
||||
const hashlineReplaceSchema = Type.Object(
|
||||
{
|
||||
replace: Type.Object({
|
||||
@@ -169,9 +172,9 @@ const hashlineReplaceSchema = Type.Object(
|
||||
{ additionalProperties: true },
|
||||
);
|
||||
const hashlineEditItemSchema = Type.Union([
|
||||
hashlineSingleSchema,
|
||||
hashlineRangeSchema,
|
||||
hashlineInsertAfterSchema,
|
||||
hashlineSetSchema,
|
||||
hashlineSetRangeSchema,
|
||||
hashlineInsertSchema,
|
||||
hashlineReplaceSchema,
|
||||
]);
|
||||
const hashlineEditSchema = Type.Object(
|
||||
@@ -426,30 +429,23 @@ export class EditTool implements AgentTool<TInput> {
|
||||
if (("old_text" in edit || "new_text" in edit) && !("replace" in edit)) {
|
||||
throw new Error(
|
||||
`edits[${i}] contains 'old_text'/'new_text' at top level (replace mode). ` +
|
||||
`Use {replace: {old_text, new_text}} for hashline content replace, or {set_line}, {replace_lines}, {insert_after}.`,
|
||||
`Use {replace: {old_text, new_text}} for hashline content replace, or {set}, {set_range}, {insert}.`,
|
||||
);
|
||||
}
|
||||
if ("diff" in edit) {
|
||||
throw new Error(
|
||||
`edits[${i}] contains 'diff' field from patch mode. ` +
|
||||
`Hashline edits use: {set_line}, {replace_lines}, {insert_after}, or {replace}.`,
|
||||
`Hashline edits use: {set}, {set_range}, {insert}, or {replace}.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!("set_line" in edit) &&
|
||||
!("replace_lines" in edit) &&
|
||||
!("insert_after" in edit) &&
|
||||
!("replace" in edit)
|
||||
) {
|
||||
if (!("set" in edit) && !("set_range" in edit) && !("insert" in edit) && !("replace" in edit)) {
|
||||
throw new Error(
|
||||
`edits[${i}] must contain exactly one of: 'set_line', 'replace_lines', 'insert_after', or 'replace'. Got keys: [${Object.keys(edit).join(", ")}].`,
|
||||
`edits[${i}] must contain exactly one of: 'set', 'set_range', 'insert', or 'replace'. Got keys: [${Object.keys(edit).join(", ")}].`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const anchorEdits = edits.filter(
|
||||
(e): e is HashlineEdit => "set_line" in e || "replace_lines" in e || "insert_after" in e,
|
||||
);
|
||||
const anchorEdits = edits.filter((e): e is HashlineEdit => "set" in e || "set_range" in e || "insert" in e);
|
||||
const replaceEdits = edits.filter(
|
||||
(e): e is { replace: { old_text: string; new_text: string; all?: boolean } } => "replace" in e,
|
||||
);
|
||||
@@ -467,7 +463,7 @@ export class EditTool implements AgentTool<TInput> {
|
||||
const originalNormalized = normalizeToLF(content);
|
||||
let normalizedContent = originalNormalized;
|
||||
|
||||
// Apply anchor-based edits first (set_line, replace_lines, insert_after)
|
||||
// Apply anchor-based edits first (set, set_range, insert)
|
||||
const anchorResult = applyHashlineEdits(normalizedContent, anchorEdits);
|
||||
normalizedContent = anchorResult.content;
|
||||
|
||||
@@ -508,17 +504,19 @@ export class EditTool implements AgentTool<TInput> {
|
||||
const targetLines: string[] = [];
|
||||
for (const edit of edits) {
|
||||
const refs: string[] = [];
|
||||
if ("set_line" in edit) refs.push(edit.set_line.anchor);
|
||||
else if ("replace_lines" in edit)
|
||||
refs.push(edit.replace_lines.start_anchor, edit.replace_lines.end_anchor);
|
||||
else if ("insert_after" in edit) refs.push(edit.insert_after.anchor);
|
||||
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);
|
||||
}
|
||||
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}`);
|
||||
targetLines.push(`${parsed.line}#${hash}|${lineContent}`);
|
||||
}
|
||||
} catch {
|
||||
/* skip malformed refs */
|
||||
|
||||
@@ -86,9 +86,9 @@ interface EditRenderArgs {
|
||||
}
|
||||
|
||||
type HashlineEditPreview =
|
||||
| { set_line: { anchor: string; new_text: string } }
|
||||
| { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
|
||||
| { insert_after: { anchor: string; text: string } }
|
||||
| { set: { ref: string; body: string[] } }
|
||||
| { set_range: { beg: string; end: string; body: string[] } }
|
||||
| { insert: { before?: string; after?: string; body: string[] } }
|
||||
| { replace: { old_text: string; new_text: string; all?: boolean } };
|
||||
|
||||
/** Extended context for edit tool rendering */
|
||||
@@ -168,20 +168,28 @@ function formatStreamingHashlineEdits(edits: unknown[], uiTheme: Theme, ui: Tool
|
||||
dst: "",
|
||||
};
|
||||
}
|
||||
if ("set_line" in editRecord) {
|
||||
const setLine = asRecord(editRecord.set_line);
|
||||
if ("set" in editRecord) {
|
||||
const setLine = asRecord(editRecord.set);
|
||||
return {
|
||||
srcLabel: `• set_line ${typeof setLine?.anchor === "string" ? setLine.anchor : "…"}`,
|
||||
dst: typeof setLine?.new_text === "string" ? setLine.new_text : "",
|
||||
srcLabel: `• set ${typeof setLine?.ref === "string" ? setLine.ref : "…"}`,
|
||||
dst: Array.isArray(setLine?.body)
|
||||
? (setLine.body as string[]).join("\n")
|
||||
: typeof setLine?.body === "string"
|
||||
? setLine.body
|
||||
: "",
|
||||
};
|
||||
}
|
||||
if ("replace_lines" in editRecord) {
|
||||
const replaceLines = asRecord(editRecord.replace_lines);
|
||||
const start = typeof replaceLines?.start_anchor === "string" ? replaceLines.start_anchor : "…";
|
||||
const end = typeof replaceLines?.end_anchor === "string" ? replaceLines.end_anchor : "…";
|
||||
if ("set_range" in editRecord) {
|
||||
const setRange = asRecord(editRecord.set_range);
|
||||
const start = typeof setRange?.beg === "string" ? setRange.beg : "…";
|
||||
const end = typeof setRange?.end === "string" ? setRange.end : "…";
|
||||
return {
|
||||
srcLabel: `• replace_lines ${start}..${end}`,
|
||||
dst: typeof replaceLines?.new_text === "string" ? replaceLines.new_text : "",
|
||||
srcLabel: `• set_range ${start}..${end}`,
|
||||
dst: Array.isArray(setRange?.body)
|
||||
? (setRange.body as string[]).join("\n")
|
||||
: typeof setRange?.body === "string"
|
||||
? setRange.body
|
||||
: "",
|
||||
};
|
||||
}
|
||||
if ("replace" in editRecord) {
|
||||
@@ -192,17 +200,23 @@ function formatStreamingHashlineEdits(edits: unknown[], uiTheme: Theme, ui: Tool
|
||||
dst: typeof replace?.new_text === "string" ? replace.new_text : "",
|
||||
};
|
||||
}
|
||||
if ("insert_after" in editRecord) {
|
||||
const insertAfter = asRecord(editRecord.insert_after);
|
||||
const anchor = typeof insertAfter?.anchor === "string" ? insertAfter.anchor : "…";
|
||||
const text =
|
||||
typeof insertAfter?.text === "string"
|
||||
? insertAfter.text
|
||||
: typeof insertAfter?.content === "string"
|
||||
? insertAfter.content
|
||||
: "";
|
||||
if ("insert" in editRecord) {
|
||||
const insertOp = asRecord(editRecord.insert);
|
||||
const after = typeof insertOp?.after === "string" ? insertOp.after : undefined;
|
||||
const before = typeof insertOp?.before === "string" ? insertOp.before : undefined;
|
||||
const body = insertOp?.body;
|
||||
const text = Array.isArray(body)
|
||||
? (body as string[]).join("\n")
|
||||
: typeof body === "string"
|
||||
? body
|
||||
: typeof insertOp?.text === "string"
|
||||
? insertOp.text
|
||||
: typeof insertOp?.content === "string"
|
||||
? (insertOp.content as string)
|
||||
: "";
|
||||
const refs = [after, before].filter(Boolean).join("..") || "…";
|
||||
return {
|
||||
srcLabel: `• insert_after ${anchor}..`,
|
||||
srcLabel: `• insert ${refs}`,
|
||||
dst: text,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user