/** * Hashline edit mode — a line-addressable edit format using text hashes. * * Each line in a file is identified by its 1-indexed line number and a short * hexadecimal hash derived from the normalized line text (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:TEXT` * Reference format: `"LINENUM#HASH"` (e.g. `"5#aa"`) */ import * as fs from "node:fs/promises"; import * as nodePath from "node:path"; import type { AgentToolResult } from "@f5-sales-demo/pi-agent-core"; import { isEnoent } from "@f5-sales-demo/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import type { BunFile } from "bun"; import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; import type { ToolSession } from "../../tools"; import { assertEditableFileContent } from "../../tools/auto-generated-guard"; import { invalidateFsScanAfterDelete, invalidateFsScanAfterRename, invalidateFsScanAfterWrite, } from "../../tools/fs-cache-invalidation"; import { outputMeta } from "../../tools/output-meta"; import { resolveToCwd } from "../../tools/path-utils"; import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard"; import { generateDiffString } from "../diff"; import { computeLineHash, formatLineHash } from "../line-hash"; import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize"; import type { EditToolDetails, LspBatchRequest } from "../renderer"; export interface HashMismatch { line: number; expected: string; actual: string; } export type Anchor = { line: number; hash: string }; export type HashlineEdit = | { op: "replace_line"; pos: Anchor; lines: string[] } | { op: "replace_range"; pos: Anchor; end: Anchor; lines: string[] } | { op: "append_at"; pos: Anchor; lines: string[] } | { op: "prepend_at"; pos: Anchor; lines: string[] } | { op: "append_file"; lines: string[] } | { op: "prepend_file"; lines: string[] }; const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/; const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/; const DIFF_PLUS_RE = /^[+](?![+])/; type LinePrefixStats = { nonEmpty: number; hashPrefixCount: number; diffPlusHashPrefixCount: number; diffPlusCount: number; }; function collectLinePrefixStats(lines: string[]): LinePrefixStats { const stats: LinePrefixStats = { nonEmpty: 0, hashPrefixCount: 0, diffPlusHashPrefixCount: 0, diffPlusCount: 0, }; for (const line of lines) { if (line.length === 0) continue; stats.nonEmpty++; if (HASHLINE_PREFIX_RE.test(line)) stats.hashPrefixCount++; if (HASHLINE_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++; if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++; } return stats; } export function stripNewLinePrefixes(lines: string[]): string[] { const { nonEmpty, hashPrefixCount, diffPlusHashPrefixCount, diffPlusCount } = collectLinePrefixStats(lines); if (nonEmpty === 0) return lines; const stripHash = hashPrefixCount > 0 && hashPrefixCount === nonEmpty; const stripPlus = !stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5; if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines; return lines.map(line => { if (stripHash) return line.replace(HASHLINE_PREFIX_RE, ""); if (stripPlus) return line.replace(DIFF_PLUS_RE, ""); if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) { return line.replace(HASHLINE_PREFIX_RE, ""); } return line; }); } export function stripHashlinePrefixes(lines: string[]): string[] { const { nonEmpty, hashPrefixCount } = collectLinePrefixStats(lines); if (nonEmpty === 0 || hashPrefixCount !== nonEmpty) return lines; return lines.map(line => line.replace(HASHLINE_PREFIX_RE, "")); } const linesSchema = Type.Union([ Type.Array(Type.String(), { description: "content (preferred format)" }), Type.String(), Type.Null(), ]); const locSchema = Type.Union( [ Type.Literal("append"), Type.Literal("prepend"), Type.Object({ append: Type.String({ description: "anchor" }) }), Type.Object({ prepend: Type.String({ description: "anchor" }) }), Type.Object({ range: Type.Object({ pos: Type.String({ description: "first line to edit (inclusive)" }), end: Type.String({ description: "last line to edit (inclusive)" }), }), }), ], { description: "insert location" }, ); export const hashlineEditSchema = Type.Object( { path: Type.String({ description: "File path" }), loc: Type.Optional(locSchema), content: Type.Optional(linesSchema), delete: Type.Optional(Type.Boolean({ description: "Delete the file" })), move: Type.Optional(Type.String({ description: "Move/rename the file to this path" })), }, { additionalProperties: false }, ); export const hashlineEditParamsSchema = Type.Object( { edits: Type.Array(hashlineEditSchema, { description: "edits" }), }, { additionalProperties: false }, ); export type HashlineToolEdit = Static; export type HashlineParams = Static; export interface ExecuteHashlineSingleOptions { session: ToolSession; path: string; edits: HashlineToolEdit[]; signal?: AbortSignal; batchRequest?: LspBatchRequest; writethrough: WritethroughCallback; beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle; } export function hashlineParseText(edit: string[] | string | null | undefined): string[] { if (edit == null) return []; if (typeof edit === "string") { const normalizedEdit = edit.endsWith("\n") ? edit.slice(0, -1) : edit; edit = normalizedEdit.replaceAll("\r", "").split("\n"); } return stripNewLinePrefixes(edit); } export function isHashlineParams(params: unknown): params is HashlineParams { if (typeof params !== "object" || params === null || !("edits" in params) || !Array.isArray(params.edits)) return false; if (params.edits.length === 0) return true; const first = params.edits[0]; if (typeof first !== "object" || first === null) return false; return "loc" in first || "delete" in first || "move" in first; } function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] { return edits.map(resolveEditAnchor); } type HashlineEditInput = HashlineToolEdit | HashlineEdit; function resolveHashlineEditsForDiff(edits: HashlineEditInput[]): HashlineEdit[] { return edits.map((edit, editIndex) => { if (!edit || typeof edit !== "object") { throw new Error(`Invalid hashline edit at index ${editIndex}: expected object.`); } if ("op" in edit) { return edit; } if ("loc" in edit) { return resolveEditAnchor(edit); } throw new Error(`Invalid hashline edit at index ${editIndex}: expected op/loc payload.`); }); } function tryParseTag(raw: string): Anchor | undefined { try { return parseTag(raw); } catch { return undefined; } } function requireParsedAnchor(raw: string, op: "append" | "prepend"): Anchor { const anchor = tryParseTag(raw); if (!anchor) throw new Error(`${op} requires a valid anchor.`); return anchor; } function requireParsedRange(range: { pos: string; end: string }): { pos: Anchor; end: Anchor } { const pos = tryParseTag(range.pos); const end = tryParseTag(range.end); if (!pos || !end) throw new Error("range requires valid pos and end anchors."); return { pos, end }; } function resolveEditAnchor(edit: HashlineToolEdit): HashlineEdit { const lines = hashlineParseText(edit.content); const loc = edit.loc; if (loc === "append") { return { op: "append_file", lines }; } if (loc === "prepend") { return { op: "prepend_file", lines }; } if (typeof loc !== "object") { throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`); } if ("append" in loc) { return { op: "append_at", pos: requireParsedAnchor(loc.append, "append"), lines }; } if ("prepend" in loc) { return { op: "prepend_at", pos: requireParsedAnchor(loc.prepend, "prepend"), lines }; } if ("range" in loc) { const { pos, end } = requireParsedRange(loc.range); return { op: "replace_range", pos, end, lines }; } throw new Error("Unknown loc shape. Expected append, prepend, or range."); } // ═══════════════════════════════════════════════════════════════════════════ // Hashline streaming formatter // ═══════════════════════════════════════════════════════════════════════════ export interface HashlineStreamOptions { /** First line number to use when formatting (1-indexed). */ startLine?: number; /** Maximum formatted lines per yielded chunk (default: 200). */ maxChunkLines?: number; /** Maximum UTF-8 bytes per yielded chunk (default: 64 KiB). */ maxChunkBytes?: number; } interface ResolvedHashlineStreamOptions { startLine: number; maxChunkLines: number; maxChunkBytes: number; } type HashlineLineFormatter = (lineNumber: number, line: string) => string; interface HashlineChunkEmitter { pushLine: (line: string) => string[]; flush: () => string | undefined; } function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedHashlineStreamOptions { return { startLine: options.startLine ?? 1, maxChunkLines: options.maxChunkLines ?? 200, maxChunkBytes: options.maxChunkBytes ?? 64 * 1024, }; } function createHashlineChunkEmitter( options: ResolvedHashlineStreamOptions, formatLine: HashlineLineFormatter, ): HashlineChunkEmitter { let lineNumber = options.startLine; let outLines: string[] = []; let outBytes = 0; const flush = (): string | undefined => { if (outLines.length === 0) return undefined; const chunk = outLines.join("\n"); outLines = []; outBytes = 0; return chunk; }; const pushLine = (line: string): string[] => { const formatted = formatLine(lineNumber, line); lineNumber++; const chunksToYield: string[] = []; const sepBytes = outLines.length === 0 ? 0 : 1; const lineBytes = Buffer.byteLength(formatted, "utf-8"); if ( outLines.length > 0 && (outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes) ) { const flushed = flush(); if (flushed) chunksToYield.push(flushed); } outLines.push(formatted); outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes; if (outLines.length >= options.maxChunkLines || outBytes >= options.maxChunkBytes) { const flushed = flush(); if (flushed) chunksToYield.push(flushed); } return chunksToYield; }; return { pushLine, flush }; } function formatHashlineStreamLine(lineNumber: number, line: string): string { return `${formatLineHash(lineNumber, line)}:${line}`; } function isReadableStream(value: unknown): value is ReadableStream { return ( typeof value === "object" && value !== null && "getReader" in value && typeof (value as { getReader?: unknown }).getReader === "function" ); } async function* bytesFromReadableStream(stream: ReadableStream): AsyncGenerator { const reader = stream.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) return; if (value) yield value; } } finally { reader.releaseLock(); } } /** * Stream hashline-formatted output from a UTF-8 byte source. * * This is intended for large files where callers want incremental output * (e.g. while reading from a file handle) rather than allocating a single * large string. */ export async function* streamHashLinesFromUtf8( source: ReadableStream | AsyncIterable, options: HashlineStreamOptions = {}, ): AsyncGenerator { const resolvedOptions = resolveHashlineStreamOptions(options); const decoder = new TextDecoder("utf-8"); const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source; let pending = ""; let sawAnyText = false; let endedWithNewline = false; const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine); const consumeText = (text: string): string[] => { if (text.length === 0) return []; sawAnyText = true; pending += text; const chunksToYield: string[] = []; while (true) { const idx = pending.indexOf("\n"); if (idx === -1) break; const line = pending.slice(0, idx); pending = pending.slice(idx + 1); endedWithNewline = true; chunksToYield.push(...emitter.pushLine(line)); } if (pending.length > 0) endedWithNewline = false; return chunksToYield; }; for await (const chunk of chunks) { for (const out of consumeText(decoder.decode(chunk, { stream: true }))) { yield out; } } for (const out of consumeText(decoder.decode())) { yield out; } if (!sawAnyText) { // Mirror `"".split("\n")` behavior: one empty line. for (const out of emitter.pushLine("")) { yield out; } } else if (pending.length > 0 || endedWithNewline) { // Emit the final line (may be empty if the file ended with a newline). for (const out of emitter.pushLine(pending)) { yield out; } } const last = emitter.flush(); if (last) yield last; } /** * Stream hashline-formatted output from an (async) iterable of lines. * * Each yielded chunk is a `\n`-joined string of one or more formatted lines. */ export async function* streamHashLinesFromLines( lines: Iterable | AsyncIterable, options: HashlineStreamOptions = {}, ): AsyncGenerator { const resolvedOptions = resolveHashlineStreamOptions(options); const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine); let sawAnyLine = false; const asyncIterator = (lines as AsyncIterable)[Symbol.asyncIterator]; if (typeof asyncIterator === "function") { for await (const line of lines as AsyncIterable) { sawAnyLine = true; for (const out of emitter.pushLine(line)) { yield out; } } } else { for (const line of lines as Iterable) { sawAnyLine = true; for (const out of emitter.pushLine(line)) { yield out; } } } if (!sawAnyLine) { // Mirror `"".split("\n")` behavior: one empty line. for (const out of emitter.pushLine("")) { yield out; } } const last = emitter.flush(); if (last) yield last; } /** * Parse a line reference string like `"5#abcd"` into structured form. * * @throws Error if the format is invalid (not `NUMBER#HEXHASH`) */ 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").`); } const line = Number.parseInt(match[1], 10); if (line < 1) { throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`); } return { line, hash: match[2] }; } // ═══════════════════════════════════════════════════════════════════════════ // Hash Mismatch Error // ═══════════════════════════════════════════════════════════════════════════ /** Number of context lines shown above/below each mismatched line */ 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#ID` so the caller can fix all refs at once. */ export class HashlineMismatchError extends Error { readonly remaps: ReadonlyMap; constructor( public readonly mismatches: HashMismatch[], public readonly fileLines: string[], ) { super(HashlineMismatchError.formatMessage(mismatches, fileLines)); this.name = "HashlineMismatchError"; const remaps = new Map(); for (const m of mismatches) { const actual = computeLineHash(m.line, fileLines[m.line - 1]); remaps.set(`${m.line}#${m.expected}`, `${m.line}#${actual}`); } this.remaps = remaps; } static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string { const mismatchSet = new Map(); for (const m of mismatches) { mismatchSet.set(m.line, m); } // Collect line ranges to display (mismatch lines + context) const displayLines = new Set(); for (const m of mismatches) { const lo = Math.max(1, m.line - MISMATCH_CONTEXT); const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT); for (let i = lo; i <= hi; i++) { displayLines.add(i); } } const sorted = [...displayLines].sort((a, b) => a - b); const lines: string[] = []; lines.push( `${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(""); let prevLine = -1; for (const lineNum of sorted) { // Gap separator between non-contiguous regions if (prevLine !== -1 && lineNum > prevLine + 1) { lines.push(" ..."); } prevLine = lineNum; const text = fileLines[lineNum - 1]; const hash = computeLineHash(lineNum, text); const prefix = `${lineNum}#${hash}`; if (mismatchSet.has(lineNum)) { lines.push(`>>> ${prefix}:${text}`); } else { lines.push(` ${prefix}:${text}`); } } return lines.join("\n"); } } /** * Validate that a line reference points to an existing line with a matching hash. * * @param ref - Parsed line reference (1-indexed line number + expected hash) * @param fileLines - Array of file lines (0-indexed) * @throws HashlineMismatchError if the hash doesn't match (includes correct hashes in context) * @throws Error if the line is out of range */ export function validateLineRef(ref: { line: number; hash: string }, fileLines: string[]): void { if (ref.line < 1 || ref.line > fileLines.length) { 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) { throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines); } } function isEscapedTabAutocorrectEnabled(): boolean { switch (Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS) { case "0": return false; case "1": return true; default: return true; } } function maybeAutocorrectEscapedTabIndentation(edits: HashlineEdit[], warnings: string[]): void { if (!isEscapedTabAutocorrectEnabled()) return; for (const edit of edits) { if (edit.lines.length === 0) continue; const hasEscapedTabs = edit.lines.some(line => line.includes("\\t")); if (!hasEscapedTabs) continue; const hasRealTabs = edit.lines.some(line => line.includes("\t")); if (hasRealTabs) continue; let correctedCount = 0; const corrected = edit.lines.map(line => line.replace(/^((?:\\t)+)/, escaped => { correctedCount += escaped.length / 2; return "\t".repeat(escaped.length / 2); }), ); if (correctedCount === 0) continue; edit.lines = corrected; warnings.push( `Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`, ); } } function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warnings: string[]): void { for (const edit of edits) { if (edit.lines.length === 0) continue; if (!edit.lines.some(line => /\\uDDDD/i.test(line))) continue; warnings.push( `Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.`, ); } } function runHashlinePreflightSanitizers(edits: HashlineEdit[], warnings: string[]): void { maybeAutocorrectEscapedTabIndentation(edits, warnings); maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings); } function ensureHashlineEditHasContent(edit: HashlineEdit): void { if (edit.lines.length === 0) { edit.lines = [""]; } } function collectBoundaryDuplicationWarning(edit: HashlineEdit, originalFileLines: string[], warnings: string[]): void { let endLine: number; switch (edit.op) { case "replace_line": endLine = edit.pos.line; break; case "replace_range": endLine = edit.end.line; break; default: return; } if (edit.lines.length === 0) return; const nextSurvivingIdx = endLine; if (nextSurvivingIdx >= originalFileLines.length) return; const nextSurvivingLine = originalFileLines[nextSurvivingIdx]; const lastInsertedLine = edit.lines[edit.lines.length - 1]; const trimmedNext = nextSurvivingLine.trim(); const trimmedLast = lastInsertedLine.trim(); if (trimmedLast.length > 0 && trimmedLast === trimmedNext) { const tag = formatLineHash(endLine + 1, nextSurvivingLine); warnings.push( `Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` + `If you meant to replace the entire block, set \`end\` to ${tag} instead.`, ); } } function dedupeHashlineEdits(edits: HashlineEdit[]): void { const seenEditKeys = new Map(); const dedupIndices = new Set(); for (let i = 0; i < edits.length; i++) { const edit = edits[i]; let lineKey: string; switch (edit.op) { case "replace_line": lineKey = `s:${edit.pos.line}`; break; case "replace_range": lineKey = `r:${edit.pos.line}:${edit.end.line}`; break; case "append_at": lineKey = `i:${edit.pos.line}`; break; case "prepend_at": lineKey = `ib:${edit.pos.line}`; break; case "append_file": lineKey = "ieof"; break; case "prepend_file": lineKey = "ibef"; break; } const dstKey = `${lineKey}:${edit.lines.join("\n")}`; if (seenEditKeys.has(dstKey)) { dedupIndices.add(i); } else { seenEditKeys.set(dstKey, i); } } if (dedupIndices.size === 0) return; for (let i = edits.length - 1; i >= 0; i--) { if (dedupIndices.has(i)) edits.splice(i, 1); } } function getHashlineEditSortKey(edit: HashlineEdit, fileLineCount: number): { sortLine: number; precedence: number } { switch (edit.op) { case "replace_line": return { sortLine: edit.pos.line, precedence: 0 }; case "replace_range": return { sortLine: edit.end.line, precedence: 0 }; case "append_at": return { sortLine: edit.pos.line, precedence: 1 }; case "prepend_at": return { sortLine: edit.pos.line, precedence: 2 }; case "append_file": return { sortLine: fileLineCount + 1, precedence: 1 }; case "prepend_file": return { sortLine: 0, precedence: 2 }; } } function applyHashlineEditToLines( edit: HashlineEdit, fileLines: string[], originalFileLines: string[], editIndex: number, noopEdits: Array<{ editIndex: number; loc: string; current: string }>, trackFirstChanged: (line: number) => void, ): void { switch (edit.op) { case "replace_line": { const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line); const newLines = edit.lines; if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) { noopEdits.push({ editIndex, loc: `${edit.pos.line}#${edit.pos.hash}`, current: origLines.join("\n"), }); break; } fileLines.splice(edit.pos.line - 1, 1, ...newLines); trackFirstChanged(edit.pos.line); break; } case "replace_range": { const count = edit.end.line - edit.pos.line + 1; fileLines.splice(edit.pos.line - 1, count, ...edit.lines); trackFirstChanged(edit.pos.line); break; } case "append_at": { const inserted = edit.lines; if (inserted.length === 0) { noopEdits.push({ editIndex, loc: `${edit.pos.line}#${edit.pos.hash}`, current: originalFileLines[edit.pos.line - 1], }); break; } fileLines.splice(edit.pos.line, 0, ...inserted); trackFirstChanged(edit.pos.line + 1); break; } case "prepend_at": { const inserted = edit.lines; if (inserted.length === 0) { noopEdits.push({ editIndex, loc: `${edit.pos.line}#${edit.pos.hash}`, current: originalFileLines[edit.pos.line - 1], }); break; } fileLines.splice(edit.pos.line - 1, 0, ...inserted); trackFirstChanged(edit.pos.line); break; } case "append_file": { const inserted = edit.lines; if (inserted.length === 0) { noopEdits.push({ editIndex, loc: "EOF", current: "" }); break; } 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 "prepend_file": { const inserted = edit.lines; if (inserted.length === 0) { noopEdits.push({ editIndex, loc: "BOF", current: "" }); break; } if (fileLines.length === 1 && fileLines[0] === "") { fileLines.splice(0, 1, ...inserted); } else { fileLines.splice(0, 0, ...inserted); } trackFirstChanged(1); break; } } } function buildHashlineEditResult(params: { fileLines: string[]; firstChangedLine: number | undefined; warnings: string[]; noopEdits: Array<{ editIndex: number; loc: string; current: string }>; }): { lines: string; firstChangedLine: number | undefined; warnings?: string[]; noopEdits?: Array<{ editIndex: number; loc: string; current: string }>; } { const { fileLines, firstChangedLine, warnings, noopEdits } = params; return { lines: fileLines.join("\n"), firstChangedLine, ...(warnings.length > 0 ? { warnings } : {}), ...(noopEdits.length > 0 ? { noopEdits } : {}), }; } function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] { const mismatches: HashMismatch[] = []; for (const edit of edits) { switch (edit.op) { case "replace_line": validateHashlineRef(edit.pos); break; case "replace_range": validateHashlineRef(edit.pos); validateHashlineRef(edit.end); if (edit.pos.line > edit.end.line) { throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`); } break; case "append_at": case "prepend_at": validateHashlineRef(edit.pos); ensureHashlineEditHasContent(edit); break; case "append_file": case "prepend_file": ensureHashlineEditHasContent(edit); break; } } return mismatches; function validateHashlineRef(ref: { line: number; hash: string }): void { if (ref.line < 1 || ref.line > fileLines.length) { 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) { return; } mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash }); } } // ═══════════════════════════════════════════════════════════════════════════ // Edit Application // ═══════════════════════════════════════════════════════════════════════════ /** * Apply an array of hashline edits to file content. * * Each edit operation identifies target lines directly (`replace`, * `append`, `prepend`). Line references are resolved via {@link parseTag} * and hashes validated before any mutation. * * Edits are sorted bottom-up (highest effective line first) so earlier * splices don't invalidate later line numbers. * * @returns The modified content and the 1-indexed first changed line number */ export function applyHashlineEdits( text: string, edits: HashlineEdit[], ): { lines: string; firstChangedLine: number | undefined; warnings?: string[]; noopEdits?: Array<{ editIndex: number; loc: string; current: string }>; } { if (edits.length === 0) { return { lines: text, firstChangedLine: undefined }; } const fileLines = text.split("\n"); const originalFileLines = [...fileLines]; let firstChangedLine: number | undefined; const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = []; const warnings: string[] = []; const mismatches = validateHashlineEditRefs(edits, fileLines); if (mismatches.length > 0) { throw new HashlineMismatchError(mismatches, fileLines); } runHashlinePreflightSanitizers(edits, warnings); for (const edit of edits) { collectBoundaryDuplicationWarning(edit, originalFileLines, warnings); } dedupeHashlineEdits(edits); const annotated = edits .map((edit, idx) => { const { sortLine, precedence } = getHashlineEditSortKey(edit, fileLines.length); return { edit, idx, sortLine, precedence }; }) .sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx); for (const { edit, idx } of annotated) { applyHashlineEditToLines(edit, fileLines, originalFileLines, idx, noopEdits, trackFirstChanged); } return buildHashlineEditResult({ fileLines, firstChangedLine, warnings, noopEdits }); function trackFirstChanged(line: number): void { if (firstChangedLine === undefined || line < firstChangedLine) { firstChangedLine = line; } } } export interface CompactHashlineDiffPreview { preview: string; addedLines: number; removedLines: number; } export interface CompactHashlineDiffOptions { maxUnchangedRun?: number; maxAdditionRun?: number; maxDeletionRun?: number; maxOutputLines?: number; } const NUMBERED_DIFF_LINE_RE = /^([ +-])(\s*\d+)\|(.*)$/; const HASHLINE_PREVIEW_PLACEHOLDER = " "; type DiffRunKind = " " | "+" | "-" | "meta"; type DiffRun = { kind: DiffRunKind; lines: string[] }; interface ParsedNumberedDiffLine { kind: " " | "+" | "-"; lineNumber: number; lineWidth: number; content: string; raw: string; } interface CompactPreviewCounters { oldLine?: number; newLine?: number; } function parseNumberedDiffLine(line: string): ParsedNumberedDiffLine | undefined { const match = NUMBERED_DIFF_LINE_RE.exec(line); if (!match) return undefined; const kind = match[1]; if (kind !== " " && kind !== "+" && kind !== "-") return undefined; const lineField = match[2]; const lineNumber = Number(lineField.trim()); if (!Number.isInteger(lineNumber)) return undefined; return { kind, lineNumber, lineWidth: lineField.length, content: match[3], raw: line }; } function syncOldLineCounters(counters: CompactPreviewCounters, lineNumber: number): void { if (counters.oldLine === undefined || counters.newLine === undefined) { counters.oldLine = lineNumber; counters.newLine = lineNumber; return; } const delta = lineNumber - counters.oldLine; counters.oldLine = lineNumber; counters.newLine += delta; } function syncNewLineCounters(counters: CompactPreviewCounters, lineNumber: number): void { if (counters.oldLine === undefined || counters.newLine === undefined) { counters.oldLine = lineNumber; counters.newLine = lineNumber; return; } const delta = lineNumber - counters.newLine; counters.oldLine += delta; counters.newLine = lineNumber; } function formatCompactHashlineLine(kind: " " | "+", lineNumber: number, width: number, content: string): string { const padded = String(lineNumber).padStart(width, " "); return `${kind}${padded}#${computeLineHash(lineNumber, content)}|${content}`; } function formatCompactRemovedLine(lineNumber: number, width: number, content: string): string { const padded = String(lineNumber).padStart(width, " "); return `-${padded}${HASHLINE_PREVIEW_PLACEHOLDER}|${content}`; } function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters): { kind: DiffRunKind; text: string } { const parsed = parseNumberedDiffLine(line); if (!parsed) return { kind: "meta", text: line }; if (parsed.content === "...") { if (parsed.kind === "+") { syncNewLineCounters(counters, parsed.lineNumber); } else { syncOldLineCounters(counters, parsed.lineNumber); } return { kind: parsed.kind, text: parsed.raw }; } switch (parsed.kind) { case "+": { syncNewLineCounters(counters, parsed.lineNumber); const newLine = counters.newLine; if (newLine === undefined) return { kind: "+", text: parsed.raw }; const text = formatCompactHashlineLine("+", newLine, parsed.lineWidth, parsed.content); counters.newLine = newLine + 1; return { kind: "+", text }; } case "-": { syncOldLineCounters(counters, parsed.lineNumber); const text = formatCompactRemovedLine(parsed.lineNumber, parsed.lineWidth, parsed.content); counters.oldLine = parsed.lineNumber + 1; return { kind: "-", text }; } case " ": { syncOldLineCounters(counters, parsed.lineNumber); const newLine = counters.newLine; if (newLine === undefined) return { kind: " ", text: parsed.raw }; const text = formatCompactHashlineLine(" ", newLine, parsed.lineWidth, parsed.content); counters.oldLine = parsed.lineNumber + 1; counters.newLine = newLine + 1; return { kind: " ", text }; } } } function splitDiffRuns(lines: string[]): DiffRun[] { const runs: DiffRun[] = []; const counters: CompactPreviewCounters = {}; for (const line of lines) { const formatted = formatCompactPreviewLine(line, counters); const prev = runs[runs.length - 1]; if (prev && prev.kind === formatted.kind) { prev.lines.push(formatted.text); continue; } runs.push({ kind: formatted.kind, lines: [formatted.text] }); } return runs; } function collapseFromStart(lines: string[], maxLines: number, label: string): string[] { if (lines.length <= maxLines) return lines; const hidden = lines.length - maxLines; return [...lines.slice(0, maxLines), ` ... ${hidden} more ${label} lines`]; } function collapseFromEnd(lines: string[], maxLines: number, label: string): string[] { if (lines.length <= maxLines) return lines; const hidden = lines.length - maxLines; return [` ... ${hidden} more ${label} lines`, ...lines.slice(-maxLines)]; } function collapseFromMiddle(lines: string[], maxLines: number, label: string): string[] { if (lines.length <= maxLines * 2) return lines; const hidden = lines.length - maxLines * 2; return [...lines.slice(0, maxLines), ` ... ${hidden} more ${label} lines`, ...lines.slice(-maxLines)]; } /** * Build a compact diff preview suitable for model-visible tool responses. * * Collapses long unchanged runs and long consecutive additions/removals so the * model sees the shape of edits without replaying full file content. */ export function buildCompactHashlineDiffPreview( diff: string, options: CompactHashlineDiffOptions = {}, ): CompactHashlineDiffPreview { const maxUnchangedRun = options.maxUnchangedRun ?? 2; const maxAdditionRun = options.maxAdditionRun ?? 2; const maxDeletionRun = options.maxDeletionRun ?? 2; const maxOutputLines = options.maxOutputLines ?? 16; const inputLines = diff.length === 0 ? [] : diff.split("\n"); const runs = splitDiffRuns(inputLines); const out: string[] = []; let addedLines = 0; let removedLines = 0; for (let runIndex = 0; runIndex < runs.length; runIndex++) { const run = runs[runIndex]; switch (run.kind) { case "meta": out.push(...run.lines); break; case "+": addedLines += run.lines.length; out.push(...collapseFromStart(run.lines, maxAdditionRun, "added")); break; case "-": removedLines += run.lines.length; out.push(...collapseFromStart(run.lines, maxDeletionRun, "removed")); break; case " ": if (runIndex === 0) { out.push(...collapseFromEnd(run.lines, maxUnchangedRun, "unchanged")); break; } if (runIndex === runs.length - 1) { out.push(...collapseFromStart(run.lines, maxUnchangedRun, "unchanged")); break; } out.push(...collapseFromMiddle(run.lines, maxUnchangedRun, "unchanged")); break; } } if (out.length > maxOutputLines) { const hidden = out.length - maxOutputLines; return { preview: [...out.slice(0, maxOutputLines), ` ... ${hidden} more preview lines`].join("\n"), addedLines, removedLines, }; } return { preview: out.join("\n"), addedLines, removedLines }; } export async function computeHashlineDiff( input: { path: string; edits: HashlineEditInput[]; move?: string }, cwd: string, ): Promise< | { diff: string; firstChangedLine: number | undefined; } | { error: string; } > { const { path, edits, move } = input; const absolutePath = resolveToCwd(path, cwd); const movePath = move ? resolveToCwd(move, cwd) : undefined; const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0; try { const resolvedEdits = resolveHashlineEditsForDiff(edits); const file = Bun.file(absolutePath); if (movePath === absolutePath) { return { error: "move path is the same as source path" }; } if (isMoveOnly) { return { diff: "", firstChangedLine: undefined }; } const rawContent = await readHashlineFileText(file, path); const { text: content } = stripBom(rawContent); const normalizedContent = normalizeToLF(content); const result = applyHashlineEdits(normalizedContent, resolvedEdits); if (normalizedContent === result.lines && !move) { return { error: `No changes would be made to ${path}. The edits produce identical content.` }; } return generateDiffString(normalizedContent, result.lines); } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; } } async function readHashlineFileText(file: BunFile, path: string): Promise { try { return await file.text(); } catch (error) { if (isEnoent(error)) { throw new Error(`File not found: ${path}`); } const message = error instanceof Error ? error.message : String(error); throw new Error(message || `Unable to read ${path}`); } } export async function executeHashlineSingle( options: ExecuteHashlineSingleOptions, ): Promise> { const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options; // Extract file-level ops from edits const deleteFile = edits.some(e => e.delete); const move = edits.find(e => e.move)?.move; // Filter to content edits only (those with loc) const contentEdits = edits.filter(e => e.loc != null); enforcePlanModeWrite(session, path, { op: deleteFile ? "delete" : "update", move }); if (path.endsWith(".ipynb") && contentEdits.length > 0) { throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); } const absolutePath = resolvePlanPath(session, path); const resolvedMove = move ? resolvePlanPath(session, move) : undefined; if (resolvedMove === absolutePath) { throw new Error("move path is the same as source path"); } const sourceFile = Bun.file(absolutePath); const sourceExists = await sourceFile.exists(); const isMoveOnly = Boolean(resolvedMove) && contentEdits.length === 0; if (deleteFile) { if (sourceExists) { await sourceFile.unlink(); } invalidateFsScanAfterDelete(absolutePath); return { content: [{ type: "text", text: `Deleted ${path}` }], details: { diff: "", op: "delete", meta: outputMeta().get(), }, }; } if (isMoveOnly && resolvedMove) { if (!sourceExists) { throw new Error(`File not found: ${path}`); } const parentDir = nodePath.dirname(resolvedMove); if (parentDir && parentDir !== ".") { await fs.mkdir(parentDir, { recursive: true }); } await fs.rename(absolutePath, resolvedMove); invalidateFsScanAfterRename(absolutePath, resolvedMove); return { content: [{ type: "text", text: `Moved ${path} to ${move}` }], details: { diff: "", op: "update", move, meta: outputMeta().get(), }, }; } if (!sourceExists) { const lines: string[] = []; for (const edit of contentEdits) { if (edit.loc === "append") { lines.push(...hashlineParseText(edit.content)); } else if (edit.loc === "prepend") { lines.unshift(...hashlineParseText(edit.content)); } else { throw new Error(`File not found: ${path}`); } } await Bun.write(absolutePath, lines.join("\n")); invalidateFsScanAfterWrite(absolutePath); return { content: [{ type: "text", text: `Created ${path}` }], details: { diff: "", op: "create", meta: outputMeta().get(), }, }; } const anchorEdits = resolveEditAnchors(contentEdits); const rawContent = await sourceFile.text(); assertEditableFileContent(rawContent, path); const { bom, text } = stripBom(rawContent); const originalEnding = detectLineEnding(text); const originalNormalized = normalizeToLF(text); let normalizedText = originalNormalized; const anchorResult = applyHashlineEdits(normalizedText, anchorEdits); normalizedText = anchorResult.lines; const result = { text: normalizedText, firstChangedLine: anchorResult.firstChangedLine, warnings: anchorResult.warnings, noopEdits: anchorResult.noopEdits, }; if (originalNormalized === result.text && !move) { let diagnostic = `No changes made to ${path}. The edits produced identical content.`; if (result.noopEdits && result.noopEdits.length > 0) { const details = result.noopEdits .map( edit => `Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}| ${edit.current}`, ) .join("\n"); diagnostic += `\n${details}`; if (result.noopEdits.length === 1 && result.noopEdits[0]?.current) { const preview = result.noopEdits[0].current.trimEnd(); if (preview.length > 0) { diagnostic += `\nThe file currently contains these lines:\n${preview}\nYour edits were normalized back to the original content (whitespace-only differences are preserved as-is). Ensure your replacement changes actual code, not just formatting.`; } } } throw new Error(diagnostic); } const writePath = resolvedMove ?? absolutePath; const finalContent = bom + restoreLineEndings(result.text, originalEnding); const diagnostics = await writethrough(writePath, finalContent, signal, Bun.file(writePath), batchRequest, dst => dst === writePath ? beginDeferredDiagnosticsForPath(writePath) : undefined, ); if (resolvedMove && resolvedMove !== absolutePath) { await sourceFile.unlink(); invalidateFsScanAfterRename(absolutePath, resolvedMove); } else { invalidateFsScanAfterWrite(absolutePath); } const diffResult = generateDiffString(originalNormalized, result.text); const meta = outputMeta() .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) .get(); const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`; const preview = buildCompactHashlineDiffPreview(diffResult.diff); const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`; const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""; const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : ""; return { content: [ { type: "text", text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`, }, ], details: { diff: diffResult.diff, firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine, diagnostics, op: "update", move, meta, }, }; }