diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a8d684e94..86df7e8ca 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Moved hashline APIs to the dedicated `@oh-my-pi/pi-coding-agent/hashline` module, moved hash helpers to `@oh-my-pi/pi-coding-agent/hashline/hash`, and removed the legacy `edit/modes/hashline` and `edit/line-hash` source subpaths. + ### Removed - Removed hashline auto-rebase. Anchor mismatches now reject immediately so the model re-reads instead of silently relocating an edit to a hash-collision within ±5 lines, which could otherwise apply the change to the wrong region. Stale-anchor recovery via the cached read snapshot is unaffected. diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 49f338087..439d040c3 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -227,6 +227,14 @@ "types": "./src/edit/modes/*.ts", "import": "./src/edit/modes/*.ts" }, + "./hashline": { + "types": "./src/hashline/index.ts", + "import": "./src/hashline/index.ts" + }, + "./hashline/*": { + "types": "./src/hashline/*.ts", + "import": "./src/hashline/*.ts" + }, "./exa": { "types": "./src/exa/index.ts", "import": "./src/exa/index.ts" diff --git a/packages/coding-agent/src/config/prompt-templates.ts b/packages/coding-agent/src/config/prompt-templates.ts index 6bf3ee509..7b333e937 100644 --- a/packages/coding-agent/src/config/prompt-templates.ts +++ b/packages/coding-agent/src/config/prompt-templates.ts @@ -8,7 +8,7 @@ import { parseFrontmatter, prompt, } from "@oh-my-pi/pi-utils"; -import { computeLineHash, HL_BODY_SEP, HL_EDIT_SEP } from "../edit/line-hash"; +import { computeLineHash, HL_BODY_SEP, HL_EDIT_SEP } from "../hashline/hash"; import { jtdToTypeScript } from "../tools/jtd-to-typescript"; import { parseCommandArgs, substituteArgs } from "../utils/command-args"; diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts index ce68681f1..4410eed62 100644 --- a/packages/coding-agent/src/edit/index.ts +++ b/packages/coding-agent/src/edit/index.ts @@ -1,6 +1,14 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { prompt } from "@oh-my-pi/pi-utils"; import type { Static } from "@sinclair/typebox"; +import { + executeHashlineSingle, + HashlineMismatchError, + type HashlineParams, + hashlineEditParamsSchema, +} from "../hashline"; +import hashlineGrammarTemplate from "../hashline/grammar.lark" with { type: "text" }; +import { resolveHashlineGrammarPlaceholders } from "../hashline/hash"; import { createLspWritethrough, type FileDiagnosticsResult, @@ -16,16 +24,8 @@ import type { ToolSession } from "../tools"; import { VimTool, vimSchema } from "../tools/vim"; import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode"; import type { VimToolDetails } from "../vim/types"; -import { resolveHashlineGrammarPlaceholders } from "./line-hash"; import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch"; import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" }; -import { - executeHashlineSingle, - HashlineMismatchError, - type HashlineParams, - hashlineEditParamsSchema, -} from "./modes/hashline"; -import hashlineGrammarTemplate from "./modes/hashline.lark" with { type: "text" }; import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch"; import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace"; import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer"; @@ -34,13 +34,12 @@ export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/ed export * from "./apply-patch"; export * from "./diff"; export * from "./file-read-cache"; -export * from "./line-hash"; // Resolve the `$HFMT$` and `$HSEP$` placeholders in the hashline Lark grammar. const hashlineGrammar = resolveHashlineGrammarPlaceholders(hashlineGrammarTemplate); +export * from "../hashline"; export * from "./modes/apply-patch"; -export * from "./modes/hashline"; export * from "./modes/patch"; export * from "./modes/replace"; export * from "./normalize"; diff --git a/packages/coding-agent/src/edit/modes/hashline.ts b/packages/coding-agent/src/edit/modes/hashline.ts deleted file mode 100644 index 898aac8d3..000000000 --- a/packages/coding-agent/src/edit/modes/hashline.ts +++ /dev/null @@ -1,1978 +0,0 @@ -/** - * Hashline edit mode. - * - * A compact, line-anchored wire format for file edits. Each section starts - * with `@PATH`. Edit ops are explicit blocks (`+ ANCHOR`, `- A..B`, `= A..B`) - * with payload lines prefixed by `|`. - * - * The module is organized into the following sections: - * - * 1. Imports - * 2. Public types & schemas - * 3. Constants & shared regexes - * 4. Small string utilities - * 5. Read-output prefix stripping (stripNewLinePrefixes, hashlineParseText) - * 6. Hashline streaming (streamHashLinesFromUtf8) - * 7. Anchor parsing & validation (parseTag, parseLid, parseRange, ...) - * 8. Mismatch error (HashlineMismatchError) - * 9. Compact diff preview (buildCompactHashlineDiffPreview) - * 10. Edit DSL parsing (parseHashline, parseHashlineWithWarnings) - * 11. Edit application (applyHashlineEdits) - * 12. Input splitting (splitHashlineInput, splitHashlineInputs) - * 13. Diff computation (computeHashlineDiff) - * 14. Execution (executeHashlineSingle) - */ - -// ─────────────────────────────────────────────────────────────────────────── -// 1. Imports -// ─────────────────────────────────────────────────────────────────────────── - -import * as path from "node:path"; -import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { isEnoent } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; -import * as Diff from "diff"; -import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; -import type { ToolSession } from "../../tools"; -import { assertEditableFileContent } from "../../tools/auto-generated-guard"; -import { 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 { formatCodeFrameLine } from "../../tools/render-utils"; -import { generateDiffString } from "../diff"; -import { type FileReadCache, getFileReadCache } from "../file-read-cache"; -import { - computeLineHash, - describeAnchorExamples, - formatHashLine, - HL_ANCHOR_RE_RAW, - HL_BODY_SEP, - HL_BODY_SEP_RE_RAW, - HL_EDIT_SEP, - HL_EDIT_SEP_RE_RAW, - HL_HASH_CAPTURE_RE_RAW, -} from "../line-hash"; -import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize"; -import { readEditFileText, serializeEditFileText } from "../read-file"; -import type { EditToolDetails, LspBatchRequest } from "../renderer"; - -// ─────────────────────────────────────────────────────────────────────────── -// 2. Public types & schemas -// ─────────────────────────────────────────────────────────────────────────── - -export interface HashMismatch { - line: number; - expected: string; - actual: string; -} - -export type Anchor = { - line: number; - hash: string; - contentHint?: string; -}; - -type HashlineCursor = - | { kind: "bof" } - | { kind: "eof" } - | { kind: "before_anchor"; anchor: Anchor } - | { kind: "after_anchor"; anchor: Anchor }; - -export type HashlineEdit = - | { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number } - | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string } - | { kind: "modify"; anchor: Anchor; prefix: string; suffix: string; lineNum: number; index: number }; - -export const hashlineEditParamsSchema = Type.Object({ input: Type.String() }); -export type HashlineParams = Static; - -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; -} - -export interface CompactHashlineDiffPreview { - preview: string; - addedLines: number; - removedLines: number; -} - -export interface CompactHashlineDiffOptions { - /** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */ - maxUnchangedRun?: number; -} -export interface HashlineApplyOptions { - autoDropPureInsertDuplicates?: boolean; -} - -export interface SplitHashlineOptions { - cwd?: string; - path?: string; -} - -export interface ExecuteHashlineSingleOptions { - session: ToolSession; - input: string; - path?: string; - signal?: AbortSignal; - batchRequest?: LspBatchRequest; - writethrough: WritethroughCallback; - beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle; -} - -// ─────────────────────────────────────────────────────────────────────────── -// 3. Constants & shared regexes -// ─────────────────────────────────────────────────────────────────────────── - -/** Lines of context shown either side of a hash mismatch. */ -const MISMATCH_CONTEXT = 2; - -/** Filler hash used for the interior of a multi-line range; not validated. */ -const RANGE_INTERIOR_HASH = "**"; - -/** Header marker introducing a new file section in multi-section input. */ -const FILE_HEADER_PREFIX = "@"; - -const HL_EDIT_SEPARATOR_RE = HL_EDIT_SEP_RE_RAW; -const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`; -const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`); -const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`); -const DIFF_PLUS_RE = /^[+](?![+])/; -const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/; - -const HL_HASH_HINT_RE = /^[a-z]{2}$/i; -const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160"); - -const PARSE_TAG_RE = new RegExp(`^${HL_ANCHOR_RE_RAW}`); -const LID_CAPTURE_RE = new RegExp(`^${HL_HASH_CAPTURE_RE_RAW}$`); - -// ─────────────────────────────────────────────────────────────────────────── -// 4. Small string utilities -// ─────────────────────────────────────────────────────────────────────────── - -function stripTrailingCarriageReturn(line: string): string { - return line.endsWith("\r") ? line.slice(0, -1) : line; -} - -function stripLeadingHashlinePrefixes(line: string): string { - let result = line; - let previous: string; - do { - previous = result; - result = result.replace(HL_PREFIX_RE, ""); - } while (result !== previous); - return result; -} - -// ─────────────────────────────────────────────────────────────────────────── -// 5. Read-output prefix stripping -// -// When a model echoes back content from a `read` or `search` response, every -// line is prefixed with either a hashline tag (`123ab|`) or, for diff-style -// echoes, a leading `+`. These helpers detect that and recover the raw text. -// ─────────────────────────────────────────────────────────────────────────── - -type LinePrefixStats = { - nonEmpty: number; - hashPrefixCount: number; - diffPlusHashPrefixCount: number; - diffPlusCount: number; - truncationNoticeCount: number; -}; - -function collectLinePrefixStats(lines: string[]): LinePrefixStats { - const stats: LinePrefixStats = { - nonEmpty: 0, - hashPrefixCount: 0, - diffPlusHashPrefixCount: 0, - diffPlusCount: 0, - truncationNoticeCount: 0, - }; - - for (const line of lines) { - if (line.length === 0) continue; - if (READ_TRUNCATION_NOTICE_RE.test(line)) { - stats.truncationNoticeCount++; - continue; - } - stats.nonEmpty++; - if (HL_PREFIX_RE.test(line)) stats.hashPrefixCount++; - if (HL_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++; - if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++; - } - return stats; -} - -export function stripNewLinePrefixes(lines: string[]): string[] { - const stats = collectLinePrefixStats(lines); - if (stats.nonEmpty === 0) return lines; - - const stripHash = stats.hashPrefixCount > 0 && stats.hashPrefixCount === stats.nonEmpty; - const stripPlus = - !stripHash && - stats.diffPlusHashPrefixCount === 0 && - stats.diffPlusCount > 0 && - stats.diffPlusCount >= stats.nonEmpty * 0.5; - - if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines; - - return lines - .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)) - .map(line => { - if (stripHash) return stripLeadingHashlinePrefixes(line); - if (stripPlus) return line.replace(DIFF_PLUS_RE, ""); - if (stats.diffPlusHashPrefixCount > 0 && HL_PREFIX_PLUS_RE.test(line)) { - return line.replace(HL_PREFIX_RE, ""); - } - return line; - }); -} - -export function stripHashlinePrefixes(lines: string[]): string[] { - const stats = collectLinePrefixStats(lines); - if (stats.nonEmpty === 0) return lines; - if (stats.hashPrefixCount !== stats.nonEmpty) return lines; - return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line)); -} - -/** - * Normalize line payloads by stripping read/search line prefixes. `null` / - * `undefined` yield `[]`; a single multiline string is split on `\n`. - */ -export function hashlineParseText(edit: string[] | string | null | undefined): string[] { - if (edit == null) return []; - if (typeof edit === "string") { - const trimmed = edit.endsWith("\n") ? edit.slice(0, -1) : edit; - edit = trimmed.replaceAll("\r", "").split("\n"); - } - return stripNewLinePrefixes(edit); -} - -// ─────────────────────────────────────────────────────────────────────────── -// 6. Hashline streaming -// -// Convert a UTF-8 byte stream into a sequence of formatted hashline chunks, -// each capped by line count and byte size. -// ─────────────────────────────────────────────────────────────────────────── - -interface ResolvedHashlineStreamOptions { - startLine: number; - maxChunkLines: number; - maxChunkBytes: number; -} - -function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedHashlineStreamOptions { - return { - startLine: options.startLine ?? 1, - maxChunkLines: options.maxChunkLines ?? 200, - maxChunkBytes: options.maxChunkBytes ?? 64 * 1024, - }; -} - -interface HashlineChunkEmitter { - pushLine: (line: string) => string[]; - flush: () => string | undefined; -} - -function createHashlineChunkEmitter(options: ResolvedHashlineStreamOptions): 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 = formatHashLine(lineNumber, line); - lineNumber++; - - const chunks: string[] = []; - const sepBytes = outLines.length === 0 ? 0 : 1; - const lineBytes = Buffer.byteLength(formatted, "utf-8"); - const wouldOverflow = - outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes; - - if (outLines.length > 0 && wouldOverflow) { - const flushed = flush(); - if (flushed) chunks.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) chunks.push(flushed); - } - return chunks; - }; - - return { pushLine, flush }; -} - -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(); - } -} - -export async function* streamHashLinesFromUtf8( - source: ReadableStream | AsyncIterable, - options: HashlineStreamOptions = {}, -): AsyncGenerator { - const resolved = resolveHashlineStreamOptions(options); - const decoder = new TextDecoder("utf-8"); - const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source; - const emitter = createHashlineChunkEmitter(resolved); - - let pending = ""; - let sawAnyLine = false; - - for await (const chunk of chunks) { - pending += decoder.decode(chunk, { stream: true }); - let nl = pending.indexOf("\n"); - while (nl !== -1) { - const raw = pending.slice(0, nl); - const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; - sawAnyLine = true; - for (const out of emitter.pushLine(line)) yield out; - pending = pending.slice(nl + 1); - nl = pending.indexOf("\n"); - } - } - - pending += decoder.decode(); - if (pending.length > 0) { - sawAnyLine = true; - const tail = pending.endsWith("\r") ? pending.slice(0, -1) : pending; - for (const out of emitter.pushLine(tail)) yield out; - } - if (!sawAnyLine) { - for (const out of emitter.pushLine("")) yield out; - } - - const last = emitter.flush(); - if (last) yield last; -} - -// ─────────────────────────────────────────────────────────────────────────── -// 7. Anchor parsing & validation -// ─────────────────────────────────────────────────────────────────────────── - -export function formatFullAnchorRequirement(raw?: string): string { - const suffix = typeof raw === "string" ? raw.trim() : ""; - const hashOnlyHint = HL_HASH_HINT_RE.test(suffix) - ? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` + - `Copy the full anchor exactly as shown (for example, "160${suffix}").` - : ""; - const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`; - return ( - `the full anchor exactly as shown by read/search output ` + - `(line number + hash, for example ${HL_ANCHOR_EXAMPLES})${received}${hashOnlyHint}` - ); -} - -export function parseTag(ref: string): { line: number; hash: string } { - const match = ref.match(PARSE_TAG_RE); - if (!match) { - throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`); - } - 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] }; -} - -function parseLid(raw: string, lineNum: number): Anchor { - const match = LID_CAPTURE_RE.exec(raw); - if (!match) { - throw new Error( - `line ${lineNum}: expected a full anchor such as ${describeAnchorExamples("119")}; ` + - `got ${JSON.stringify(raw)}.`, - ); - } - return { line: Number.parseInt(match[1], 10), hash: match[2] }; -} - -interface ParsedRange { - start: Anchor; - end: Anchor; -} - -function parseRange(raw: string, lineNum: number): ParsedRange { - const [startRaw, endRaw] = raw.split(".."); - if (!startRaw) throw new Error(`line ${lineNum}: range is missing its first anchor.`); - const start = parseLid(startRaw, lineNum); - const end = endRaw === undefined ? { ...start } : parseLid(endRaw, lineNum); - if (end.line < start.line) { - throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} ends before it starts.`); - } - if (end.line === start.line && end.hash !== start.hash) { - throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} uses two different hashes for the same line.`); - } - return { start, end }; -} - -function expandRange(range: ParsedRange): Anchor[] { - const anchors: Anchor[] = []; - for (let line = range.start.line; line <= range.end.line; line++) { - const hash = - line === range.start.line ? range.start.hash : line === range.end.line ? range.end.hash : RANGE_INTERIOR_HASH; - anchors.push({ line, hash }); - } - return anchors; -} - -function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): HashlineCursor { - if (raw === "BOF") return { kind: "bof" }; - if (raw === "EOF") return { kind: "eof" }; - const cursorKind = kind === "before" ? "before_anchor" : "after_anchor"; - return { kind: cursorKind, anchor: parseLid(raw, lineNum) }; -} - -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); - } -} - -// ─────────────────────────────────────────────────────────────────────────── -// 8. Mismatch error -// ─────────────────────────────────────────────────────────────────────────── - -function getMismatchDisplayLines(mismatches: HashMismatch[], fileLines: string[]): number[] { - const displayLines = new Set(); - for (const mismatch of mismatches) { - const lo = Math.max(1, mismatch.line - MISMATCH_CONTEXT); - const hi = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT); - for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum); - } - return [...displayLines].sort((a, b) => a - b); -} - -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 mismatch of mismatches) { - const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? ""); - remaps.set(`${mismatch.line}${mismatch.expected}`, `${mismatch.line}${actual}`); - } - this.remaps = remaps; - } - - get displayMessage(): string { - return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines); - } - - private static rejectionHeader(mismatches: HashMismatch[]): string[] { - const noun = mismatches.length > 1 ? "lines have" : "line has"; - return [ - `Edit rejected: ${mismatches.length} ${noun} changed since the last read (marked *).`, - "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.", - ]; - } - - static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string { - const mismatchSet = new Set(mismatches.map(m => m.line)); - const displayLines = getMismatchDisplayLines(mismatches, fileLines); - const width = displayLines.reduce((cur, n) => Math.max(cur, String(n).length), 0); - - const out = [...HashlineMismatchError.rejectionHeader(mismatches), ""]; - let previous = -1; - for (const lineNum of displayLines) { - if (previous !== -1 && lineNum > previous + 1) out.push("..."); - previous = lineNum; - const marker = mismatchSet.has(lineNum) ? "*" : " "; - out.push(formatCodeFrameLine(marker, lineNum, fileLines[lineNum - 1] ?? "", width)); - } - return out.join("\n"); - } - - static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string { - const mismatchSet = new Set(mismatches.map(m => m.line)); - const lines = HashlineMismatchError.rejectionHeader(mismatches); - let previous = -1; - for (const lineNum of getMismatchDisplayLines(mismatches, fileLines)) { - if (previous !== -1 && lineNum > previous + 1) lines.push("..."); - previous = lineNum; - const text = fileLines[lineNum - 1] ?? ""; - const hash = computeLineHash(lineNum, text); - const marker = mismatchSet.has(lineNum) ? "*" : " "; - lines.push(`${marker}${lineNum}${hash}${HL_BODY_SEP}${text}`); - } - return lines.join("\n"); - } -} - -// ─────────────────────────────────────────────────────────────────────────── -// 9. Compact diff preview -// ─────────────────────────────────────────────────────────────────────────── - -export function buildCompactHashlineDiffPreview( - diff: string, - _options: CompactHashlineDiffOptions = {}, -): CompactHashlineDiffPreview { - const lines = diff.length === 0 ? [] : diff.split("\n"); - let addedLines = 0; - let removedLines = 0; - - // `generateDiffString` numbers `+` lines with the post-edit line number, - // `-` lines with the pre-edit line number, and context lines with the - // pre-edit line number. To emit fresh anchors usable for follow-up edits, - // we convert context-line numbers to post-edit positions by tracking the - // running offset (added so far - removed so far) as we walk the diff. - const formatted = lines.map(line => { - const kind = line[0]; - if (kind !== "+" && kind !== "-" && kind !== " ") return line; - - const body = line.slice(1); - const sep = body.indexOf("|"); - if (sep === -1) return line; - - const lineNumber = Number.parseInt(body.slice(0, sep), 10); - const content = body.slice(sep + 1); - - switch (kind) { - case "+": - addedLines++; - return `+${lineNumber}${computeLineHash(lineNumber, content)}${HL_BODY_SEP}${content}`; - case "-": - removedLines++; - return `-${lineNumber}--${HL_BODY_SEP}${content}`; - default: { - const newLineNumber = lineNumber + addedLines - removedLines; - return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HL_BODY_SEP}${content}`; - } - } - }); - - return { preview: formatted.join("\n"), addedLines, removedLines }; -} - -// ─────────────────────────────────────────────────────────────────────────── -// 10. Edit DSL parsing -// -// Grammar (one op per "block"): -// "+ ANCHOR" followed by 1+ "TEXT" payload lines — insert -// "- A..B" no payload — delete range -// "= A..B" followed by 1+ "TEXT" payload lines — replace -// -// ANCHOR is `LINE`, e.g. `160ab`. BOF / EOF are also valid insert targets. -// ─────────────────────────────────────────────────────────────────────────── - -const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/; -const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/; -const DELETE_OP_RE = /^-\s*(\S+)$/; -const REPLACE_OP_RE = /^=\s*(\S+)$/; -const INLINE_BEFORE_OP_RE = new RegExp(`^<\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`); -const INLINE_AFTER_OP_RE = new RegExp(`^\\+\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`); - -function cloneCursor(cursor: HashlineCursor): HashlineCursor { - if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } }; - if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } }; - return cursor; -} - -function collectPayload( - lines: string[], - startIndex: number, - opLineNum: number, - requirePayload: boolean, -): { payload: string[]; nextIndex: number } { - const payload: string[] = []; - let index = startIndex; - while (index < lines.length) { - const line = stripTrailingCarriageReturn(lines[index]); - if (!line.startsWith(HL_EDIT_SEP)) break; - payload.push(line.slice(1)); - index++; - } - if (payload.length === 0 && requirePayload) { - throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`); - } - return { payload, nextIndex: index }; -} - -export function parseHashline(diff: string): HashlineEdit[] { - return parseHashlineWithWarnings(diff).edits; -} - -export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } { - const edits: HashlineEdit[] = []; - const warnings: string[] = []; - const lines = diff.split("\n"); - let editIndex = 0; - - const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => { - edits.push({ kind: "insert", cursor: cloneCursor(cursor), text, lineNum, index: editIndex++ }); - }; - - for (let i = 0; i < lines.length; ) { - const lineNum = i + 1; - const line = stripTrailingCarriageReturn(lines[i]); - - if (line.trim().length === 0) { - i++; - continue; - } - if (line.startsWith(HL_EDIT_SEP)) { - throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`); - } - - const inlineBeforeMatch = INLINE_BEFORE_OP_RE.exec(line); - if (inlineBeforeMatch) { - const anchor = parseLid(`${inlineBeforeMatch[1]}${inlineBeforeMatch[2]}`, lineNum); - edits.push({ - kind: "modify", - anchor, - prefix: inlineBeforeMatch[3], - suffix: "", - lineNum, - index: editIndex++, - }); - const cursor: HashlineCursor = { kind: "before_anchor", anchor }; - const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false); - for (const text of payload) pushInsert(cursor, text, lineNum); - i = nextIndex; - continue; - } - - const inlineAfterMatch = INLINE_AFTER_OP_RE.exec(line); - if (inlineAfterMatch) { - const anchor = parseLid(`${inlineAfterMatch[1]}${inlineAfterMatch[2]}`, lineNum); - edits.push({ - kind: "modify", - anchor, - prefix: "", - suffix: inlineAfterMatch[3], - lineNum, - index: editIndex++, - }); - const cursor: HashlineCursor = { kind: "after_anchor", anchor }; - const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false); - for (const text of payload) pushInsert(cursor, text, lineNum); - i = nextIndex; - continue; - } - - const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line); - if (insertBeforeMatch) { - const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before"); - const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true); - for (const text of payload) pushInsert(cursor, text, lineNum); - i = nextIndex; - continue; - } - - const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line); - if (insertAfterMatch) { - const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after"); - const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true); - for (const text of payload) pushInsert(cursor, text, lineNum); - i = nextIndex; - continue; - } - - const deleteMatch = DELETE_OP_RE.exec(line); - if (deleteMatch) { - for (const anchor of expandRange(parseRange(deleteMatch[1], lineNum))) { - edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ }); - } - i++; - continue; - } - - const replaceMatch = REPLACE_OP_RE.exec(line); - if (replaceMatch) { - const range = parseRange(replaceMatch[1], lineNum); - const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false); - // `= A..B` with no payload blanks the range to a single empty line. - const replacement = payload.length === 0 ? [""] : payload; - for (const text of replacement) { - edits.push({ - kind: "insert", - cursor: { kind: "before_anchor", anchor: { ...range.start } }, - text, - lineNum, - index: editIndex++, - }); - } - for (const anchor of expandRange(range)) { - edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ }); - } - i = nextIndex; - continue; - } - - throw new Error( - `line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or "${HL_EDIT_SEP}TEXT" payload lines. ` + - `Got ${JSON.stringify(line)}.`, - ); - } - - return { edits, warnings }; -} - -// ─────────────────────────────────────────────────────────────────────────── -// 11. Edit application -// ─────────────────────────────────────────────────────────────────────────── - -interface HashlineApplyResult { - lines: string; - firstChangedLine?: number; - warnings?: string[]; - noopEdits?: HashlineNoopEdit[]; -} - -interface HashlineNoopEdit { - editIndex: number; - loc: string; - reason: string; - current: string; -} - -type HashlineLineOrigin = "original" | "insert" | "replacement"; - -interface IndexedEdit { - edit: HashlineEdit; - idx: number; -} - -type HashlineDeleteEdit = Extract; - -interface HashlineReplacementGroup { - startIndex: number; - endIndex: number; - sourceLineNum: number; - replacement: string[]; - deletes: HashlineDeleteEdit[]; -} - -function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] { - if (edit.kind === "delete") return [edit.anchor]; - if (edit.kind === "modify") return [edit.anchor]; - if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor]; - if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor]; - return []; -} - -/** - * Verify every anchor's hash. Any mismatch is reported as a `HashMismatch`; - * there is no auto-rebase. Callers are expected to surface mismatches as - * `HashlineMismatchError` so the model re-reads and re-anchors. - */ -function validateHashlineAnchors(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] { - const mismatches: HashMismatch[] = []; - for (const edit of edits) { - for (const anchor of getHashlineEditAnchors(edit)) { - if (anchor.line < 1 || anchor.line > fileLines.length) { - throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`); - } - if (anchor.hash === RANGE_INTERIOR_HASH) continue; - - const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1] ?? ""); - if (actualHash === anchor.hash) continue; - - mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash }); - } - } - return mismatches; -} - -function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void { - if (lines.length === 0) return; - const origins = lines.map((): HashlineLineOrigin => "insert"); - if (fileLines.length === 1 && fileLines[0] === "") { - fileLines.splice(0, 1, ...lines); - lineOrigins.splice(0, 1, ...origins); - return; - } - fileLines.splice(0, 0, ...lines); - lineOrigins.splice(0, 0, ...origins); -} - -function insertAtEnd(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): number | undefined { - if (lines.length === 0) return undefined; - const origins = lines.map((): HashlineLineOrigin => "insert"); - if (fileLines.length === 1 && fileLines[0] === "") { - fileLines.splice(0, 1, ...lines); - lineOrigins.splice(0, 1, ...origins); - return 1; - } - const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === ""; - const insertIndex = hasTrailingNewline ? fileLines.length - 1 : fileLines.length; - fileLines.splice(insertIndex, 0, ...lines); - lineOrigins.splice(insertIndex, 0, ...origins); - return insertIndex + 1; -} - -/** Bucket edits by the line they target so we can apply each line's group in one splice. */ - -function getAnchorTargetLine(edit: HashlineEdit): number | undefined { - if (edit.kind === "delete" || edit.kind === "modify") return edit.anchor.line; - if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line; - return undefined; -} - -function collectAnchorTargetLines(edits: HashlineEdit[]): Set { - const lines = new Set(); - for (const edit of edits) { - const line = getAnchorTargetLine(edit); - if (line !== undefined) lines.add(line); - } - return lines; -} - -function findReplacementGroup(edits: HashlineEdit[], startIndex: number): HashlineReplacementGroup | undefined { - const first = edits[startIndex]; - if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined; - - const sourceLineNum = first.lineNum; - const replacement: string[] = []; - let index = startIndex; - while (index < edits.length) { - const edit = edits[index]; - if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break; - replacement.push(edit.text); - index++; - } - - const deletes: HashlineDeleteEdit[] = []; - while (index < edits.length) { - const edit = edits[index]; - if (edit.kind !== "delete" || edit.lineNum !== sourceLineNum) break; - deletes.push(edit); - index++; - } - if (deletes.length === 0) return undefined; - - const startLine = deletes[0].anchor.line; - for (let offset = 0; offset < deletes.length; offset++) { - if (deletes[offset].anchor.line !== startLine + offset) return undefined; - } - const cursorLine = first.cursor.anchor.line; - if (cursorLine !== startLine) return undefined; - - return { startIndex, endIndex: index - 1, sourceLineNum, replacement, deletes }; -} - -function countMatchingPrefixBlock(fileLines: string[], startLine: number, replacement: string[]): number { - const max = Math.min(replacement.length, startLine - 1); - for (let count = max; count >= 2; count--) { - let matches = true; - for (let offset = 0; offset < count; offset++) { - if (fileLines[startLine - count - 1 + offset] !== replacement[offset]) { - matches = false; - break; - } - } - if (matches) return count; - } - return 0; -} - -function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacement: string[]): number { - const max = Math.min(replacement.length, fileLines.length - endLine); - for (let count = max; count >= 2; count--) { - let matches = true; - for (let offset = 0; offset < count; offset++) { - if (fileLines[endLine + offset] !== replacement[replacement.length - count + offset]) { - matches = false; - break; - } - } - if (matches) return count; - } - return 0; -} - -// Single-line duplicate absorption is limited to structural closing delimiters. -// General one-line context is too easy to delete incorrectly, but duplicated -// `};` / `)` / `]` boundaries usually indicate a replacement range stopped one -// line early and would otherwise produce a syntax error. -const STRUCTURAL_CLOSING_BOUNDARY_RE = /^\s*[\])}]+[;,]?\s*$/; - -function isStructuralClosingBoundaryLine(line: string): boolean { - return STRUCTURAL_CLOSING_BOUNDARY_RE.test(line); -} - -interface DelimiterBalance { - paren: number; - bracket: number; - brace: number; -} - -const ZERO_DELIMITER_BALANCE: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 }; - -/** - * Naive bracket counter — does NOT skip string/template/comment contents. The - * single-line structural absorb relies on this being safe-by-asymmetry: the - * candidate boundary line is constrained by `STRUCTURAL_CLOSING_BOUNDARY_RE` - * to be pure delimiters, so noise in deleted lines or non-boundary kept payload - * tends to push `expected !== kept` and biases the heuristic toward NOT - * absorbing (the safe direction). If we ever extend this to opening boundaries - * or non-structural single lines, swap this for a real tokenizer. - */ -function computeDelimiterBalance(lines: string[]): DelimiterBalance { - const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 }; - for (const line of lines) { - for (const char of line) { - switch (char) { - case "(": - balance.paren++; - break; - case ")": - balance.paren--; - break; - case "[": - balance.bracket++; - break; - case "]": - balance.bracket--; - break; - case "{": - balance.brace++; - break; - case "}": - balance.brace--; - break; - } - } - } - return balance; -} - -function delimiterBalancesEqual(a: DelimiterBalance, b: DelimiterBalance): boolean { - return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace; -} - -/** - * Decides whether the structural-boundary candidate should be dropped: the - * `keptPayload` (full payload with the boundary line removed) must restore the - * caller's `expectedBalance`, while the `fullPayload` (boundary line still - * present) must NOT. For replacements `expectedBalance` is the deleted - * region's net delimiter balance; for pure inserts it is zero. - */ -function shouldDropSingleStructuralBoundary( - fullPayload: string[], - keptPayload: string[], - expectedBalance: DelimiterBalance, -): boolean { - return ( - delimiterBalancesEqual(computeDelimiterBalance(keptPayload), expectedBalance) && - !delimiterBalancesEqual(computeDelimiterBalance(fullPayload), expectedBalance) - ); -} - -function countMatchingSingleStructuralPrefixBoundary( - fileLines: string[], - startLine: number, - replacement: string[], - expectedBalance: DelimiterBalance, -): number { - if (replacement.length === 0 || startLine <= 1) return 0; - const line = replacement[0]; - if (!isStructuralClosingBoundaryLine(line)) return 0; - if (fileLines[startLine - 2] !== line) return 0; - return shouldDropSingleStructuralBoundary(replacement, replacement.slice(1), expectedBalance) ? 1 : 0; -} - -function countMatchingSingleStructuralSuffixBoundary( - fileLines: string[], - endLine: number, - replacement: string[], - expectedBalance: DelimiterBalance, -): number { - if (replacement.length === 0 || endLine >= fileLines.length) return 0; - const line = replacement[replacement.length - 1]; - if (!isStructuralClosingBoundaryLine(line)) return 0; - if (fileLines[endLine] !== line) return 0; - return shouldDropSingleStructuralBoundary(replacement, replacement.slice(0, -1), expectedBalance) ? 1 : 0; -} - -function hasExternalTargets(lines: Iterable, externalTargetLines: Set): boolean { - for (const line of lines) { - if (externalTargetLines.has(line)) return true; - } - return false; -} - -function contiguousRange(start: number, count: number): number[] { - return Array.from({ length: count }, (_, offset) => start + offset); -} - -function deleteEditForAutoAbsorbedLine( - line: number, - sourceLineNum: number, - index: number, - fileLines: string[], -): HashlineEdit { - return { - kind: "delete", - anchor: { line, hash: computeLineHash(line, fileLines[line - 1] ?? "") }, - lineNum: sourceLineNum, - index, - }; -} - -interface HashlinePureInsertGroup { - startIndex: number; - endIndex: number; - sourceLineNum: number; - cursor: HashlineCursor; - payload: string[]; -} - -function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean { - if (a.kind !== b.kind) return false; - if (a.kind === "bof" || a.kind === "eof") return true; - const aAnchor = (a as { anchor: Anchor }).anchor; - const bAnchor = (b as { anchor: Anchor }).anchor; - return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash; -} - -/** - * Collects a run of consecutive `insert` edits that all share the same - * `lineNum` and `cursor`, IFF that run is not immediately followed by a - * `delete` at the same `lineNum` (which would make it a replacement group - * instead). Returns the contiguous payload so we can check it for boundary - * duplicates against the file. - */ -function findPureInsertGroup(edits: HashlineEdit[], startIndex: number): HashlinePureInsertGroup | undefined { - const first = edits[startIndex]; - if (first?.kind !== "insert") return undefined; - - const sourceLineNum = first.lineNum; - const cursor = first.cursor; - const payload: string[] = []; - let index = startIndex; - while (index < edits.length) { - const edit = edits[index]; - if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break; - if (!cursorMatches(edit.cursor, cursor)) break; - payload.push(edit.text); - index++; - } - - // If the run is followed by a delete at the same source lineNum, this is a - // replacement group (handled by absorbReplacement…). Decline. - if (index < edits.length && edits[index].kind === "delete" && edits[index].lineNum === sourceLineNum) { - return undefined; - } - - return { startIndex, endIndex: index - 1, sourceLineNum, cursor, payload }; -} - -/** - * For a pure-insert group, locate the file region adjacent to the insertion - * point. Returns 0-indexed bounds: - * - `aboveEndIdx`: index of the last file line strictly above the insertion - * point (-1 if none). - * - `belowStartIdx`: index of the first file line strictly below the - * insertion point (`fileLines.length` if none). - */ -function pureInsertNeighborhood( - cursor: HashlineCursor, - fileLines: string[], -): { aboveEndIdx: number; belowStartIdx: number } { - if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 }; - if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length }; - if (cursor.kind === "before_anchor") { - return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 }; - } - // after_anchor - return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line }; -} - -interface PureInsertAbsorbResult { - keptPayload: string[]; - absorbedLeading: number; - absorbedTrailing: number; - leadingFileRange?: { start: number; end: number }; // 1-indexed inclusive - trailingFileRange?: { start: number; end: number }; // 1-indexed inclusive -} - -/** - * Mirror of replacement-absorb's prefix/suffix block check, but for pure - * inserts: drop payload lines that exactly duplicate the file lines - * immediately above (leading) or immediately below (trailing) the insertion - * point. Generic context echo absorption requires a minimum run of 2, but a - * single structural closing delimiter is absorbed because duplicated `}` / - * `});`-style boundaries almost always mean the insert included adjacent - * context. - */ -function tryAbsorbPureInsertGroup( - group: HashlinePureInsertGroup, - fileLines: string[], - allowGenericBoundaryAbsorb: boolean, -): PureInsertAbsorbResult { - const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 }; - if (group.payload.length === 0) return empty; - - const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines); - - // Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx]. - let absorbedLeading = 0; - if (allowGenericBoundaryAbsorb) { - const maxLead = Math.min(group.payload.length, aboveEndIdx + 1); - for (let count = maxLead; count >= 2; count--) { - let ok = true; - for (let offset = 0; offset < count; offset++) { - if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) { - ok = false; - break; - } - } - if (ok) { - absorbedLeading = count; - break; - } - } - } - if ( - absorbedLeading === 0 && - group.payload.length > 0 && - aboveEndIdx >= 0 && - isStructuralClosingBoundaryLine(group.payload[0]) && - group.payload[0] === fileLines[aboveEndIdx] && - shouldDropSingleStructuralBoundary(group.payload, group.payload.slice(1), ZERO_DELIMITER_BALANCE) - ) { - absorbedLeading = 1; - } - - // Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1]. - // Don't double-count payload lines already absorbed as leading. - let absorbedTrailing = 0; - const remainingPayload = group.payload.slice(absorbedLeading); - const remaining = remainingPayload.length; - if (allowGenericBoundaryAbsorb) { - const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx); - for (let count = maxTrail; count >= 2; count--) { - let ok = true; - for (let offset = 0; offset < count; offset++) { - if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) { - ok = false; - break; - } - } - if (ok) { - absorbedTrailing = count; - break; - } - } - } - if ( - absorbedTrailing === 0 && - remaining > 0 && - belowStartIdx < fileLines.length && - isStructuralClosingBoundaryLine(remainingPayload[remainingPayload.length - 1]) && - remainingPayload[remainingPayload.length - 1] === fileLines[belowStartIdx] && - shouldDropSingleStructuralBoundary(remainingPayload, remainingPayload.slice(0, -1), ZERO_DELIMITER_BALANCE) - ) { - absorbedTrailing = 1; - } - - if (absorbedLeading === 0 && absorbedTrailing === 0) return empty; - - return { - keptPayload: group.payload.slice(absorbedLeading, group.payload.length - absorbedTrailing), - absorbedLeading, - absorbedTrailing, - leadingFileRange: - absorbedLeading > 0 ? { start: aboveEndIdx - absorbedLeading + 2, end: aboveEndIdx + 1 } : undefined, - trailingFileRange: - absorbedTrailing > 0 ? { start: belowStartIdx + 1, end: belowStartIdx + absorbedTrailing } : undefined, - }; -} - -function absorbReplacementBoundaryDuplicates( - edits: HashlineEdit[], - fileLines: string[], - warnings: string[], - options: HashlineApplyOptions, -): HashlineEdit[] { - let nextSyntheticIndex = edits.length; - const absorbed: HashlineEdit[] = []; - - // Anchor targets are stable across the loop because we only ever append - // synthetic deletes (never mutate originals). A line in this set that - // falls outside the current group's range is necessarily owned by another - // op, so absorbing it would silently steal its target. - const allTargetLines = collectAnchorTargetLines(edits); - const emittedAbsorbKeys = new Set(); - - for (let index = 0; index < edits.length; index++) { - const group = findReplacementGroup(edits, index); - if (!group) { - const pureInsert = findPureInsertGroup(edits, index); - if (pureInsert) { - const result = tryAbsorbPureInsertGroup( - pureInsert, - fileLines, - options.autoDropPureInsertDuplicates === true, - ); - if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) { - if (result.leadingFileRange) { - const { start, end } = result.leadingFileRange; - const key = `pure-insert-leading:${start}..${end}`; - if (!emittedAbsorbKeys.has(key)) { - emittedAbsorbKeys.add(key); - warnings.push( - `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` + - `(file lines ${start}..${end} already match the payload's leading lines).`, - ); - } - } - if (result.trailingFileRange) { - const { start, end } = result.trailingFileRange; - const key = `pure-insert-trailing:${start}..${end}`; - if (!emittedAbsorbKeys.has(key)) { - emittedAbsorbKeys.add(key); - warnings.push( - `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` + - `(file lines ${start}..${end} already match the payload's trailing lines).`, - ); - } - } - for (const text of result.keptPayload) { - absorbed.push({ - kind: "insert", - cursor: cloneCursor(pureInsert.cursor), - text, - lineNum: pureInsert.sourceLineNum, - index: nextSyntheticIndex++, - }); - } - index = pureInsert.endIndex; - continue; - } - for (let groupIndex = pureInsert.startIndex; groupIndex <= pureInsert.endIndex; groupIndex++) { - absorbed.push(edits[groupIndex]); - } - index = pureInsert.endIndex; - continue; - } - absorbed.push(edits[index]); - continue; - } - - const startLine = group.deletes[0].anchor.line; - const endLine = group.deletes[group.deletes.length - 1].anchor.line; - - const deletedBalance = computeDelimiterBalance( - group.deletes.map(deleteEdit => fileLines[deleteEdit.anchor.line - 1] ?? ""), - ); - const prefixCount = - countMatchingPrefixBlock(fileLines, startLine, group.replacement) || - countMatchingSingleStructuralPrefixBoundary(fileLines, startLine, group.replacement, deletedBalance); - const suffixCount = - countMatchingSuffixBlock(fileLines, endLine, group.replacement) || - countMatchingSingleStructuralSuffixBoundary(fileLines, endLine, group.replacement, deletedBalance); - const prefixLines = contiguousRange(startLine - prefixCount, prefixCount); - const suffixLines = contiguousRange(endLine + 1, suffixCount); - const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount; - const safeSuffixCount = hasExternalTargets(suffixLines, allTargetLines) ? 0 : suffixCount; - - if (safePrefixCount > 0) { - const absorbStart = startLine - safePrefixCount; - const key = `prefix:${absorbStart}..${startLine - 1}`; - if (!emittedAbsorbKeys.has(key)) { - emittedAbsorbKeys.add(key); - warnings.push( - `Auto-absorbed ${safePrefixCount} duplicate line(s) above replacement at line ${group.sourceLineNum} ` + - `(file lines ${absorbStart}..${startLine - 1} matched the payload's leading lines; ` + - `widened the deletion to absorb them).`, - ); - } - } - if (safeSuffixCount > 0) { - const absorbEnd = endLine + safeSuffixCount; - const key = `suffix:${endLine + 1}..${absorbEnd}`; - if (!emittedAbsorbKeys.has(key)) { - emittedAbsorbKeys.add(key); - warnings.push( - `Auto-absorbed ${safeSuffixCount} duplicate line(s) below replacement at line ${group.sourceLineNum} ` + - `(file lines ${endLine + 1}..${absorbEnd} matched the payload's trailing lines; ` + - `widened the deletion to absorb them).`, - ); - } - } - - for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) { - absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines)); - } - for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) { - absorbed.push(edits[groupIndex]); - } - for (const line of contiguousRange(endLine + 1, safeSuffixCount)) { - absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines)); - } - - index = group.endIndex; - } - - return absorbed; -} - -function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map { - const byLine = new Map(); - for (const entry of edits) { - const line = - entry.edit.kind === "delete" - ? entry.edit.anchor.line - : entry.edit.kind === "modify" - ? entry.edit.anchor.line - : entry.edit.cursor.kind === "before_anchor" - ? entry.edit.cursor.anchor.line - : 0; - const bucket = byLine.get(line); - if (bucket) bucket.push(entry); - else byLine.set(line, [entry]); - } - return byLine; -} - -export function applyHashlineEdits( - text: string, - edits: HashlineEdit[], - options: HashlineApplyOptions = {}, -): HashlineApplyResult { - if (edits.length === 0) return { lines: text, firstChangedLine: undefined }; - - const fileLines = text.split("\n"); - const lineOrigins: HashlineLineOrigin[] = fileLines.map(() => "original"); - const warnings: string[] = []; - - let firstChangedLine: number | undefined; - const trackFirstChanged = (line: number) => { - if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line; - }; - - const mismatches = validateHashlineAnchors(edits, fileLines); - if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines); - - const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options); - - // Normalize after_anchor inserts to before_anchor of the next line, or EOF - // when the anchor is the final line. This keeps the bucketing logic below - // (which only knows about before_anchor / bof / eof) untouched. - for (const edit of normalizedEdits) { - if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") continue; - const anchorLine = edit.cursor.anchor.line; - if (anchorLine >= fileLines.length) { - edit.cursor = { kind: "eof" }; - continue; - } - const nextLineNum = anchorLine + 1; - const nextContent = fileLines[nextLineNum - 1] ?? ""; - edit.cursor = { - kind: "before_anchor", - anchor: { line: nextLineNum, hash: computeLineHash(nextLineNum, nextContent) }, - }; - } - - // Partition edits into BOF, EOF, and anchor-targeted buckets. - const bofLines: string[] = []; - const eofLines: string[] = []; - const anchorEdits: IndexedEdit[] = []; - normalizedEdits.forEach((edit, idx) => { - if (edit.kind === "insert" && edit.cursor.kind === "bof") { - bofLines.push(edit.text); - } else if (edit.kind === "insert" && edit.cursor.kind === "eof") { - eofLines.push(edit.text); - } else { - anchorEdits.push({ edit, idx }); - } - }); - - // Apply per-line buckets bottom-up so earlier indices stay valid. - const byLine = bucketAnchorEditsByLine(anchorEdits); - for (const line of [...byLine.keys()].sort((a, b) => b - a)) { - const bucket = byLine.get(line); - if (!bucket) continue; - bucket.sort((a, b) => a.idx - b.idx); - - const idx = line - 1; - const currentLine = fileLines[idx] ?? ""; - const beforeLines: string[] = []; - let deleteLine = false; - let prefix = ""; - let suffix = ""; - let modified = false; - - for (const { edit } of bucket) { - if (edit.kind === "insert") { - beforeLines.push(edit.text); - } else if (edit.kind === "delete") { - deleteLine = true; - } else if (edit.kind === "modify") { - prefix = edit.prefix + prefix; - suffix = suffix + edit.suffix; - modified = true; - } - } - if (beforeLines.length === 0 && !deleteLine && !modified) continue; - if (deleteLine && modified) { - throw new Error( - `line ${line}: cannot combine inline modify ("< ${line}${HL_EDIT_SEP}…" or "+ ${line}${HL_EDIT_SEP}…") with a delete or replace targeting the same line.`, - ); - } - - const effectiveLine = modified ? prefix + currentLine + suffix : currentLine; - const replacement = deleteLine ? beforeLines : [...beforeLines, effectiveLine]; - const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert")); - if (!deleteLine) { - origins[origins.length - 1] = modified ? "replacement" : (lineOrigins[idx] ?? "original"); - } - - fileLines.splice(idx, 1, ...replacement); - lineOrigins.splice(idx, 1, ...origins); - trackFirstChanged(line); - } - - if (bofLines.length > 0) { - insertAtStart(fileLines, lineOrigins, bofLines); - trackFirstChanged(1); - } - const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines); - if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine); - - return { - lines: fileLines.join("\n"), - firstChangedLine, - ...(warnings.length > 0 ? { warnings } : {}), - }; -} - -// ─────────────────────────────────────────────────────────────────────────── -// 11b. Anchor-stale recovery via cached read snapshots -// -// When `applyHashlineEdits` rejects because some anchors no longer match the -// current on-disk content, the model may still have authored those anchors -// against a real, valid version of the file — one that was just rendered to -// it by the `read` or `search` tool, before something else (a subagent, a -// linter, the user) modified the file out-of-band. -// -// The cache in `file-read-cache.ts` keeps a small LRU snapshot of those -// rendered lines. We use it to reconstruct that "previous version", re-apply -// the edits against it, and then 3-way-merge the resulting diff back onto -// the live file. If the merge cleanly lands, that becomes our output. If -// it doesn't (or the cache doesn't even cover the failing anchors), we -// surface the original mismatch error so the model sees the truth. -// ─────────────────────────────────────────────────────────────────────────── - -export interface HashlineRecoveryArgs { - cache: FileReadCache; - absolutePath: string; - currentText: string; - edits: HashlineEdit[]; - options: HashlineApplyOptions; -} - -export interface HashlineRecoveryResult { - lines: string; - firstChangedLine: number | undefined; - warnings: string[]; -} - -const HASHLINE_RECOVERY_FUZZ_FACTOR = 3; - -const HASHLINE_RECOVERY_WARNING = - "Recovered from stale anchors using a previous read snapshot (file changed externally between read and edit)."; - -/** - * Attempt to recover from a `HashlineMismatchError` by replaying the edits - * against a cached pre-edit snapshot of the file and 3-way-merging the result - * onto the current on-disk content. Returns `null` when no recovery is - * possible — callers should propagate the original mismatch error in that - * case. - */ -export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): HashlineRecoveryResult | null { - const { cache, absolutePath, currentText, edits, options } = args; - const snapshot = cache.get(absolutePath); - if (!snapshot || snapshot.lines.size === 0) return null; - - const overlaid = currentText.split("\n"); - let maxCachedLine = 0; - for (const lineNum of snapshot.lines.keys()) { - if (lineNum > maxCachedLine) maxCachedLine = lineNum; - } - while (overlaid.length < maxCachedLine) overlaid.push(""); - for (const [lineNum, content] of snapshot.lines) { - overlaid[lineNum - 1] = content; - } - const previousText = overlaid.join("\n"); - if (previousText === currentText) return null; - - let applied: HashlineApplyResult; - try { - applied = applyHashlineEdits(previousText, edits, options); - } catch (err) { - if (err instanceof HashlineMismatchError) return null; - throw err; - } - if (applied.lines === previousText) return null; - - const patch = Diff.structuredPatch("file", "file", previousText, applied.lines, "", "", { context: 3 }); - const merged = Diff.applyPatch(currentText, patch, { fuzzFactor: HASHLINE_RECOVERY_FUZZ_FACTOR }); - if (typeof merged !== "string" || merged === currentText) return null; - - const mergedDiff = generateDiffString(currentText, merged); - const recoveryWarnings = [HASHLINE_RECOVERY_WARNING, ...(applied.warnings ?? [])]; - - return { - lines: merged, - firstChangedLine: mergedDiff.firstChangedLine ?? applied.firstChangedLine, - warnings: recoveryWarnings, - }; -} - -// ─────────────────────────────────────────────────────────────────────────── -// 12. Input splitting -// -// Hashline input may contain multiple file sections, each introduced by a -// header line of the form `@`. If the input contains recognizable ops -// but no header, we synthesize one from the caller-supplied `path` option. -// ─────────────────────────────────────────────────────────────────────────── - -export interface HashlineInputSection { - path: string; - diff: string; -} - -function unquoteHashlinePath(pathText: string): string { - if (pathText.length < 2) return pathText; - const first = pathText[0]; - const last = pathText[pathText.length - 1]; - if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1); - return pathText; -} - -function normalizeHashlinePath(rawPath: string, cwd?: string): string { - const unquoted = unquoteHashlinePath(rawPath.trim()); - if (!cwd || !path.isAbsolute(unquoted)) return unquoted; - const relative = path.relative(path.resolve(cwd), path.resolve(unquoted)); - const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); - return isWithinCwd ? relative || "." : unquoted; -} - -function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null { - const trimmed = line.trimEnd(); - if (trimmed === FILE_HEADER_PREFIX) { - throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`); - } - if (!trimmed.startsWith(FILE_HEADER_PREFIX)) return null; - const parsedPath = normalizeHashlinePath(trimmed.slice(1), cwd); - if (parsedPath.length === 0) { - throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`); - } - return { path: parsedPath, diff: "" }; -} - -function stripLeadingBlankLines(input: string): string { - const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input; - const lines = stripped.split("\n"); - while (lines.length > 0 && lines[0].replace(/\r$/, "").trim().length === 0) lines.shift(); - return lines.join("\n"); -} - -export function containsRecognizableHashlineOperations(input: string): boolean { - for (const rawLine of input.split("\n")) { - const line = stripTrailingCarriageReturn(rawLine); - if (/^[+<=-]\s+/.test(line) || line.startsWith(HL_EDIT_SEP)) return true; - } - return false; -} - -function normalizeFallbackInput(input: string, options: SplitHashlineOptions): string { - const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input; - const hasExplicitHeader = stripped - .split("\n") - .some(rawLine => parseHashlineHeaderLine(stripTrailingCarriageReturn(rawLine), options.cwd) !== null); - if (hasExplicitHeader) return input; - - if (!options.path || !containsRecognizableHashlineOperations(input)) return input; - const fallbackPath = normalizeHashlinePath(options.path, options.cwd); - if (fallbackPath.length === 0) return input; - return `${FILE_HEADER_PREFIX} ${fallbackPath}\n${input}`; -} - -export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } { - const [section] = splitHashlineInputs(input, options); - return section; -} - -export function splitHashlineInputs(input: string, options: SplitHashlineOptions = {}): HashlineInputSection[] { - const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options)); - const lines = stripped.split("\n"); - const firstLine = stripTrailingCarriageReturn(lines[0] ?? ""); - - if (parseHashlineHeaderLine(firstLine, options.cwd) === null) { - const preview = JSON.stringify(firstLine.slice(0, 120)); - throw new Error( - `input must begin with "@PATH" on the first non-blank line; got: ${preview}. ` + - `Example: "@src/foo.ts" then edit ops.`, - ); - } - - const sections: HashlineInputSection[] = []; - let currentPath = ""; - let currentLines: string[] = []; - - const flush = () => { - if (currentPath.length === 0) return; - sections.push({ path: currentPath, diff: currentLines.join("\n") }); - currentLines = []; - }; - - for (const rawLine of lines) { - const line = stripTrailingCarriageReturn(rawLine); - const header = parseHashlineHeaderLine(line, options.cwd); - if (header !== null) { - flush(); - currentPath = header.path; - currentLines = []; - } else { - currentLines.push(rawLine); - } - } - flush(); - return sections; -} - -// ─────────────────────────────────────────────────────────────────────────── -// 13. Diff computation (for streaming preview) -// ─────────────────────────────────────────────────────────────────────────── - -async function readHashlineFileText( - _file: { text(): Promise }, - absolutePath: string, - pathText: string, -): Promise { - try { - return await readEditFileText(absolutePath, pathText); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(message || `Unable to read ${pathText}`); - } -} - -export async function computeHashlineSectionDiff( - section: HashlineInputSection, - cwd: string, - options: HashlineApplyOptions = {}, -): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> { - try { - const absolutePath = resolveToCwd(section.path, cwd); - const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path); - const { text: content } = stripBom(rawContent); - const normalized = normalizeToLF(content); - const result = applyHashlineEdits(normalized, parseHashline(section.diff), options); - if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` }; - return generateDiffString(normalized, result.lines); - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } -} - -export async function computeHashlineDiff( - input: { input: string; path?: string }, - cwd: string, - options: HashlineApplyOptions = {}, -): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> { - let sections: HashlineInputSection[]; - try { - sections = splitHashlineInputs(input.input, { cwd, path: input.path }); - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - if (sections.length !== 1) { - return { error: "Streaming diff preview supports exactly one hashline section." }; - } - return computeHashlineSectionDiff(sections[0], cwd, options); -} - -// ─────────────────────────────────────────────────────────────────────────── -// 14. Execution -// ─────────────────────────────────────────────────────────────────────────── - -interface ReadHashlineFileResult { - exists: boolean; - rawContent: string; -} - -async function readHashlineFile(absolutePath: string, pathText: string): Promise { - try { - return { exists: true, rawContent: await readEditFileText(absolutePath, pathText) }; - } catch (error) { - if (isEnoent(error)) return { exists: false, rawContent: "" }; - if (error instanceof Error && error.message === `File not found: ${pathText}`) - return { exists: false, rawContent: "" }; - throw error; - } -} - -function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean { - return edits.some(edit => { - if (edit.kind === "delete") return true; - if (edit.kind === "modify") return true; - return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; - }); -} - -function formatNoChangeDiagnostic(pathText: string): string { - return `Edits to ${pathText} resulted in no changes being made.`; -} - -function getHashlineApplyOptions(session: ToolSession): HashlineApplyOptions { - return { - autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"), - }; -} - -function getTextContent(result: AgentToolResult): string { - return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); -} - -function getEditDetails(result: AgentToolResult): EditToolDetails { - return result.details ?? { diff: "" }; -} - -/** - * Apply hashline edits with anchor-stale recovery: on `HashlineMismatchError`, - * consult the read-snapshot cache for the file and 3-way-merge the edits onto - * the current text. If recovery succeeds, return the merged result with a - * synthetic warning. Otherwise re-throw the original mismatch error. - */ -function applyHashlineEditsWithRecovery( - session: ToolSession, - absolutePath: string, - text: string, - edits: HashlineEdit[], - options: HashlineApplyOptions, -): HashlineApplyResult { - try { - return applyHashlineEdits(text, edits, options); - } catch (err) { - if (!(err instanceof HashlineMismatchError)) throw err; - const recovered = tryRecoverHashlineWithCache({ - cache: getFileReadCache(session), - absolutePath, - currentText: text, - edits, - options, - }); - if (!recovered) throw err; - return { - lines: recovered.lines, - firstChangedLine: recovered.firstChangedLine, - warnings: recovered.warnings, - }; - } -} - -/** - * Run all the front-end checks (notebook guard, parse, plan-mode check, file - * load, edit application) without writing. Used to fail fast before applying - * any changes in a multi-section batch. - */ -async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise { - const { session, path: sectionPath, diff } = options; - - const absolutePath = resolvePlanPath(session, sectionPath); - const { edits } = parseHashlineWithWarnings(diff); - enforcePlanModeWrite(session, sectionPath, { op: "update" }); - - const source = await readHashlineFile(absolutePath, sectionPath); - if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sectionPath}`); - if (source.exists) assertEditableFileContent(source.rawContent, sectionPath); - - const { text } = stripBom(source.rawContent); - const normalized = normalizeToLF(text); - const result = applyHashlineEditsWithRecovery( - session, - absolutePath, - normalized, - edits, - getHashlineApplyOptions(session), - ); - if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath)); -} - -async function executeHashlineSection( - options: ExecuteHashlineSingleOptions & HashlineInputSection, -): Promise> { - const { - session, - path: sourcePath, - diff, - signal, - batchRequest, - writethrough, - beginDeferredDiagnosticsForPath, - } = options; - - const absolutePath = resolvePlanPath(session, sourcePath); - const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff); - enforcePlanModeWrite(session, sourcePath, { op: "update" }); - - const source = await readHashlineFile(absolutePath, sourcePath); - if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sourcePath}`); - if (source.exists) assertEditableFileContent(source.rawContent, sourcePath); - - const { bom, text } = stripBom(source.rawContent); - const originalEnding = detectLineEnding(text); - const originalNormalized = normalizeToLF(text); - const result = applyHashlineEditsWithRecovery( - session, - absolutePath, - originalNormalized, - edits, - getHashlineApplyOptions(session), - ); - - if (originalNormalized === result.lines) { - return { - content: [{ type: "text", text: formatNoChangeDiagnostic(sourcePath) }], - details: { diff: "", op: "update", meta: outputMeta().get() }, - }; - } - - const finalContent = await serializeEditFileText( - absolutePath, - sourcePath, - bom + restoreLineEndings(result.lines, originalEnding), - ); - const diagnostics = await writethrough( - absolutePath, - finalContent, - signal, - Bun.file(absolutePath), - batchRequest, - dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined), - ); - invalidateFsScanAfterWrite(absolutePath); - // The post-edit content is the freshest, most authoritative "model view" - // of the file: the model just received it back as the diff/preview. Cache - // it so a follow-up edit anchored against this state can still recover - // if the file is touched out-of-band before the next edit lands. - getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n")); - - const diffResult = generateDiffString(originalNormalized, result.lines); - const meta = outputMeta() - .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) - .get(); - const preview = buildCompactHashlineDiffPreview(diffResult.diff); - - const warnings = [...parseWarnings, ...(result.warnings ?? [])]; - const warningsBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : ""; - const previewBlock = preview.preview ? `\n${preview.preview}` : ""; - const headline = preview.preview - ? `${sourcePath}:` - : source.exists - ? `Updated ${sourcePath}` - : `Created ${sourcePath}`; - - return { - content: [{ type: "text", text: `${headline}${previewBlock}${warningsBlock}` }], - details: { - diff: diffResult.diff, - firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine, - diagnostics, - op: source.exists ? "update" : "create", - meta, - }, - }; -} - -export async function executeHashlineSingle( - options: ExecuteHashlineSingleOptions, -): Promise> { - const sections = mergeSamePathSections( - splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path }), - ); - - // Fast path: a single section needs no preflight pass. - if (sections.length === 1) return executeHashlineSection({ ...options, ...sections[0] }); - - // Multi-section: validate everything up front so we don't apply a partial batch. - for (const section of sections) await preflightHashlineSection({ ...options, ...section }); - - const results = []; - for (const section of sections) { - results.push({ path: section.path, result: await executeHashlineSection({ ...options, ...section }) }); - } - - return { - content: [{ type: "text", text: results.map(({ result }) => getTextContent(result)).join("\n\n") }], - details: { - diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"), - perFileResults: results.map(({ path: resultPath, result }) => { - const details = getEditDetails(result); - return { - path: resultPath, - diff: details.diff, - firstChangedLine: details.firstChangedLine, - diagnostics: details.diagnostics, - op: details.op, - move: details.move, - meta: details.meta, - }; - }), - }, - }; -} - -/** - * Collapse consecutive or interleaved sections targeting the same path into a - * single section with concatenated diffs. Anchors authored against the same - * file snapshot must be applied as one batch; otherwise the first sub-edit - * shifts line numbers out from under the second's anchors and validation fails. - * Path order is preserved by first occurrence. - */ -function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] { - const byPath = new Map(); - for (const section of sections) { - const existing = byPath.get(section.path); - if (existing) existing.push(section.diff); - else byPath.set(section.path, [section.diff]); - } - return Array.from(byPath, ([path, diffs]) => ({ path, diff: diffs.join("\n") })); -} diff --git a/packages/coding-agent/src/edit/streaming.ts b/packages/coding-agent/src/edit/streaming.ts index 8b962a4ae..7285a2956 100644 --- a/packages/coding-agent/src/edit/streaming.ts +++ b/packages/coding-agent/src/edit/streaming.ts @@ -12,17 +12,18 @@ * The shared renderer / `ToolExecutionComponent` consult the strategy via * the injected `editMode` rather than probing argument shape. */ -import type { Theme } from "../modes/theme/theme"; -import { type EditMode, resolveEditMode } from "../utils/edit-mode"; -import { computeEditDiff, type DiffError, type DiffResult } from "./diff"; -import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch"; + import { computeHashlineDiff, computeHashlineSectionDiff, containsRecognizableHashlineOperations, type HashlineInputSection, splitHashlineInputs, -} from "./modes/hashline"; +} from "../hashline"; +import type { Theme } from "../modes/theme/theme"; +import { type EditMode, resolveEditMode } from "../utils/edit-mode"; +import { computeEditDiff, type DiffError, type DiffResult } from "./diff"; +import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch"; import { computePatchDiff, type PatchEditEntry } from "./modes/patch"; import type { ReplaceEditEntry } from "./modes/replace"; diff --git a/packages/coding-agent/src/hashline/anchors.ts b/packages/coding-agent/src/hashline/anchors.ts new file mode 100644 index 000000000..5805220a4 --- /dev/null +++ b/packages/coding-agent/src/hashline/anchors.ts @@ -0,0 +1,113 @@ +import { formatCodeFrameLine } from "../tools/render-utils"; +import { MISMATCH_CONTEXT } from "./constants"; +import { computeLineHash, describeAnchorExamples, HL_ANCHOR_RE_RAW, HL_BODY_SEP } from "./hash"; +import type { HashMismatch } from "./types"; + +const HL_HASH_HINT_RE = /^[a-z]{2}$/i; +const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160"); +const PARSE_TAG_RE = new RegExp(`^${HL_ANCHOR_RE_RAW}`); + +export function formatFullAnchorRequirement(raw?: string): string { + const suffix = typeof raw === "string" ? raw.trim() : ""; + const hashOnlyHint = HL_HASH_HINT_RE.test(suffix) + ? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` + + `Copy the full anchor exactly as shown (for example, "160${suffix}").` + : ""; + const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`; + return ( + `the full anchor exactly as shown by read/search output ` + + `(line number + hash, for example ${HL_ANCHOR_EXAMPLES})${received}${hashOnlyHint}` + ); +} + +export function parseTag(ref: string): { line: number; hash: string } { + const match = ref.match(PARSE_TAG_RE); + if (!match) { + throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`); + } + 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] }; +} + +function getMismatchDisplayLines(mismatches: HashMismatch[], fileLines: string[]): number[] { + const displayLines = new Set(); + for (const mismatch of mismatches) { + const lo = Math.max(1, mismatch.line - MISMATCH_CONTEXT); + const hi = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT); + for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum); + } + return [...displayLines].sort((a, b) => a - b); +} + +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 mismatch of mismatches) { + const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? ""); + remaps.set(`${mismatch.line}${mismatch.expected}`, `${mismatch.line}${actual}`); + } + this.remaps = remaps; + } + + get displayMessage(): string { + return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines); + } + + private static rejectionHeader(mismatches: HashMismatch[]): string[] { + const noun = mismatches.length > 1 ? "lines have" : "line has"; + return [ + `Edit rejected: ${mismatches.length} ${noun} changed since the last read (marked *).`, + "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.", + ]; + } + + static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string { + const mismatchSet = new Set(mismatches.map(m => m.line)); + const displayLines = getMismatchDisplayLines(mismatches, fileLines); + const width = displayLines.reduce((cur, n) => Math.max(cur, String(n).length), 0); + + const out = [...HashlineMismatchError.rejectionHeader(mismatches), ""]; + let previous = -1; + for (const lineNum of displayLines) { + if (previous !== -1 && lineNum > previous + 1) out.push("..."); + previous = lineNum; + const marker = mismatchSet.has(lineNum) ? "*" : " "; + out.push(formatCodeFrameLine(marker, lineNum, fileLines[lineNum - 1] ?? "", width)); + } + return out.join("\n"); + } + + static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string { + const mismatchSet = new Set(mismatches.map(m => m.line)); + const lines = HashlineMismatchError.rejectionHeader(mismatches); + let previous = -1; + for (const lineNum of getMismatchDisplayLines(mismatches, fileLines)) { + if (previous !== -1 && lineNum > previous + 1) lines.push("..."); + previous = lineNum; + const text = fileLines[lineNum - 1] ?? ""; + const hash = computeLineHash(lineNum, text); + const marker = mismatchSet.has(lineNum) ? "*" : " "; + lines.push(`${marker}${lineNum}${hash}${HL_BODY_SEP}${text}`); + } + return lines.join("\n"); + } +} + +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); + } +} diff --git a/packages/coding-agent/src/hashline/apply.ts b/packages/coding-agent/src/hashline/apply.ts new file mode 100644 index 000000000..a81d66e69 --- /dev/null +++ b/packages/coding-agent/src/hashline/apply.ts @@ -0,0 +1,732 @@ +import { HashlineMismatchError } from "./anchors"; +import { RANGE_INTERIOR_HASH } from "./constants"; +import { computeLineHash, HL_EDIT_SEP } from "./hash"; +import { cloneCursor } from "./parser"; +import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit, HashMismatch } from "./types"; + +export interface HashlineApplyResult { + lines: string; + firstChangedLine?: number; + warnings?: string[]; + noopEdits?: HashlineNoopEdit[]; +} + +export interface HashlineNoopEdit { + editIndex: number; + loc: string; + reason: string; + current: string; +} + +type HashlineLineOrigin = "original" | "insert" | "replacement"; + +interface IndexedEdit { + edit: HashlineEdit; + idx: number; +} + +type HashlineDeleteEdit = Extract; + +interface HashlineReplacementGroup { + startIndex: number; + endIndex: number; + sourceLineNum: number; + replacement: string[]; + deletes: HashlineDeleteEdit[]; +} + +function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] { + if (edit.kind === "delete") return [edit.anchor]; + if (edit.kind === "modify") return [edit.anchor]; + if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor]; + if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor]; + return []; +} + +/** + * Verify every anchor's hash. Any mismatch is reported as a `HashMismatch`; + * there is no auto-rebase. Callers are expected to surface mismatches as + * `HashlineMismatchError` so the model re-reads and re-anchors. + */ +function validateHashlineAnchors(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] { + const mismatches: HashMismatch[] = []; + for (const edit of edits) { + for (const anchor of getHashlineEditAnchors(edit)) { + if (anchor.line < 1 || anchor.line > fileLines.length) { + throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`); + } + if (anchor.hash === RANGE_INTERIOR_HASH) continue; + + const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1] ?? ""); + if (actualHash === anchor.hash) continue; + + mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash }); + } + } + return mismatches; +} + +function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void { + if (lines.length === 0) return; + const origins = lines.map((): HashlineLineOrigin => "insert"); + if (fileLines.length === 1 && fileLines[0] === "") { + fileLines.splice(0, 1, ...lines); + lineOrigins.splice(0, 1, ...origins); + return; + } + fileLines.splice(0, 0, ...lines); + lineOrigins.splice(0, 0, ...origins); +} + +function insertAtEnd(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): number | undefined { + if (lines.length === 0) return undefined; + const origins = lines.map((): HashlineLineOrigin => "insert"); + if (fileLines.length === 1 && fileLines[0] === "") { + fileLines.splice(0, 1, ...lines); + lineOrigins.splice(0, 1, ...origins); + return 1; + } + const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === ""; + const insertIndex = hasTrailingNewline ? fileLines.length - 1 : fileLines.length; + fileLines.splice(insertIndex, 0, ...lines); + lineOrigins.splice(insertIndex, 0, ...origins); + return insertIndex + 1; +} + +/** Bucket edits by the line they target so we can apply each line's group in one splice. */ + +function getAnchorTargetLine(edit: HashlineEdit): number | undefined { + if (edit.kind === "delete" || edit.kind === "modify") return edit.anchor.line; + if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line; + return undefined; +} + +function collectAnchorTargetLines(edits: HashlineEdit[]): Set { + const lines = new Set(); + for (const edit of edits) { + const line = getAnchorTargetLine(edit); + if (line !== undefined) lines.add(line); + } + return lines; +} + +function findReplacementGroup(edits: HashlineEdit[], startIndex: number): HashlineReplacementGroup | undefined { + const first = edits[startIndex]; + if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined; + + const sourceLineNum = first.lineNum; + const replacement: string[] = []; + let index = startIndex; + while (index < edits.length) { + const edit = edits[index]; + if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break; + replacement.push(edit.text); + index++; + } + + const deletes: HashlineDeleteEdit[] = []; + while (index < edits.length) { + const edit = edits[index]; + if (edit.kind !== "delete" || edit.lineNum !== sourceLineNum) break; + deletes.push(edit); + index++; + } + if (deletes.length === 0) return undefined; + + const startLine = deletes[0].anchor.line; + for (let offset = 0; offset < deletes.length; offset++) { + if (deletes[offset].anchor.line !== startLine + offset) return undefined; + } + const cursorLine = first.cursor.anchor.line; + if (cursorLine !== startLine) return undefined; + + return { startIndex, endIndex: index - 1, sourceLineNum, replacement, deletes }; +} + +function countMatchingPrefixBlock(fileLines: string[], startLine: number, replacement: string[]): number { + const max = Math.min(replacement.length, startLine - 1); + for (let count = max; count >= 2; count--) { + let matches = true; + for (let offset = 0; offset < count; offset++) { + if (fileLines[startLine - count - 1 + offset] !== replacement[offset]) { + matches = false; + break; + } + } + if (matches) return count; + } + return 0; +} + +function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacement: string[]): number { + const max = Math.min(replacement.length, fileLines.length - endLine); + for (let count = max; count >= 2; count--) { + let matches = true; + for (let offset = 0; offset < count; offset++) { + if (fileLines[endLine + offset] !== replacement[replacement.length - count + offset]) { + matches = false; + break; + } + } + if (matches) return count; + } + return 0; +} + +// Single-line duplicate absorption is limited to structural closing delimiters. +// General one-line context is too easy to delete incorrectly, but duplicated +// `};` / `)` / `]` boundaries usually indicate a replacement range stopped one +// line early and would otherwise produce a syntax error. +const STRUCTURAL_CLOSING_BOUNDARY_RE = /^\s*[\])}]+[;,]?\s*$/; + +function isStructuralClosingBoundaryLine(line: string): boolean { + return STRUCTURAL_CLOSING_BOUNDARY_RE.test(line); +} + +interface DelimiterBalance { + paren: number; + bracket: number; + brace: number; +} + +const ZERO_DELIMITER_BALANCE: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 }; + +/** + * Naive bracket counter — does NOT skip string/template/comment contents. The + * single-line structural absorb relies on this being safe-by-asymmetry: the + * candidate boundary line is constrained by `STRUCTURAL_CLOSING_BOUNDARY_RE` + * to be pure delimiters, so noise in deleted lines or non-boundary kept payload + * tends to push `expected !== kept` and biases the heuristic toward NOT + * absorbing (the safe direction). If we ever extend this to opening boundaries + * or non-structural single lines, swap this for a real tokenizer. + */ +function computeDelimiterBalance(lines: string[]): DelimiterBalance { + const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 }; + for (const line of lines) { + for (const char of line) { + switch (char) { + case "(": + balance.paren++; + break; + case ")": + balance.paren--; + break; + case "[": + balance.bracket++; + break; + case "]": + balance.bracket--; + break; + case "{": + balance.brace++; + break; + case "}": + balance.brace--; + break; + } + } + } + return balance; +} + +function delimiterBalancesEqual(a: DelimiterBalance, b: DelimiterBalance): boolean { + return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace; +} + +/** + * Decides whether the structural-boundary candidate should be dropped: the + * `keptPayload` (full payload with the boundary line removed) must restore the + * caller's `expectedBalance`, while the `fullPayload` (boundary line still + * present) must NOT. For replacements `expectedBalance` is the deleted + * region's net delimiter balance; for pure inserts it is zero. + */ +function shouldDropSingleStructuralBoundary( + fullPayload: string[], + keptPayload: string[], + expectedBalance: DelimiterBalance, +): boolean { + return ( + delimiterBalancesEqual(computeDelimiterBalance(keptPayload), expectedBalance) && + !delimiterBalancesEqual(computeDelimiterBalance(fullPayload), expectedBalance) + ); +} + +function countMatchingSingleStructuralPrefixBoundary( + fileLines: string[], + startLine: number, + replacement: string[], + expectedBalance: DelimiterBalance, +): number { + if (replacement.length === 0 || startLine <= 1) return 0; + const line = replacement[0]; + if (!isStructuralClosingBoundaryLine(line)) return 0; + if (fileLines[startLine - 2] !== line) return 0; + return shouldDropSingleStructuralBoundary(replacement, replacement.slice(1), expectedBalance) ? 1 : 0; +} + +function countMatchingSingleStructuralSuffixBoundary( + fileLines: string[], + endLine: number, + replacement: string[], + expectedBalance: DelimiterBalance, +): number { + if (replacement.length === 0 || endLine >= fileLines.length) return 0; + const line = replacement[replacement.length - 1]; + if (!isStructuralClosingBoundaryLine(line)) return 0; + if (fileLines[endLine] !== line) return 0; + return shouldDropSingleStructuralBoundary(replacement, replacement.slice(0, -1), expectedBalance) ? 1 : 0; +} + +function hasExternalTargets(lines: Iterable, externalTargetLines: Set): boolean { + for (const line of lines) { + if (externalTargetLines.has(line)) return true; + } + return false; +} + +function contiguousRange(start: number, count: number): number[] { + return Array.from({ length: count }, (_, offset) => start + offset); +} + +function deleteEditForAutoAbsorbedLine( + line: number, + sourceLineNum: number, + index: number, + fileLines: string[], +): HashlineEdit { + return { + kind: "delete", + anchor: { line, hash: computeLineHash(line, fileLines[line - 1] ?? "") }, + lineNum: sourceLineNum, + index, + }; +} + +interface HashlinePureInsertGroup { + startIndex: number; + endIndex: number; + sourceLineNum: number; + cursor: HashlineCursor; + payload: string[]; +} + +function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === "bof" || a.kind === "eof") return true; + const aAnchor = (a as { anchor: Anchor }).anchor; + const bAnchor = (b as { anchor: Anchor }).anchor; + return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash; +} + +/** + * Collects a run of consecutive `insert` edits that all share the same + * `lineNum` and `cursor`, IFF that run is not immediately followed by a + * `delete` at the same `lineNum` (which would make it a replacement group + * instead). Returns the contiguous payload so we can check it for boundary + * duplicates against the file. + */ +function findPureInsertGroup(edits: HashlineEdit[], startIndex: number): HashlinePureInsertGroup | undefined { + const first = edits[startIndex]; + if (first?.kind !== "insert") return undefined; + + const sourceLineNum = first.lineNum; + const cursor = first.cursor; + const payload: string[] = []; + let index = startIndex; + while (index < edits.length) { + const edit = edits[index]; + if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break; + if (!cursorMatches(edit.cursor, cursor)) break; + payload.push(edit.text); + index++; + } + + // If the run is followed by a delete at the same source lineNum, this is a + // replacement group (handled by absorbReplacement…). Decline. + if (index < edits.length && edits[index].kind === "delete" && edits[index].lineNum === sourceLineNum) { + return undefined; + } + + return { startIndex, endIndex: index - 1, sourceLineNum, cursor, payload }; +} + +/** + * For a pure-insert group, locate the file region adjacent to the insertion + * point. Returns 0-indexed bounds: + * - `aboveEndIdx`: index of the last file line strictly above the insertion + * point (-1 if none). + * - `belowStartIdx`: index of the first file line strictly below the + * insertion point (`fileLines.length` if none). + */ +function pureInsertNeighborhood( + cursor: HashlineCursor, + fileLines: string[], +): { aboveEndIdx: number; belowStartIdx: number } { + if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 }; + if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length }; + if (cursor.kind === "before_anchor") { + return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 }; + } + // after_anchor + return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line }; +} + +interface PureInsertAbsorbResult { + keptPayload: string[]; + absorbedLeading: number; + absorbedTrailing: number; + leadingFileRange?: { start: number; end: number }; // 1-indexed inclusive + trailingFileRange?: { start: number; end: number }; // 1-indexed inclusive +} + +/** + * Mirror of replacement-absorb's prefix/suffix block check, but for pure + * inserts: drop payload lines that exactly duplicate the file lines + * immediately above (leading) or immediately below (trailing) the insertion + * point. Generic context echo absorption requires a minimum run of 2, but a + * single structural closing delimiter is absorbed because duplicated `}` / + * `});`-style boundaries almost always mean the insert included adjacent + * context. + */ +function tryAbsorbPureInsertGroup( + group: HashlinePureInsertGroup, + fileLines: string[], + allowGenericBoundaryAbsorb: boolean, +): PureInsertAbsorbResult { + const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 }; + if (group.payload.length === 0) return empty; + + const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines); + + // Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx]. + let absorbedLeading = 0; + if (allowGenericBoundaryAbsorb) { + const maxLead = Math.min(group.payload.length, aboveEndIdx + 1); + for (let count = maxLead; count >= 2; count--) { + let ok = true; + for (let offset = 0; offset < count; offset++) { + if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) { + ok = false; + break; + } + } + if (ok) { + absorbedLeading = count; + break; + } + } + } + if ( + absorbedLeading === 0 && + group.payload.length > 0 && + aboveEndIdx >= 0 && + isStructuralClosingBoundaryLine(group.payload[0]) && + group.payload[0] === fileLines[aboveEndIdx] && + shouldDropSingleStructuralBoundary(group.payload, group.payload.slice(1), ZERO_DELIMITER_BALANCE) + ) { + absorbedLeading = 1; + } + + // Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1]. + // Don't double-count payload lines already absorbed as leading. + let absorbedTrailing = 0; + const remainingPayload = group.payload.slice(absorbedLeading); + const remaining = remainingPayload.length; + if (allowGenericBoundaryAbsorb) { + const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx); + for (let count = maxTrail; count >= 2; count--) { + let ok = true; + for (let offset = 0; offset < count; offset++) { + if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) { + ok = false; + break; + } + } + if (ok) { + absorbedTrailing = count; + break; + } + } + } + if ( + absorbedTrailing === 0 && + remaining > 0 && + belowStartIdx < fileLines.length && + isStructuralClosingBoundaryLine(remainingPayload[remainingPayload.length - 1]) && + remainingPayload[remainingPayload.length - 1] === fileLines[belowStartIdx] && + shouldDropSingleStructuralBoundary(remainingPayload, remainingPayload.slice(0, -1), ZERO_DELIMITER_BALANCE) + ) { + absorbedTrailing = 1; + } + + if (absorbedLeading === 0 && absorbedTrailing === 0) return empty; + + return { + keptPayload: group.payload.slice(absorbedLeading, group.payload.length - absorbedTrailing), + absorbedLeading, + absorbedTrailing, + leadingFileRange: + absorbedLeading > 0 ? { start: aboveEndIdx - absorbedLeading + 2, end: aboveEndIdx + 1 } : undefined, + trailingFileRange: + absorbedTrailing > 0 ? { start: belowStartIdx + 1, end: belowStartIdx + absorbedTrailing } : undefined, + }; +} + +function absorbReplacementBoundaryDuplicates( + edits: HashlineEdit[], + fileLines: string[], + warnings: string[], + options: HashlineApplyOptions, +): HashlineEdit[] { + let nextSyntheticIndex = edits.length; + const absorbed: HashlineEdit[] = []; + + // Anchor targets are stable across the loop because we only ever append + // synthetic deletes (never mutate originals). A line in this set that + // falls outside the current group's range is necessarily owned by another + // op, so absorbing it would silently steal its target. + const allTargetLines = collectAnchorTargetLines(edits); + const emittedAbsorbKeys = new Set(); + + for (let index = 0; index < edits.length; index++) { + const group = findReplacementGroup(edits, index); + if (!group) { + const pureInsert = findPureInsertGroup(edits, index); + if (pureInsert) { + const result = tryAbsorbPureInsertGroup( + pureInsert, + fileLines, + options.autoDropPureInsertDuplicates === true, + ); + if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) { + if (result.leadingFileRange) { + const { start, end } = result.leadingFileRange; + const key = `pure-insert-leading:${start}..${end}`; + if (!emittedAbsorbKeys.has(key)) { + emittedAbsorbKeys.add(key); + warnings.push( + `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` + + `(file lines ${start}..${end} already match the payload's leading lines).`, + ); + } + } + if (result.trailingFileRange) { + const { start, end } = result.trailingFileRange; + const key = `pure-insert-trailing:${start}..${end}`; + if (!emittedAbsorbKeys.has(key)) { + emittedAbsorbKeys.add(key); + warnings.push( + `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` + + `(file lines ${start}..${end} already match the payload's trailing lines).`, + ); + } + } + for (const text of result.keptPayload) { + absorbed.push({ + kind: "insert", + cursor: cloneCursor(pureInsert.cursor), + text, + lineNum: pureInsert.sourceLineNum, + index: nextSyntheticIndex++, + }); + } + index = pureInsert.endIndex; + continue; + } + for (let groupIndex = pureInsert.startIndex; groupIndex <= pureInsert.endIndex; groupIndex++) { + absorbed.push(edits[groupIndex]); + } + index = pureInsert.endIndex; + continue; + } + absorbed.push(edits[index]); + continue; + } + + const startLine = group.deletes[0].anchor.line; + const endLine = group.deletes[group.deletes.length - 1].anchor.line; + + const deletedBalance = computeDelimiterBalance( + group.deletes.map(deleteEdit => fileLines[deleteEdit.anchor.line - 1] ?? ""), + ); + const prefixCount = + countMatchingPrefixBlock(fileLines, startLine, group.replacement) || + countMatchingSingleStructuralPrefixBoundary(fileLines, startLine, group.replacement, deletedBalance); + const suffixCount = + countMatchingSuffixBlock(fileLines, endLine, group.replacement) || + countMatchingSingleStructuralSuffixBoundary(fileLines, endLine, group.replacement, deletedBalance); + const prefixLines = contiguousRange(startLine - prefixCount, prefixCount); + const suffixLines = contiguousRange(endLine + 1, suffixCount); + const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount; + const safeSuffixCount = hasExternalTargets(suffixLines, allTargetLines) ? 0 : suffixCount; + + if (safePrefixCount > 0) { + const absorbStart = startLine - safePrefixCount; + const key = `prefix:${absorbStart}..${startLine - 1}`; + if (!emittedAbsorbKeys.has(key)) { + emittedAbsorbKeys.add(key); + warnings.push( + `Auto-absorbed ${safePrefixCount} duplicate line(s) above replacement at line ${group.sourceLineNum} ` + + `(file lines ${absorbStart}..${startLine - 1} matched the payload's leading lines; ` + + `widened the deletion to absorb them).`, + ); + } + } + if (safeSuffixCount > 0) { + const absorbEnd = endLine + safeSuffixCount; + const key = `suffix:${endLine + 1}..${absorbEnd}`; + if (!emittedAbsorbKeys.has(key)) { + emittedAbsorbKeys.add(key); + warnings.push( + `Auto-absorbed ${safeSuffixCount} duplicate line(s) below replacement at line ${group.sourceLineNum} ` + + `(file lines ${endLine + 1}..${absorbEnd} matched the payload's trailing lines; ` + + `widened the deletion to absorb them).`, + ); + } + } + + for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) { + absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines)); + } + for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) { + absorbed.push(edits[groupIndex]); + } + for (const line of contiguousRange(endLine + 1, safeSuffixCount)) { + absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines)); + } + + index = group.endIndex; + } + + return absorbed; +} + +function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map { + const byLine = new Map(); + for (const entry of edits) { + const line = + entry.edit.kind === "delete" + ? entry.edit.anchor.line + : entry.edit.kind === "modify" + ? entry.edit.anchor.line + : entry.edit.cursor.kind === "before_anchor" + ? entry.edit.cursor.anchor.line + : 0; + const bucket = byLine.get(line); + if (bucket) bucket.push(entry); + else byLine.set(line, [entry]); + } + return byLine; +} + +export function applyHashlineEdits( + text: string, + edits: HashlineEdit[], + options: HashlineApplyOptions = {}, +): HashlineApplyResult { + if (edits.length === 0) return { lines: text, firstChangedLine: undefined }; + + const fileLines = text.split("\n"); + const lineOrigins: HashlineLineOrigin[] = fileLines.map(() => "original"); + const warnings: string[] = []; + + let firstChangedLine: number | undefined; + const trackFirstChanged = (line: number) => { + if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line; + }; + + const mismatches = validateHashlineAnchors(edits, fileLines); + if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines); + + const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options); + + // Normalize after_anchor inserts to before_anchor of the next line, or EOF + // when the anchor is the final line. This keeps the bucketing logic below + // (which only knows about before_anchor / bof / eof) untouched. + for (const edit of normalizedEdits) { + if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") continue; + const anchorLine = edit.cursor.anchor.line; + if (anchorLine >= fileLines.length) { + edit.cursor = { kind: "eof" }; + continue; + } + const nextLineNum = anchorLine + 1; + const nextContent = fileLines[nextLineNum - 1] ?? ""; + edit.cursor = { + kind: "before_anchor", + anchor: { line: nextLineNum, hash: computeLineHash(nextLineNum, nextContent) }, + }; + } + + // Partition edits into BOF, EOF, and anchor-targeted buckets. + const bofLines: string[] = []; + const eofLines: string[] = []; + const anchorEdits: IndexedEdit[] = []; + normalizedEdits.forEach((edit, idx) => { + if (edit.kind === "insert" && edit.cursor.kind === "bof") { + bofLines.push(edit.text); + } else if (edit.kind === "insert" && edit.cursor.kind === "eof") { + eofLines.push(edit.text); + } else { + anchorEdits.push({ edit, idx }); + } + }); + + // Apply per-line buckets bottom-up so earlier indices stay valid. + const byLine = bucketAnchorEditsByLine(anchorEdits); + for (const line of [...byLine.keys()].sort((a, b) => b - a)) { + const bucket = byLine.get(line); + if (!bucket) continue; + bucket.sort((a, b) => a.idx - b.idx); + + const idx = line - 1; + const currentLine = fileLines[idx] ?? ""; + const beforeLines: string[] = []; + let deleteLine = false; + let prefix = ""; + let suffix = ""; + let modified = false; + + for (const { edit } of bucket) { + if (edit.kind === "insert") { + beforeLines.push(edit.text); + } else if (edit.kind === "delete") { + deleteLine = true; + } else if (edit.kind === "modify") { + prefix = edit.prefix + prefix; + suffix = suffix + edit.suffix; + modified = true; + } + } + if (beforeLines.length === 0 && !deleteLine && !modified) continue; + if (deleteLine && modified) { + throw new Error( + `line ${line}: cannot combine inline modify ("< ${line}${HL_EDIT_SEP}…" or "+ ${line}${HL_EDIT_SEP}…") with a delete or replace targeting the same line.`, + ); + } + + const effectiveLine = modified ? prefix + currentLine + suffix : currentLine; + const replacement = deleteLine ? beforeLines : [...beforeLines, effectiveLine]; + const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert")); + if (!deleteLine) { + origins[origins.length - 1] = modified ? "replacement" : (lineOrigins[idx] ?? "original"); + } + + fileLines.splice(idx, 1, ...replacement); + lineOrigins.splice(idx, 1, ...origins); + trackFirstChanged(line); + } + + if (bofLines.length > 0) { + insertAtStart(fileLines, lineOrigins, bofLines); + trackFirstChanged(1); + } + const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines); + if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine); + + return { + lines: fileLines.join("\n"), + firstChangedLine, + ...(warnings.length > 0 ? { warnings } : {}), + }; +} diff --git a/packages/coding-agent/src/hashline/bigrams.json b/packages/coding-agent/src/hashline/bigrams.json new file mode 100644 index 000000000..113f295c7 --- /dev/null +++ b/packages/coding-agent/src/hashline/bigrams.json @@ -0,0 +1,649 @@ +[ + "aa", + "ab", + "ac", + "ad", + "ae", + "af", + "ag", + "ah", + "ai", + "aj", + "ak", + "al", + "am", + "an", + "ao", + "ap", + "aq", + "ar", + "as", + "at", + "au", + "av", + "aw", + "ax", + "ay", + "az", + "ba", + "bb", + "bc", + "bd", + "be", + "bf", + "bg", + "bh", + "bi", + "bj", + "bk", + "bl", + "bm", + "bn", + "bo", + "bp", + "br", + "bs", + "bt", + "bu", + "bv", + "bw", + "bx", + "by", + "bz", + "ca", + "cb", + "cc", + "cd", + "ce", + "cf", + "cg", + "ch", + "ci", + "cj", + "ck", + "cl", + "cm", + "cn", + "co", + "cp", + "cq", + "cr", + "cs", + "ct", + "cu", + "cv", + "cw", + "cx", + "cy", + "cz", + "da", + "db", + "dc", + "dd", + "de", + "df", + "dg", + "dh", + "di", + "dj", + "dk", + "dl", + "dm", + "dn", + "do", + "dp", + "dq", + "dr", + "ds", + "dt", + "du", + "dv", + "dw", + "dx", + "dy", + "dz", + "ea", + "eb", + "ec", + "ed", + "ee", + "ef", + "eg", + "eh", + "ei", + "ej", + "ek", + "el", + "em", + "en", + "eo", + "ep", + "eq", + "er", + "es", + "et", + "eu", + "ev", + "ew", + "ex", + "ey", + "ez", + "fa", + "fb", + "fc", + "fd", + "fe", + "ff", + "fg", + "fh", + "fi", + "fj", + "fk", + "fl", + "fm", + "fn", + "fo", + "fp", + "fq", + "fr", + "fs", + "ft", + "fu", + "fv", + "fw", + "fx", + "fy", + "fz", + "ga", + "gb", + "gc", + "gd", + "ge", + "gf", + "gg", + "gh", + "gi", + "gj", + "gl", + "gm", + "gn", + "go", + "gp", + "gr", + "gs", + "gt", + "gu", + "gv", + "gw", + "gx", + "gy", + "gz", + "ha", + "hb", + "hc", + "hd", + "he", + "hf", + "hg", + "hh", + "hi", + "hj", + "hk", + "hl", + "hm", + "hn", + "ho", + "hp", + "hq", + "hr", + "hs", + "ht", + "hu", + "hv", + "hw", + "hx", + "hy", + "hz", + "ia", + "ib", + "ic", + "id", + "ie", + "if", + "ig", + "ih", + "ii", + "ij", + "ik", + "il", + "im", + "in", + "io", + "ip", + "iq", + "ir", + "is", + "it", + "iu", + "iv", + "iw", + "ix", + "iy", + "iz", + "ja", + "jb", + "jc", + "jd", + "je", + "jf", + "jg", + "jh", + "ji", + "jj", + "jk", + "jl", + "jm", + "jn", + "jo", + "jp", + "jq", + "jr", + "js", + "jt", + "ju", + "jw", + "jx", + "jy", + "ka", + "kb", + "kc", + "kd", + "ke", + "kf", + "kg", + "kh", + "ki", + "kj", + "kk", + "kl", + "km", + "kn", + "ko", + "kp", + "kr", + "ks", + "kt", + "ku", + "kv", + "kw", + "kx", + "ky", + "la", + "lb", + "lc", + "ld", + "le", + "lf", + "lg", + "lh", + "li", + "lj", + "lk", + "ll", + "lm", + "ln", + "lo", + "lp", + "lr", + "ls", + "lt", + "lu", + "lv", + "lw", + "lx", + "ly", + "lz", + "ma", + "mb", + "mc", + "md", + "me", + "mf", + "mg", + "mh", + "mi", + "mj", + "mk", + "ml", + "mm", + "mn", + "mo", + "mp", + "mq", + "mr", + "ms", + "mt", + "mu", + "mv", + "mw", + "mx", + "my", + "mz", + "na", + "nb", + "nc", + "nd", + "ne", + "nf", + "ng", + "nh", + "ni", + "nj", + "nk", + "nl", + "nm", + "nn", + "no", + "np", + "nr", + "ns", + "nt", + "nu", + "nv", + "nw", + "nx", + "ny", + "nz", + "oa", + "ob", + "oc", + "od", + "oe", + "of", + "og", + "oh", + "oi", + "oj", + "ok", + "ol", + "om", + "on", + "oo", + "op", + "oq", + "or", + "os", + "ot", + "ou", + "ov", + "ow", + "ox", + "oy", + "oz", + "pa", + "pb", + "pc", + "pd", + "pe", + "pf", + "pg", + "ph", + "pi", + "pj", + "pk", + "pl", + "pm", + "pn", + "po", + "pp", + "pq", + "pr", + "ps", + "pt", + "pu", + "pv", + "pw", + "px", + "py", + "pz", + "qa", + "qb", + "qc", + "qd", + "qe", + "qh", + "qi", + "ql", + "qm", + "qn", + "qo", + "qp", + "qq", + "qr", + "qs", + "qt", + "qu", + "qw", + "qx", + "qy", + "ra", + "rb", + "rc", + "rd", + "re", + "rf", + "rg", + "rh", + "ri", + "rk", + "rl", + "rm", + "rn", + "ro", + "rp", + "rq", + "rr", + "rs", + "rt", + "ru", + "rv", + "rw", + "rx", + "ry", + "rz", + "sa", + "sb", + "sc", + "sd", + "se", + "sf", + "sg", + "sh", + "si", + "sj", + "sk", + "sl", + "sm", + "sn", + "so", + "sp", + "sq", + "sr", + "ss", + "st", + "su", + "sv", + "sw", + "sx", + "sy", + "sz", + "ta", + "tb", + "tc", + "td", + "te", + "tf", + "tg", + "th", + "ti", + "tj", + "tk", + "tl", + "tm", + "tn", + "to", + "tp", + "tr", + "ts", + "tt", + "tu", + "tv", + "tw", + "tx", + "ty", + "tz", + "ua", + "ub", + "uc", + "ud", + "ue", + "uf", + "ug", + "uh", + "ui", + "uj", + "uk", + "ul", + "um", + "un", + "uo", + "up", + "uq", + "ur", + "us", + "ut", + "uu", + "uv", + "uw", + "ux", + "uy", + "uz", + "va", + "vb", + "vc", + "vd", + "ve", + "vf", + "vg", + "vh", + "vi", + "vj", + "vk", + "vl", + "vm", + "vn", + "vo", + "vp", + "vq", + "vr", + "vs", + "vt", + "vu", + "vv", + "vw", + "vx", + "vy", + "vz", + "wa", + "wb", + "wc", + "wd", + "we", + "wf", + "wg", + "wh", + "wi", + "wj", + "wk", + "wl", + "wm", + "wn", + "wo", + "wp", + "wr", + "ws", + "wt", + "wu", + "wv", + "ww", + "wx", + "wy", + "xa", + "xb", + "xc", + "xd", + "xe", + "xf", + "xh", + "xi", + "xl", + "xm", + "xn", + "xo", + "xp", + "xr", + "xs", + "xt", + "xu", + "xx", + "xy", + "xz", + "ya", + "yb", + "yc", + "yd", + "ye", + "yf", + "yg", + "yh", + "yi", + "yj", + "yk", + "yl", + "ym", + "yn", + "yo", + "yp", + "yr", + "ys", + "yt", + "yu", + "yv", + "yw", + "yx", + "yy", + "yz", + "za", + "zb", + "zc", + "zd", + "ze", + "zf", + "zg", + "zh", + "zi", + "zk", + "zl", + "zm", + "zn", + "zo", + "zp", + "zr", + "zs", + "zt", + "zu", + "zw", + "zx", + "zy", + "zz" +] diff --git a/packages/coding-agent/src/hashline/constants.ts b/packages/coding-agent/src/hashline/constants.ts new file mode 100644 index 000000000..db23f5e06 --- /dev/null +++ b/packages/coding-agent/src/hashline/constants.ts @@ -0,0 +1,8 @@ +/** Lines of context shown either side of a hash mismatch. */ +export const MISMATCH_CONTEXT = 2; + +/** Filler hash used for the interior of a multi-line range; not validated. */ +export const RANGE_INTERIOR_HASH = "**"; + +/** Header marker introducing a new file section in multi-section input. */ +export const FILE_HEADER_PREFIX = "@"; diff --git a/packages/coding-agent/src/hashline/diff-preview.ts b/packages/coding-agent/src/hashline/diff-preview.ts new file mode 100644 index 000000000..8b41950dc --- /dev/null +++ b/packages/coding-agent/src/hashline/diff-preview.ts @@ -0,0 +1,43 @@ +import { computeLineHash, HL_BODY_SEP } from "./hash"; +import type { CompactHashlineDiffOptions, CompactHashlineDiffPreview } from "./types"; + +export function buildCompactHashlineDiffPreview( + diff: string, + _options: CompactHashlineDiffOptions = {}, +): CompactHashlineDiffPreview { + const lines = diff.length === 0 ? [] : diff.split("\n"); + let addedLines = 0; + let removedLines = 0; + + // `generateDiffString` numbers `+` lines with the post-edit line number, + // `-` lines with the pre-edit line number, and context lines with the + // pre-edit line number. To emit fresh anchors usable for follow-up edits, + // we convert context-line numbers to post-edit positions by tracking the + // running offset (added so far - removed so far) as we walk the diff. + const formatted = lines.map(line => { + const kind = line[0]; + if (kind !== "+" && kind !== "-" && kind !== " ") return line; + + const body = line.slice(1); + const sep = body.indexOf("|"); + if (sep === -1) return line; + + const lineNumber = Number.parseInt(body.slice(0, sep), 10); + const content = body.slice(sep + 1); + + switch (kind) { + case "+": + addedLines++; + return `+${lineNumber}${computeLineHash(lineNumber, content)}${HL_BODY_SEP}${content}`; + case "-": + removedLines++; + return `-${lineNumber}--${HL_BODY_SEP}${content}`; + default: { + const newLineNumber = lineNumber + addedLines - removedLines; + return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HL_BODY_SEP}${content}`; + } + } + }); + + return { preview: formatted.join("\n"), addedLines, removedLines }; +} diff --git a/packages/coding-agent/src/hashline/diff.ts b/packages/coding-agent/src/hashline/diff.ts new file mode 100644 index 000000000..36987e8b5 --- /dev/null +++ b/packages/coding-agent/src/hashline/diff.ts @@ -0,0 +1,56 @@ +import { generateDiffString } from "../edit/diff"; +import { normalizeToLF, stripBom } from "../edit/normalize"; +import { readEditFileText } from "../edit/read-file"; +import { resolveToCwd } from "../tools/path-utils"; +import { applyHashlineEdits } from "./apply"; +import { type HashlineInputSection, splitHashlineInputs } from "./input"; +import { parseHashline } from "./parser"; +import type { HashlineApplyOptions } from "./types"; + +async function readHashlineFileText( + _file: { text(): Promise }, + absolutePath: string, + pathText: string, +): Promise { + try { + return await readEditFileText(absolutePath, pathText); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(message || `Unable to read ${pathText}`); + } +} + +export async function computeHashlineSectionDiff( + section: HashlineInputSection, + cwd: string, + options: HashlineApplyOptions = {}, +): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> { + try { + const absolutePath = resolveToCwd(section.path, cwd); + const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path); + const { text: content } = stripBom(rawContent); + const normalized = normalizeToLF(content); + const result = applyHashlineEdits(normalized, parseHashline(section.diff), options); + if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` }; + return generateDiffString(normalized, result.lines); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function computeHashlineDiff( + input: { input: string; path?: string }, + cwd: string, + options: HashlineApplyOptions = {}, +): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> { + let sections: HashlineInputSection[]; + try { + sections = splitHashlineInputs(input.input, { cwd, path: input.path }); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + if (sections.length !== 1) { + return { error: "Streaming diff preview supports exactly one hashline section." }; + } + return computeHashlineSectionDiff(sections[0], cwd, options); +} diff --git a/packages/coding-agent/src/hashline/execute.ts b/packages/coding-agent/src/hashline/execute.ts new file mode 100644 index 000000000..d14cf26ea --- /dev/null +++ b/packages/coding-agent/src/hashline/execute.ts @@ -0,0 +1,268 @@ +import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { isEnoent } from "@oh-my-pi/pi-utils"; +import { generateDiffString } from "../edit/diff"; +import { getFileReadCache } from "../edit/file-read-cache"; +import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../edit/normalize"; +import { readEditFileText, serializeEditFileText } from "../edit/read-file"; +import type { EditToolDetails } from "../edit/renderer"; +import type { ToolSession } from "../tools"; +import { assertEditableFileContent } from "../tools/auto-generated-guard"; +import { invalidateFsScanAfterWrite } from "../tools/fs-cache-invalidation"; +import { outputMeta } from "../tools/output-meta"; +import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"; +import { HashlineMismatchError } from "./anchors"; +import { applyHashlineEdits, type HashlineApplyResult } from "./apply"; +import { buildCompactHashlineDiffPreview } from "./diff-preview"; +import { type HashlineInputSection, splitHashlineInputs } from "./input"; +import { parseHashlineWithWarnings } from "./parser"; +import { tryRecoverHashlineWithCache } from "./recovery"; +import type { + ExecuteHashlineSingleOptions, + HashlineApplyOptions, + HashlineEdit, + hashlineEditParamsSchema, +} from "./types"; + +interface ReadHashlineFileResult { + exists: boolean; + rawContent: string; +} + +async function readHashlineFile(absolutePath: string, pathText: string): Promise { + try { + return { exists: true, rawContent: await readEditFileText(absolutePath, pathText) }; + } catch (error) { + if (isEnoent(error)) return { exists: false, rawContent: "" }; + if (error instanceof Error && error.message === `File not found: ${pathText}`) + return { exists: false, rawContent: "" }; + throw error; + } +} + +function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean { + return edits.some(edit => { + if (edit.kind === "delete") return true; + if (edit.kind === "modify") return true; + return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; + }); +} + +function formatNoChangeDiagnostic(pathText: string): string { + return `Edits to ${pathText} resulted in no changes being made.`; +} + +function getHashlineApplyOptions(session: ToolSession): HashlineApplyOptions { + return { + autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"), + }; +} + +function getTextContent(result: AgentToolResult): string { + return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); +} + +function getEditDetails(result: AgentToolResult): EditToolDetails { + return result.details ?? { diff: "" }; +} + +/** + * Apply hashline edits with anchor-stale recovery: on `HashlineMismatchError`, + * consult the read-snapshot cache for the file and 3-way-merge the edits onto + * the current text. If recovery succeeds, return the merged result with a + * synthetic warning. Otherwise re-throw the original mismatch error. + */ +function applyHashlineEditsWithRecovery( + session: ToolSession, + absolutePath: string, + text: string, + edits: HashlineEdit[], + options: HashlineApplyOptions, +): HashlineApplyResult { + try { + return applyHashlineEdits(text, edits, options); + } catch (err) { + if (!(err instanceof HashlineMismatchError)) throw err; + const recovered = tryRecoverHashlineWithCache({ + cache: getFileReadCache(session), + absolutePath, + currentText: text, + edits, + options, + }); + if (!recovered) throw err; + return { + lines: recovered.lines, + firstChangedLine: recovered.firstChangedLine, + warnings: recovered.warnings, + }; + } +} + +/** + * Run all the front-end checks (notebook guard, parse, plan-mode check, file + * load, edit application) without writing. Used to fail fast before applying + * any changes in a multi-section batch. + */ +async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise { + const { session, path: sectionPath, diff } = options; + + const absolutePath = resolvePlanPath(session, sectionPath); + const { edits } = parseHashlineWithWarnings(diff); + enforcePlanModeWrite(session, sectionPath, { op: "update" }); + + const source = await readHashlineFile(absolutePath, sectionPath); + if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sectionPath}`); + if (source.exists) assertEditableFileContent(source.rawContent, sectionPath); + + const { text } = stripBom(source.rawContent); + const normalized = normalizeToLF(text); + const result = applyHashlineEditsWithRecovery( + session, + absolutePath, + normalized, + edits, + getHashlineApplyOptions(session), + ); + if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath)); +} + +async function executeHashlineSection( + options: ExecuteHashlineSingleOptions & HashlineInputSection, +): Promise> { + const { + session, + path: sourcePath, + diff, + signal, + batchRequest, + writethrough, + beginDeferredDiagnosticsForPath, + } = options; + + const absolutePath = resolvePlanPath(session, sourcePath); + const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff); + enforcePlanModeWrite(session, sourcePath, { op: "update" }); + + const source = await readHashlineFile(absolutePath, sourcePath); + if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sourcePath}`); + if (source.exists) assertEditableFileContent(source.rawContent, sourcePath); + + const { bom, text } = stripBom(source.rawContent); + const originalEnding = detectLineEnding(text); + const originalNormalized = normalizeToLF(text); + const result = applyHashlineEditsWithRecovery( + session, + absolutePath, + originalNormalized, + edits, + getHashlineApplyOptions(session), + ); + + if (originalNormalized === result.lines) { + return { + content: [{ type: "text", text: formatNoChangeDiagnostic(sourcePath) }], + details: { diff: "", op: "update", meta: outputMeta().get() }, + }; + } + + const finalContent = await serializeEditFileText( + absolutePath, + sourcePath, + bom + restoreLineEndings(result.lines, originalEnding), + ); + const diagnostics = await writethrough( + absolutePath, + finalContent, + signal, + Bun.file(absolutePath), + batchRequest, + dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined), + ); + invalidateFsScanAfterWrite(absolutePath); + // The post-edit content is the freshest, most authoritative "model view" + // of the file: the model just received it back as the diff/preview. Cache + // it so a follow-up edit anchored against this state can still recover + // if the file is touched out-of-band before the next edit lands. + getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n")); + + const diffResult = generateDiffString(originalNormalized, result.lines); + const meta = outputMeta() + .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) + .get(); + const preview = buildCompactHashlineDiffPreview(diffResult.diff); + + const warnings = [...parseWarnings, ...(result.warnings ?? [])]; + const warningsBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : ""; + const previewBlock = preview.preview ? `\n${preview.preview}` : ""; + const headline = preview.preview + ? `${sourcePath}:` + : source.exists + ? `Updated ${sourcePath}` + : `Created ${sourcePath}`; + + return { + content: [{ type: "text", text: `${headline}${previewBlock}${warningsBlock}` }], + details: { + diff: diffResult.diff, + firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine, + diagnostics, + op: source.exists ? "update" : "create", + meta, + }, + }; +} + +export async function executeHashlineSingle( + options: ExecuteHashlineSingleOptions, +): Promise> { + const sections = mergeSamePathSections( + splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path }), + ); + + // Fast path: a single section needs no preflight pass. + if (sections.length === 1) return executeHashlineSection({ ...options, ...sections[0] }); + + // Multi-section: validate everything up front so we don't apply a partial batch. + for (const section of sections) await preflightHashlineSection({ ...options, ...section }); + + const results = []; + for (const section of sections) { + results.push({ path: section.path, result: await executeHashlineSection({ ...options, ...section }) }); + } + + return { + content: [{ type: "text", text: results.map(({ result }) => getTextContent(result)).join("\n\n") }], + details: { + diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"), + perFileResults: results.map(({ path: resultPath, result }) => { + const details = getEditDetails(result); + return { + path: resultPath, + diff: details.diff, + firstChangedLine: details.firstChangedLine, + diagnostics: details.diagnostics, + op: details.op, + move: details.move, + meta: details.meta, + }; + }), + }, + }; +} + +/** + * Collapse consecutive or interleaved sections targeting the same path into a + * single section with concatenated diffs. Anchors authored against the same + * file snapshot must be applied as one batch; otherwise the first sub-edit + * shifts line numbers out from under the second's anchors and validation fails. + * Path order is preserved by first occurrence. + */ +function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] { + const byPath = new Map(); + for (const section of sections) { + const existing = byPath.get(section.path); + if (existing) existing.push(section.diff); + else byPath.set(section.path, [section.diff]); + } + return Array.from(byPath, ([path, diffs]) => ({ path, diff: diffs.join("\n") })); +} diff --git a/packages/coding-agent/src/edit/modes/hashline.lark b/packages/coding-agent/src/hashline/grammar.lark similarity index 100% rename from packages/coding-agent/src/edit/modes/hashline.lark rename to packages/coding-agent/src/hashline/grammar.lark diff --git a/packages/coding-agent/src/edit/line-hash.ts b/packages/coding-agent/src/hashline/hash.ts similarity index 64% rename from packages/coding-agent/src/edit/line-hash.ts rename to packages/coding-agent/src/hashline/hash.ts index cca641c13..163e71fdd 100644 --- a/packages/coding-agent/src/edit/line-hash.ts +++ b/packages/coding-agent/src/hashline/hash.ts @@ -1,8 +1,10 @@ /** - * Lightweight line-hash utilities extracted from hashline.ts to avoid - * circular dependencies (prompt-templates → hashline → tools → edit). + * Core hash utilities shared by hashline edit mode, read/search output, + * and prompt helpers. */ +import bigrams from "./bigrams.json" with { type: "json" }; + /** * 647 single-token BPE bigrams for hashline anchors. Every entry tokenizes as * exactly one token in modern BPE vocabularies (cl100k / o200k / Claude family), @@ -15,655 +17,7 @@ * Order is stable forever — changing it would invalidate every saved * `LINE+ID` reference in transcripts and prompts. */ -export const HL_BIGRAMS = [ - "aa", - "ab", - "ac", - "ad", - "ae", - "af", - "ag", - "ah", - "ai", - "aj", - "ak", - "al", - "am", - "an", - "ao", - "ap", - "aq", - "ar", - "as", - "at", - "au", - "av", - "aw", - "ax", - "ay", - "az", - "ba", - "bb", - "bc", - "bd", - "be", - "bf", - "bg", - "bh", - "bi", - "bj", - "bk", - "bl", - "bm", - "bn", - "bo", - "bp", - "br", - "bs", - "bt", - "bu", - "bv", - "bw", - "bx", - "by", - "bz", - "ca", - "cb", - "cc", - "cd", - "ce", - "cf", - "cg", - "ch", - "ci", - "cj", - "ck", - "cl", - "cm", - "cn", - "co", - "cp", - "cq", - "cr", - "cs", - "ct", - "cu", - "cv", - "cw", - "cx", - "cy", - "cz", - "da", - "db", - "dc", - "dd", - "de", - "df", - "dg", - "dh", - "di", - "dj", - "dk", - "dl", - "dm", - "dn", - "do", - "dp", - "dq", - "dr", - "ds", - "dt", - "du", - "dv", - "dw", - "dx", - "dy", - "dz", - "ea", - "eb", - "ec", - "ed", - "ee", - "ef", - "eg", - "eh", - "ei", - "ej", - "ek", - "el", - "em", - "en", - "eo", - "ep", - "eq", - "er", - "es", - "et", - "eu", - "ev", - "ew", - "ex", - "ey", - "ez", - "fa", - "fb", - "fc", - "fd", - "fe", - "ff", - "fg", - "fh", - "fi", - "fj", - "fk", - "fl", - "fm", - "fn", - "fo", - "fp", - "fq", - "fr", - "fs", - "ft", - "fu", - "fv", - "fw", - "fx", - "fy", - "fz", - "ga", - "gb", - "gc", - "gd", - "ge", - "gf", - "gg", - "gh", - "gi", - "gj", - "gl", - "gm", - "gn", - "go", - "gp", - "gr", - "gs", - "gt", - "gu", - "gv", - "gw", - "gx", - "gy", - "gz", - "ha", - "hb", - "hc", - "hd", - "he", - "hf", - "hg", - "hh", - "hi", - "hj", - "hk", - "hl", - "hm", - "hn", - "ho", - "hp", - "hq", - "hr", - "hs", - "ht", - "hu", - "hv", - "hw", - "hx", - "hy", - "hz", - "ia", - "ib", - "ic", - "id", - "ie", - "if", - "ig", - "ih", - "ii", - "ij", - "ik", - "il", - "im", - "in", - "io", - "ip", - "iq", - "ir", - "is", - "it", - "iu", - "iv", - "iw", - "ix", - "iy", - "iz", - "ja", - "jb", - "jc", - "jd", - "je", - "jf", - "jg", - "jh", - "ji", - "jj", - "jk", - "jl", - "jm", - "jn", - "jo", - "jp", - "jq", - "jr", - "js", - "jt", - "ju", - "jw", - "jx", - "jy", - "ka", - "kb", - "kc", - "kd", - "ke", - "kf", - "kg", - "kh", - "ki", - "kj", - "kk", - "kl", - "km", - "kn", - "ko", - "kp", - "kr", - "ks", - "kt", - "ku", - "kv", - "kw", - "kx", - "ky", - "la", - "lb", - "lc", - "ld", - "le", - "lf", - "lg", - "lh", - "li", - "lj", - "lk", - "ll", - "lm", - "ln", - "lo", - "lp", - "lr", - "ls", - "lt", - "lu", - "lv", - "lw", - "lx", - "ly", - "lz", - "ma", - "mb", - "mc", - "md", - "me", - "mf", - "mg", - "mh", - "mi", - "mj", - "mk", - "ml", - "mm", - "mn", - "mo", - "mp", - "mq", - "mr", - "ms", - "mt", - "mu", - "mv", - "mw", - "mx", - "my", - "mz", - "na", - "nb", - "nc", - "nd", - "ne", - "nf", - "ng", - "nh", - "ni", - "nj", - "nk", - "nl", - "nm", - "nn", - "no", - "np", - "nr", - "ns", - "nt", - "nu", - "nv", - "nw", - "nx", - "ny", - "nz", - "oa", - "ob", - "oc", - "od", - "oe", - "of", - "og", - "oh", - "oi", - "oj", - "ok", - "ol", - "om", - "on", - "oo", - "op", - "oq", - "or", - "os", - "ot", - "ou", - "ov", - "ow", - "ox", - "oy", - "oz", - "pa", - "pb", - "pc", - "pd", - "pe", - "pf", - "pg", - "ph", - "pi", - "pj", - "pk", - "pl", - "pm", - "pn", - "po", - "pp", - "pq", - "pr", - "ps", - "pt", - "pu", - "pv", - "pw", - "px", - "py", - "pz", - "qa", - "qb", - "qc", - "qd", - "qe", - "qh", - "qi", - "ql", - "qm", - "qn", - "qo", - "qp", - "qq", - "qr", - "qs", - "qt", - "qu", - "qw", - "qx", - "qy", - "ra", - "rb", - "rc", - "rd", - "re", - "rf", - "rg", - "rh", - "ri", - "rk", - "rl", - "rm", - "rn", - "ro", - "rp", - "rq", - "rr", - "rs", - "rt", - "ru", - "rv", - "rw", - "rx", - "ry", - "rz", - "sa", - "sb", - "sc", - "sd", - "se", - "sf", - "sg", - "sh", - "si", - "sj", - "sk", - "sl", - "sm", - "sn", - "so", - "sp", - "sq", - "sr", - "ss", - "st", - "su", - "sv", - "sw", - "sx", - "sy", - "sz", - "ta", - "tb", - "tc", - "td", - "te", - "tf", - "tg", - "th", - "ti", - "tj", - "tk", - "tl", - "tm", - "tn", - "to", - "tp", - "tr", - "ts", - "tt", - "tu", - "tv", - "tw", - "tx", - "ty", - "tz", - "ua", - "ub", - "uc", - "ud", - "ue", - "uf", - "ug", - "uh", - "ui", - "uj", - "uk", - "ul", - "um", - "un", - "uo", - "up", - "uq", - "ur", - "us", - "ut", - "uu", - "uv", - "uw", - "ux", - "uy", - "uz", - "va", - "vb", - "vc", - "vd", - "ve", - "vf", - "vg", - "vh", - "vi", - "vj", - "vk", - "vl", - "vm", - "vn", - "vo", - "vp", - "vq", - "vr", - "vs", - "vt", - "vu", - "vv", - "vw", - "vx", - "vy", - "vz", - "wa", - "wb", - "wc", - "wd", - "we", - "wf", - "wg", - "wh", - "wi", - "wj", - "wk", - "wl", - "wm", - "wn", - "wo", - "wp", - "wr", - "ws", - "wt", - "wu", - "wv", - "ww", - "wx", - "wy", - "xa", - "xb", - "xc", - "xd", - "xe", - "xf", - "xh", - "xi", - "xl", - "xm", - "xn", - "xo", - "xp", - "xr", - "xs", - "xt", - "xu", - "xx", - "xy", - "xz", - "ya", - "yb", - "yc", - "yd", - "ye", - "yf", - "yg", - "yh", - "yi", - "yj", - "yk", - "yl", - "ym", - "yn", - "yo", - "yp", - "yr", - "ys", - "yt", - "yu", - "yv", - "yw", - "yx", - "yy", - "yz", - "za", - "zb", - "zc", - "zd", - "ze", - "zf", - "zg", - "zh", - "zi", - "zk", - "zl", - "zm", - "zn", - "zo", - "zp", - "zr", - "zs", - "zt", - "zu", - "zw", - "zx", - "zy", - "zz", -] as const; +export const HL_BIGRAMS: readonly string[] = bigrams; export const HL_BIGRAMS_COUNT = HL_BIGRAMS.length; diff --git a/packages/coding-agent/src/hashline/index.ts b/packages/coding-agent/src/hashline/index.ts new file mode 100644 index 000000000..129186dd0 --- /dev/null +++ b/packages/coding-agent/src/hashline/index.ts @@ -0,0 +1,14 @@ +export * from "./anchors"; +export * from "./apply"; +export * from "./constants"; +export * from "./diff"; +export * from "./diff-preview"; +export * from "./execute"; +export * from "./input"; +export * from "./hash"; +export * from "./parser"; +export * from "./prefixes"; +export * from "./recovery"; +export * from "./stream"; +export * from "./types"; +export * from "./utils"; diff --git a/packages/coding-agent/src/hashline/input.ts b/packages/coding-agent/src/hashline/input.ts new file mode 100644 index 000000000..b7ab127be --- /dev/null +++ b/packages/coding-agent/src/hashline/input.ts @@ -0,0 +1,110 @@ +import * as path from "node:path"; +import { FILE_HEADER_PREFIX } from "./constants"; +import { HL_EDIT_SEP } from "./hash"; +import type { SplitHashlineOptions } from "./types"; +import { stripTrailingCarriageReturn } from "./utils"; + +export interface HashlineInputSection { + path: string; + diff: string; +} + +function unquoteHashlinePath(pathText: string): string { + if (pathText.length < 2) return pathText; + const first = pathText[0]; + const last = pathText[pathText.length - 1]; + if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1); + return pathText; +} + +function normalizeHashlinePath(rawPath: string, cwd?: string): string { + const unquoted = unquoteHashlinePath(rawPath.trim()); + if (!cwd || !path.isAbsolute(unquoted)) return unquoted; + const relative = path.relative(path.resolve(cwd), path.resolve(unquoted)); + const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return isWithinCwd ? relative || "." : unquoted; +} + +function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null { + const trimmed = line.trimEnd(); + if (trimmed === FILE_HEADER_PREFIX) { + throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`); + } + if (!trimmed.startsWith(FILE_HEADER_PREFIX)) return null; + const parsedPath = normalizeHashlinePath(trimmed.slice(1), cwd); + if (parsedPath.length === 0) { + throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`); + } + return { path: parsedPath, diff: "" }; +} + +function stripLeadingBlankLines(input: string): string { + const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input; + const lines = stripped.split("\n"); + while (lines.length > 0 && lines[0].replace(/\r$/, "").trim().length === 0) lines.shift(); + return lines.join("\n"); +} + +export function containsRecognizableHashlineOperations(input: string): boolean { + for (const rawLine of input.split("\n")) { + const line = stripTrailingCarriageReturn(rawLine); + if (/^[+<=-]\s+/.test(line) || line.startsWith(HL_EDIT_SEP)) return true; + } + return false; +} + +function normalizeFallbackInput(input: string, options: SplitHashlineOptions): string { + const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input; + const hasExplicitHeader = stripped + .split("\n") + .some(rawLine => parseHashlineHeaderLine(stripTrailingCarriageReturn(rawLine), options.cwd) !== null); + if (hasExplicitHeader) return input; + + if (!options.path || !containsRecognizableHashlineOperations(input)) return input; + const fallbackPath = normalizeHashlinePath(options.path, options.cwd); + if (fallbackPath.length === 0) return input; + return `${FILE_HEADER_PREFIX} ${fallbackPath}\n${input}`; +} + +export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } { + const [section] = splitHashlineInputs(input, options); + return section; +} + +export function splitHashlineInputs(input: string, options: SplitHashlineOptions = {}): HashlineInputSection[] { + const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options)); + const lines = stripped.split("\n"); + const firstLine = stripTrailingCarriageReturn(lines[0] ?? ""); + + if (parseHashlineHeaderLine(firstLine, options.cwd) === null) { + const preview = JSON.stringify(firstLine.slice(0, 120)); + throw new Error( + `input must begin with "@PATH" on the first non-blank line; got: ${preview}. ` + + `Example: "@src/foo.ts" then edit ops.`, + ); + } + + const sections: HashlineInputSection[] = []; + let currentPath = ""; + let currentLines: string[] = []; + + const flush = () => { + if (currentPath.length === 0) return; + sections.push({ path: currentPath, diff: currentLines.join("\n") }); + currentLines = []; + }; + + for (const rawLine of lines) { + const line = stripTrailingCarriageReturn(rawLine); + const header = parseHashlineHeaderLine(line, options.cwd); + if (header !== null) { + flush(); + currentPath = header.path; + currentLines = []; + } else { + currentLines.push(rawLine); + } + } + flush(); + return sections; +} diff --git a/packages/coding-agent/src/hashline/parser.ts b/packages/coding-agent/src/hashline/parser.ts new file mode 100644 index 000000000..a2bbe1cde --- /dev/null +++ b/packages/coding-agent/src/hashline/parser.ts @@ -0,0 +1,207 @@ +import { RANGE_INTERIOR_HASH } from "./constants"; +import { describeAnchorExamples, HL_EDIT_SEP, HL_EDIT_SEP_RE_RAW, HL_HASH_CAPTURE_RE_RAW } from "./hash"; +import type { Anchor, HashlineCursor, HashlineEdit } from "./types"; +import { stripTrailingCarriageReturn } from "./utils"; + +const HL_EDIT_SEPARATOR_RE = HL_EDIT_SEP_RE_RAW; +const LID_CAPTURE_RE = new RegExp(`^${HL_HASH_CAPTURE_RE_RAW}$`); + +function parseLid(raw: string, lineNum: number): Anchor { + const match = LID_CAPTURE_RE.exec(raw); + if (!match) { + throw new Error( + `line ${lineNum}: expected a full anchor such as ${describeAnchorExamples("119")}; ` + + `got ${JSON.stringify(raw)}.`, + ); + } + return { line: Number.parseInt(match[1], 10), hash: match[2] }; +} + +interface ParsedRange { + start: Anchor; + end: Anchor; +} + +function parseRange(raw: string, lineNum: number): ParsedRange { + const [startRaw, endRaw] = raw.split(".."); + if (!startRaw) throw new Error(`line ${lineNum}: range is missing its first anchor.`); + const start = parseLid(startRaw, lineNum); + const end = endRaw === undefined ? { ...start } : parseLid(endRaw, lineNum); + if (end.line < start.line) { + throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} ends before it starts.`); + } + if (end.line === start.line && end.hash !== start.hash) { + throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} uses two different hashes for the same line.`); + } + return { start, end }; +} + +function expandRange(range: ParsedRange): Anchor[] { + const anchors: Anchor[] = []; + for (let line = range.start.line; line <= range.end.line; line++) { + const hash = + line === range.start.line ? range.start.hash : line === range.end.line ? range.end.hash : RANGE_INTERIOR_HASH; + anchors.push({ line, hash }); + } + return anchors; +} + +function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): HashlineCursor { + if (raw === "BOF") return { kind: "bof" }; + if (raw === "EOF") return { kind: "eof" }; + const cursorKind = kind === "before" ? "before_anchor" : "after_anchor"; + return { kind: cursorKind, anchor: parseLid(raw, lineNum) }; +} + +const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/; +const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/; +const DELETE_OP_RE = /^-\s*(\S+)$/; +const REPLACE_OP_RE = /^=\s*(\S+)$/; +const INLINE_BEFORE_OP_RE = new RegExp(`^<\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`); +const INLINE_AFTER_OP_RE = new RegExp(`^\\+\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`); + +export function cloneCursor(cursor: HashlineCursor): HashlineCursor { + if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } }; + if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } }; + return cursor; +} + +function collectPayload( + lines: string[], + startIndex: number, + opLineNum: number, + requirePayload: boolean, +): { payload: string[]; nextIndex: number } { + const payload: string[] = []; + let index = startIndex; + while (index < lines.length) { + const line = stripTrailingCarriageReturn(lines[index]); + if (!line.startsWith(HL_EDIT_SEP)) break; + payload.push(line.slice(1)); + index++; + } + if (payload.length === 0 && requirePayload) { + throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`); + } + return { payload, nextIndex: index }; +} + +export function parseHashline(diff: string): HashlineEdit[] { + return parseHashlineWithWarnings(diff).edits; +} + +export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } { + const edits: HashlineEdit[] = []; + const warnings: string[] = []; + const lines = diff.split("\n"); + let editIndex = 0; + + const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => { + edits.push({ kind: "insert", cursor: cloneCursor(cursor), text, lineNum, index: editIndex++ }); + }; + + for (let i = 0; i < lines.length; ) { + const lineNum = i + 1; + const line = stripTrailingCarriageReturn(lines[i]); + + if (line.trim().length === 0) { + i++; + continue; + } + if (line.startsWith(HL_EDIT_SEP)) { + throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`); + } + + const inlineBeforeMatch = INLINE_BEFORE_OP_RE.exec(line); + if (inlineBeforeMatch) { + const anchor = parseLid(`${inlineBeforeMatch[1]}${inlineBeforeMatch[2]}`, lineNum); + edits.push({ + kind: "modify", + anchor, + prefix: inlineBeforeMatch[3], + suffix: "", + lineNum, + index: editIndex++, + }); + const cursor: HashlineCursor = { kind: "before_anchor", anchor }; + const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false); + for (const text of payload) pushInsert(cursor, text, lineNum); + i = nextIndex; + continue; + } + + const inlineAfterMatch = INLINE_AFTER_OP_RE.exec(line); + if (inlineAfterMatch) { + const anchor = parseLid(`${inlineAfterMatch[1]}${inlineAfterMatch[2]}`, lineNum); + edits.push({ + kind: "modify", + anchor, + prefix: "", + suffix: inlineAfterMatch[3], + lineNum, + index: editIndex++, + }); + const cursor: HashlineCursor = { kind: "after_anchor", anchor }; + const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false); + for (const text of payload) pushInsert(cursor, text, lineNum); + i = nextIndex; + continue; + } + + const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line); + if (insertBeforeMatch) { + const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before"); + const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true); + for (const text of payload) pushInsert(cursor, text, lineNum); + i = nextIndex; + continue; + } + + const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line); + if (insertAfterMatch) { + const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after"); + const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true); + for (const text of payload) pushInsert(cursor, text, lineNum); + i = nextIndex; + continue; + } + + const deleteMatch = DELETE_OP_RE.exec(line); + if (deleteMatch) { + for (const anchor of expandRange(parseRange(deleteMatch[1], lineNum))) { + edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ }); + } + i++; + continue; + } + + const replaceMatch = REPLACE_OP_RE.exec(line); + if (replaceMatch) { + const range = parseRange(replaceMatch[1], lineNum); + const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false); + // `= A..B` with no payload blanks the range to a single empty line. + const replacement = payload.length === 0 ? [""] : payload; + for (const text of replacement) { + edits.push({ + kind: "insert", + cursor: { kind: "before_anchor", anchor: { ...range.start } }, + text, + lineNum, + index: editIndex++, + }); + } + for (const anchor of expandRange(range)) { + edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ }); + } + i = nextIndex; + continue; + } + + throw new Error( + `line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or "${HL_EDIT_SEP}TEXT" payload lines. ` + + `Got ${JSON.stringify(line)}.`, + ); + } + + return { edits, warnings }; +} diff --git a/packages/coding-agent/src/hashline/prefixes.ts b/packages/coding-agent/src/hashline/prefixes.ts new file mode 100644 index 000000000..e24e67b82 --- /dev/null +++ b/packages/coding-agent/src/hashline/prefixes.ts @@ -0,0 +1,101 @@ +import { HL_BODY_SEP_RE_RAW } from "./hash"; + +const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`; +const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`); +const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`); +const DIFF_PLUS_RE = /^[+](?![+])/; +const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/; + +function stripLeadingHashlinePrefixes(line: string): string { + let result = line; + let previous: string; + do { + previous = result; + result = result.replace(HL_PREFIX_RE, ""); + } while (result !== previous); + return result; +} + +// ─────────────────────────────────────────────────────────────────────────── +// 5. Read-output prefix stripping +// +// When a model echoes back content from a `read` or `search` response, every +// line is prefixed with either a hashline tag (`123ab|`) or, for diff-style +// echoes, a leading `+`. These helpers detect that and recover the raw text. +// ─────────────────────────────────────────────────────────────────────────── + +type LinePrefixStats = { + nonEmpty: number; + hashPrefixCount: number; + diffPlusHashPrefixCount: number; + diffPlusCount: number; + truncationNoticeCount: number; +}; + +function collectLinePrefixStats(lines: string[]): LinePrefixStats { + const stats: LinePrefixStats = { + nonEmpty: 0, + hashPrefixCount: 0, + diffPlusHashPrefixCount: 0, + diffPlusCount: 0, + truncationNoticeCount: 0, + }; + + for (const line of lines) { + if (line.length === 0) continue; + if (READ_TRUNCATION_NOTICE_RE.test(line)) { + stats.truncationNoticeCount++; + continue; + } + stats.nonEmpty++; + if (HL_PREFIX_RE.test(line)) stats.hashPrefixCount++; + if (HL_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++; + if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++; + } + return stats; +} + +export function stripNewLinePrefixes(lines: string[]): string[] { + const stats = collectLinePrefixStats(lines); + if (stats.nonEmpty === 0) return lines; + + const stripHash = stats.hashPrefixCount > 0 && stats.hashPrefixCount === stats.nonEmpty; + const stripPlus = + !stripHash && + stats.diffPlusHashPrefixCount === 0 && + stats.diffPlusCount > 0 && + stats.diffPlusCount >= stats.nonEmpty * 0.5; + + if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines; + + return lines + .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)) + .map(line => { + if (stripHash) return stripLeadingHashlinePrefixes(line); + if (stripPlus) return line.replace(DIFF_PLUS_RE, ""); + if (stats.diffPlusHashPrefixCount > 0 && HL_PREFIX_PLUS_RE.test(line)) { + return line.replace(HL_PREFIX_RE, ""); + } + return line; + }); +} + +export function stripHashlinePrefixes(lines: string[]): string[] { + const stats = collectLinePrefixStats(lines); + if (stats.nonEmpty === 0) return lines; + if (stats.hashPrefixCount !== stats.nonEmpty) return lines; + return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line)); +} + +/** + * Normalize line payloads by stripping read/search line prefixes. `null` / + * `undefined` yield `[]`; a single multiline string is split on `\n`. + */ +export function hashlineParseText(edit: string[] | string | null | undefined): string[] { + if (edit == null) return []; + if (typeof edit === "string") { + const trimmed = edit.endsWith("\n") ? edit.slice(0, -1) : edit; + edit = trimmed.replaceAll("\r", "").split("\n"); + } + return stripNewLinePrefixes(edit); +} diff --git a/packages/coding-agent/src/hashline/recovery.ts b/packages/coding-agent/src/hashline/recovery.ts new file mode 100644 index 000000000..a1a24f84f --- /dev/null +++ b/packages/coding-agent/src/hashline/recovery.ts @@ -0,0 +1,72 @@ +import * as Diff from "diff"; +import { generateDiffString } from "../edit/diff"; +import type { FileReadCache } from "../edit/file-read-cache"; +import { HashlineMismatchError } from "./anchors"; +import { applyHashlineEdits, type HashlineApplyResult } from "./apply"; +import type { HashlineApplyOptions, HashlineEdit } from "./types"; + +export interface HashlineRecoveryArgs { + cache: FileReadCache; + absolutePath: string; + currentText: string; + edits: HashlineEdit[]; + options: HashlineApplyOptions; +} + +export interface HashlineRecoveryResult { + lines: string; + firstChangedLine: number | undefined; + warnings: string[]; +} + +const HASHLINE_RECOVERY_FUZZ_FACTOR = 3; + +const HASHLINE_RECOVERY_WARNING = + "Recovered from stale anchors using a previous read snapshot (file changed externally between read and edit)."; + +/** + * Attempt to recover from a `HashlineMismatchError` by replaying the edits + * against a cached pre-edit snapshot of the file and 3-way-merging the result + * onto the current on-disk content. Returns `null` when no recovery is + * possible — callers should propagate the original mismatch error in that + * case. + */ +export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): HashlineRecoveryResult | null { + const { cache, absolutePath, currentText, edits, options } = args; + const snapshot = cache.get(absolutePath); + if (!snapshot || snapshot.lines.size === 0) return null; + + const overlaid = currentText.split("\n"); + let maxCachedLine = 0; + for (const lineNum of snapshot.lines.keys()) { + if (lineNum > maxCachedLine) maxCachedLine = lineNum; + } + while (overlaid.length < maxCachedLine) overlaid.push(""); + for (const [lineNum, content] of snapshot.lines) { + overlaid[lineNum - 1] = content; + } + const previousText = overlaid.join("\n"); + if (previousText === currentText) return null; + + let applied: HashlineApplyResult; + try { + applied = applyHashlineEdits(previousText, edits, options); + } catch (err) { + if (err instanceof HashlineMismatchError) return null; + throw err; + } + if (applied.lines === previousText) return null; + + const patch = Diff.structuredPatch("file", "file", previousText, applied.lines, "", "", { context: 3 }); + const merged = Diff.applyPatch(currentText, patch, { fuzzFactor: HASHLINE_RECOVERY_FUZZ_FACTOR }); + if (typeof merged !== "string" || merged === currentText) return null; + + const mergedDiff = generateDiffString(currentText, merged); + const recoveryWarnings = [HASHLINE_RECOVERY_WARNING, ...(applied.warnings ?? [])]; + + return { + lines: merged, + firstChangedLine: mergedDiff.firstChangedLine ?? applied.firstChangedLine, + warnings: recoveryWarnings, + }; +} diff --git a/packages/coding-agent/src/hashline/stream.ts b/packages/coding-agent/src/hashline/stream.ts new file mode 100644 index 000000000..6e241430b --- /dev/null +++ b/packages/coding-agent/src/hashline/stream.ts @@ -0,0 +1,123 @@ +import { formatHashLine } from "./hash"; +import type { HashlineStreamOptions } from "./types"; + +interface ResolvedHashlineStreamOptions { + startLine: number; + maxChunkLines: number; + maxChunkBytes: number; +} + +function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedHashlineStreamOptions { + return { + startLine: options.startLine ?? 1, + maxChunkLines: options.maxChunkLines ?? 200, + maxChunkBytes: options.maxChunkBytes ?? 64 * 1024, + }; +} + +interface HashlineChunkEmitter { + pushLine: (line: string) => string[]; + flush: () => string | undefined; +} + +function createHashlineChunkEmitter(options: ResolvedHashlineStreamOptions): 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 = formatHashLine(lineNumber, line); + lineNumber++; + + const chunks: string[] = []; + const sepBytes = outLines.length === 0 ? 0 : 1; + const lineBytes = Buffer.byteLength(formatted, "utf-8"); + const wouldOverflow = + outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes; + + if (outLines.length > 0 && wouldOverflow) { + const flushed = flush(); + if (flushed) chunks.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) chunks.push(flushed); + } + return chunks; + }; + + return { pushLine, flush }; +} + +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(); + } +} + +export async function* streamHashLinesFromUtf8( + source: ReadableStream | AsyncIterable, + options: HashlineStreamOptions = {}, +): AsyncGenerator { + const resolved = resolveHashlineStreamOptions(options); + const decoder = new TextDecoder("utf-8"); + const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source; + const emitter = createHashlineChunkEmitter(resolved); + + let pending = ""; + let sawAnyLine = false; + + for await (const chunk of chunks) { + pending += decoder.decode(chunk, { stream: true }); + let nl = pending.indexOf("\n"); + while (nl !== -1) { + const raw = pending.slice(0, nl); + const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; + sawAnyLine = true; + for (const out of emitter.pushLine(line)) yield out; + pending = pending.slice(nl + 1); + nl = pending.indexOf("\n"); + } + } + + pending += decoder.decode(); + if (pending.length > 0) { + sawAnyLine = true; + const tail = pending.endsWith("\r") ? pending.slice(0, -1) : pending; + for (const out of emitter.pushLine(tail)) yield out; + } + if (!sawAnyLine) { + for (const out of emitter.pushLine("")) yield out; + } + + const last = emitter.flush(); + if (last) yield last; +} diff --git a/packages/coding-agent/src/hashline/types.ts b/packages/coding-agent/src/hashline/types.ts new file mode 100644 index 000000000..3c6e12c01 --- /dev/null +++ b/packages/coding-agent/src/hashline/types.ts @@ -0,0 +1,69 @@ +import type { Static } from "@sinclair/typebox"; +import { Type } from "@sinclair/typebox"; +import type { LspBatchRequest } from "../edit/renderer"; +import type { WritethroughCallback, WritethroughDeferredHandle } from "../lsp"; +import type { ToolSession } from "../tools"; + +export interface HashMismatch { + line: number; + expected: string; + actual: string; +} + +export type Anchor = { + line: number; + hash: string; + contentHint?: string; +}; + +export type HashlineCursor = + | { kind: "bof" } + | { kind: "eof" } + | { kind: "before_anchor"; anchor: Anchor } + | { kind: "after_anchor"; anchor: Anchor }; + +export type HashlineEdit = + | { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number } + | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string } + | { kind: "modify"; anchor: Anchor; prefix: string; suffix: string; lineNum: number; index: number }; + +export const hashlineEditParamsSchema = Type.Object({ input: Type.String() }); +export type HashlineParams = Static; + +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; +} + +export interface CompactHashlineDiffPreview { + preview: string; + addedLines: number; + removedLines: number; +} + +export interface CompactHashlineDiffOptions { + /** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */ + maxUnchangedRun?: number; +} +export interface HashlineApplyOptions { + autoDropPureInsertDuplicates?: boolean; +} + +export interface SplitHashlineOptions { + cwd?: string; + path?: string; +} + +export interface ExecuteHashlineSingleOptions { + session: ToolSession; + input: string; + path?: string; + signal?: AbortSignal; + batchRequest?: LspBatchRequest; + writethrough: WritethroughCallback; + beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle; +} diff --git a/packages/coding-agent/src/hashline/utils.ts b/packages/coding-agent/src/hashline/utils.ts new file mode 100644 index 000000000..260e83aed --- /dev/null +++ b/packages/coding-agent/src/hashline/utils.ts @@ -0,0 +1,3 @@ +export function stripTrailingCarriageReturn(line: string): string { + return line.endsWith("\r") ? line.slice(0, -1) : line; +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index f0fc7a132..3641452fa 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -16,7 +16,6 @@ export type * from "./config/prompt-templates"; export * from "./config/prompt-templates"; export type { RetrySettings, SkillsSettings } from "./config/settings"; export { Settings, settings } from "./config/settings"; -export * from "./edit/modes/hashline"; // Custom commands export type * from "./extensibility/custom-commands/types"; export type * from "./extensibility/custom-tools"; @@ -30,6 +29,7 @@ export * from "./extensibility/extensions"; export * from "./extensibility/skills"; // Slash commands export { type FileSlashCommand, loadSlashCommands as discoverSlashCommands } from "./extensibility/slash-commands"; +export * from "./hashline"; export type * from "./lsp"; // Main entry point export * from "./main"; diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 438218355..ed07f0e0c 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -5,8 +5,8 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; -import { computeLineHash, HL_BODY_SEP } from "../edit/line-hash"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; +import { computeLineHash, HL_BODY_SEP } from "../hashline/hash"; import type { Theme } from "../modes/theme/theme"; import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" }; import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; diff --git a/packages/coding-agent/src/tools/match-line-format.ts b/packages/coding-agent/src/tools/match-line-format.ts index 8f113915d..301323ae9 100644 --- a/packages/coding-agent/src/tools/match-line-format.ts +++ b/packages/coding-agent/src/tools/match-line-format.ts @@ -1,4 +1,4 @@ -import { computeLineHash } from "../edit/line-hash"; +import { computeLineHash } from "../hashline/hash"; /** * Format a single line of match output for grep/ast-grep style results. diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 49f35d82f..4ca020140 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -9,9 +9,9 @@ import { Text } from "@oh-my-pi/pi-tui"; import { getRemoteDir, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { getFileReadCache } from "../edit/file-read-cache"; -import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../edit/line-hash"; import { isNotebookPath, readEditableNotebookText } from "../edit/notebook"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; +import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../hashline/hash"; import { parseInternalUrl } from "../internal-urls/parse"; import type { InternalUrl } from "../internal-urls/types"; import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/utils/file-mentions.ts b/packages/coding-agent/src/utils/file-mentions.ts index 9f7056d61..55d73bc32 100644 --- a/packages/coding-agent/src/utils/file-mentions.ts +++ b/packages/coding-agent/src/utils/file-mentions.ts @@ -11,7 +11,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { glob } from "@oh-my-pi/pi-natives"; import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils"; -import { formatHashLines } from "../edit/line-hash"; +import { formatHashLines } from "../hashline/hash"; import type { FileMentionMessage } from "../session/messages"; import { DEFAULT_MAX_BYTES, diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index 09b1efbd3..7fd399a1e 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -9,7 +9,7 @@ import { DEFAULT_FUZZY_THRESHOLD, findMatch, } from "@oh-my-pi/pi-coding-agent/edit"; -import { HL_EDIT_SEP } from "@oh-my-pi/pi-coding-agent/edit/line-hash"; +import { HL_EDIT_SEP } from "@oh-my-pi/pi-coding-agent/hashline/hash"; describe("findMatch", () => { describe("exact matching", () => {